Skip to content

Commit dc0218f

Browse files
Restore NuGet contentHash, not catalog packageHash (#624) (#1343)
* Start refactor for #594 Assisted-by: Claude Code:claude-opus-5-5 * Wire vendored nuget.config via formats::nuget Vendored NuGet now reads the source keys and finds the <packageSources>, <packageSourceMapping> and <configuration> anchors through formats::nuget::parse_config, the reader that hosted, upstream restore and VEX already use. The private substring scanner (blank_comments, parse_config_source_keys, attr_value, self_closing_package_sources, insert_at_line) is deleted. User impact: - A close tag written with whitespace (</packageSources >) is now the section that gets extended; vendor used to append a second section NuGet ignores, so restore failed NU1100/NU1403 (#685). - An empty <packageSourceMapping /> is expanded in place instead of left beside a second mapping section. - A section opened and closed on one line receives the source inside it, not before its open tag. - Catch-all keys are written XML-encoded, so a key with & or a quote keeps its identity. - Malformed XML or a repeated section is refused with "malformed XML or a repeated section; not wired" instead of being spliced at the first substring match, as hosted already does. Output bytes for well-formed configs are unchanged. Fixes #685 Refs #594 Assisted-by: Claude Code:claude-opus-5-5 * Start NuGet fix: nuget-content-hash Draft placeholder while the fix is written. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Restore NuGet contentHash, not catalog hash Undoing a hosted NuGet patch (remove, rollback, and the hosted to vendored takeover) put nuget.org's catalog packageHash back into packages.lock.json. That is the SHA-512 of the signed .nupkg file, while NuGet's contentHash excludes the repository signature, so every later dotnet restore failed NU1403 for practically every nuget.org package. The upstream restore now downloads the .nupkg from nuget.org's flat container and computes the content hash the way NuGet does (SignedPackageArchiveUtility.GetPackageContentHash: the archive hashed as if the .signature.p7s entry were absent). Verified against the live Newtonsoft.Json 13.0.3 package: HrC5BXdl...gPa+zQ==, the value dotnet restore writes. Fixes #624. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Refuse NuGet archives with out-of-bounds records A central-directory record whose extra or comment length ran past the end of the archive, or a signature entry inconsistent with the directory sizes, could panic the content-hash computation (Bugbot on #1343). Both now refuse the archive instead. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 10874cc commit dc0218f

5 files changed

Lines changed: 395 additions & 108 deletions

File tree

‎crates/socket-patch-core/src/formats/nuget/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
//! attribute or a mismatched close tag makes the whole file `None`.
1212
1313
pub(crate) mod lock;
14+
pub(crate) mod package;
1415

1516
use std::collections::BTreeSet;
1617
use std::ops::Range;
Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
//! A `.nupkg`'s content hash: the `contentHash` NuGet writes into
2+
//! `packages.lock.json` and `.nupkg.metadata` (#624).
3+
//!
4+
//! For an unsigned package it is the base64 SHA-512 of the file. For a
5+
//! signed package — nuget.org repository-signs every package — NuGet hashes
6+
//! the archive AS IF the `.signature.p7s` entry were absent
7+
//! (`PackageArchiveReader.GetContentHash` →
8+
//! `SignedPackageArchiveUtility.GetPackageContentHash`):
9+
//!
10+
//! 1. the bytes before the first (non-signature) local file entry;
11+
//! 2. every non-signature file entry (local header, data, data
12+
//! descriptor), in archive order;
13+
//! 3. every non-signature central directory record, in directory order,
14+
//! with its local-header offset moved back by the signature entry's size
15+
//! when the entry it points at follows the signature;
16+
//! 4. the end-of-central-directory record with the entry counts one lower,
17+
//! the directory size less the signature's record and the directory
18+
//! offset less the signature entry's size, then the rest of the file.
19+
//!
20+
//! So the catalog `packageHash` (SHA-512 of the signed file as served) is
21+
//! NOT a lock's `contentHash`, and pinning it fails every restore NU1403.
22+
//! Zip64 archives are refused rather than guessed at.
23+
24+
use sha2::{Digest, Sha512};
25+
26+
/// The signature entry NuGet excludes (`SigningSpecifications.SignaturePath`).
27+
const SIGNATURE_PATH: &[u8] = b".signature.p7s";
28+
29+
const EOCD_SIG: u32 = 0x0605_4b50;
30+
const ZIP64_LOCATOR_SIG: u32 = 0x0706_4b50;
31+
const CENTRAL_SIG: u32 = 0x0201_4b50;
32+
const LOCAL_SIG: u32 = 0x0403_4b50;
33+
const DESCRIPTOR_SIG: u32 = 0x0807_4b50;
34+
const EOCD_LEN: usize = 22;
35+
36+
fn u16_at(b: &[u8], at: usize) -> Result<u16, String> {
37+
b.get(at..at + 2)
38+
.map(|s| u16::from_le_bytes([s[0], s[1]]))
39+
.ok_or_else(|| truncated(at))
40+
}
41+
42+
fn u32_at(b: &[u8], at: usize) -> Result<u32, String> {
43+
b.get(at..at + 4)
44+
.map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
45+
.ok_or_else(|| truncated(at))
46+
}
47+
48+
fn truncated(at: usize) -> String {
49+
format!("the package archive is truncated at byte {at}")
50+
}
51+
52+
/// One central directory record and the file entry it describes.
53+
struct Record {
54+
/// Offset of the central directory record.
55+
position: usize,
56+
header_size: usize,
57+
local_offset: usize,
58+
/// Local header + data + data descriptor.
59+
entry_size: usize,
60+
is_signature: bool,
61+
}
62+
63+
/// The base64 SHA-512 NuGet records as `contentHash` for `nupkg`.
64+
pub(crate) fn package_content_hash(nupkg: &[u8]) -> Result<String, String> {
65+
use base64::Engine as _;
66+
let eocd = find_eocd(nupkg)?;
67+
if eocd >= 20 && u32_at(nupkg, eocd - 20)? == ZIP64_LOCATOR_SIG {
68+
return Err("zip64 package archives are not supported".to_string());
69+
}
70+
let entries_disk = u16_at(nupkg, eocd + 8)?;
71+
let entries = u16_at(nupkg, eocd + 10)?;
72+
let cd_size = u32_at(nupkg, eocd + 12)?;
73+
let cd_offset = u32_at(nupkg, eocd + 16)?;
74+
if entries == u16::MAX || cd_size == u32::MAX || cd_offset == u32::MAX {
75+
return Err("zip64 package archives are not supported".to_string());
76+
}
77+
if entries_disk != entries || u16_at(nupkg, eocd + 4)? != 0 || u16_at(nupkg, eocd + 6)? != 0 {
78+
return Err("multi-disk package archives are not supported".to_string());
79+
}
80+
let mut records = Vec::with_capacity(entries as usize);
81+
let mut at = cd_offset as usize;
82+
for _ in 0..entries {
83+
if u32_at(nupkg, at)? != CENTRAL_SIG {
84+
return Err(format!("no central directory record at byte {at}"));
85+
}
86+
let flags = u16_at(nupkg, at + 8)?;
87+
let compressed = u32_at(nupkg, at + 20)? as usize;
88+
let name_len = u16_at(nupkg, at + 28)? as usize;
89+
let extra_len = u16_at(nupkg, at + 30)? as usize;
90+
let comment_len = u16_at(nupkg, at + 32)? as usize;
91+
let local_offset = u32_at(nupkg, at + 42)? as usize;
92+
let name = nupkg
93+
.get(at + 46..at + 46 + name_len)
94+
.ok_or_else(|| truncated(at + 46))?;
95+
if u32_at(nupkg, local_offset)? != LOCAL_SIG {
96+
return Err(format!("no local file header at byte {local_offset}"));
97+
}
98+
let local_header = 30
99+
+ u16_at(nupkg, local_offset + 26)? as usize
100+
+ u16_at(nupkg, local_offset + 28)? as usize;
101+
let mut entry_size = local_header + compressed;
102+
if flags & 0x0008 != 0 {
103+
// A data descriptor follows the data, with or without its
104+
// optional signature.
105+
let d = local_offset + entry_size;
106+
entry_size += if u32_at(nupkg, d)? == DESCRIPTOR_SIG {
107+
16
108+
} else {
109+
12
110+
};
111+
}
112+
if local_offset + entry_size > nupkg.len() {
113+
return Err(truncated(local_offset + entry_size));
114+
}
115+
let header_size = 46 + name_len + extra_len + comment_len;
116+
// The whole record is hashed below: it must lie inside the archive.
117+
if at + header_size > nupkg.len() {
118+
return Err(truncated(at + header_size));
119+
}
120+
records.push(Record {
121+
position: at,
122+
header_size,
123+
local_offset,
124+
entry_size,
125+
is_signature: name == SIGNATURE_PATH,
126+
});
127+
at += header_size;
128+
}
129+
let mut signatures = records.iter().filter(|r| r.is_signature);
130+
let signature = match (signatures.next(), signatures.next()) {
131+
(None, _) => return Ok(crate::utils::digest::sha512_base64_of(nupkg)),
132+
(Some(sig), None) => (sig.local_offset, sig.entry_size, sig.header_size),
133+
(Some(_), Some(_)) => return Err("the package has two signature entries".to_string()),
134+
};
135+
let (sig_offset, sig_entry_size, sig_header_size) = signature;
136+
let mut rest: Vec<&Record> = records.iter().filter(|r| !r.is_signature).collect();
137+
if rest.is_empty() {
138+
return Err("the package holds nothing but its signature".to_string());
139+
}
140+
141+
let inconsistent =
142+
|| "the package's signature entry is inconsistent with its directory".to_string();
143+
let mut hash = Sha512::new();
144+
rest.sort_by_key(|r| r.local_offset);
145+
hash.update(&nupkg[..rest[0].local_offset]);
146+
for r in &rest {
147+
hash.update(&nupkg[r.local_offset..r.local_offset + r.entry_size]);
148+
}
149+
rest.sort_by_key(|r| r.position);
150+
for r in &rest {
151+
hash.update(&nupkg[r.position..r.position + 42]);
152+
let offset = if r.local_offset > sig_offset {
153+
r.local_offset - sig_entry_size
154+
} else {
155+
r.local_offset
156+
};
157+
hash.update(
158+
u32::try_from(offset)
159+
.map_err(|_| inconsistent())?
160+
.to_le_bytes(),
161+
);
162+
hash.update(&nupkg[r.position + 46..r.position + r.header_size]);
163+
}
164+
hash.update(&nupkg[eocd..eocd + 8]);
165+
hash.update((entries_disk - 1).to_le_bytes());
166+
hash.update((entries - 1).to_le_bytes());
167+
let cd_size = u32::try_from(sig_header_size)
168+
.ok()
169+
.and_then(|n| cd_size.checked_sub(n))
170+
.ok_or_else(inconsistent)?;
171+
let cd_offset = u32::try_from(sig_entry_size)
172+
.ok()
173+
.and_then(|n| cd_offset.checked_sub(n))
174+
.ok_or_else(inconsistent)?;
175+
hash.update(cd_size.to_le_bytes());
176+
hash.update(cd_offset.to_le_bytes());
177+
hash.update(&nupkg[eocd + 20..]);
178+
Ok(base64::engine::general_purpose::STANDARD.encode(hash.finalize()))
179+
}
180+
181+
/// Offset of the end-of-central-directory record: the last signature whose
182+
/// comment length reaches exactly to the end of the file.
183+
fn find_eocd(b: &[u8]) -> Result<usize, String> {
184+
if b.len() < EOCD_LEN {
185+
return Err("the package is not a zip archive".to_string());
186+
}
187+
let floor = b.len().saturating_sub(EOCD_LEN + u16::MAX as usize);
188+
(floor..=b.len() - EOCD_LEN)
189+
.rev()
190+
.find(|&at| {
191+
u32_at(b, at) == Ok(EOCD_SIG)
192+
&& u16_at(b, at + 20).is_ok_and(|c| at + EOCD_LEN + c as usize == b.len())
193+
})
194+
.ok_or_else(|| "the package is not a zip archive".to_string())
195+
}
196+
197+
#[cfg(test)]
198+
mod tests {
199+
use super::*;
200+
use std::io::Write as _;
201+
202+
fn zip(entries: &[(&str, &[u8])], descriptor_free: bool) -> Vec<u8> {
203+
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
204+
let opts = zip::write::SimpleFileOptions::default()
205+
.last_modified_time(zip::DateTime::default())
206+
.compression_method(if descriptor_free {
207+
zip::CompressionMethod::Stored
208+
} else {
209+
zip::CompressionMethod::Deflated
210+
});
211+
for (name, data) in entries {
212+
zw.start_file(*name, opts).unwrap();
213+
zw.write_all(data).unwrap();
214+
}
215+
zw.finish().unwrap().into_inner()
216+
}
217+
218+
const FILES: [(&str, &[u8]); 3] = [
219+
("[Content_Types].xml", b"<?xml version=\"1.0\"?><Types/>"),
220+
(
221+
"pkg.nuspec",
222+
b"<package><metadata><id>Pkg</id></metadata></package>",
223+
),
224+
(
225+
"lib/net8.0/Pkg.dll",
226+
b"MZ-not-really-an-assembly-but-long-enough",
227+
),
228+
];
229+
230+
#[test]
231+
fn unsigned_package_hashes_the_whole_file() {
232+
let bytes = zip(&FILES, true);
233+
assert_eq!(
234+
package_content_hash(&bytes).unwrap(),
235+
crate::utils::digest::sha512_base64_of(&bytes)
236+
);
237+
}
238+
239+
/// A signature appended last (where NuGet places it) hashes exactly like
240+
/// the same archive written without it.
241+
#[test]
242+
fn signed_package_hashes_as_if_unsigned() {
243+
for stored in [true, false] {
244+
let unsigned = zip(&FILES, stored);
245+
let mut with_sig: Vec<(&str, &[u8])> = FILES.to_vec();
246+
with_sig.push((".signature.p7s", b"PKCS7-signature-bytes"));
247+
let signed = zip(&with_sig, stored);
248+
let hash = package_content_hash(&signed).unwrap();
249+
assert_ne!(hash, crate::utils::digest::sha512_base64_of(&signed));
250+
assert_eq!(hash, crate::utils::digest::sha512_base64_of(&unsigned));
251+
}
252+
}
253+
254+
/// A signature that is not the last entry: the entries after it have
255+
/// their offsets moved back by its size.
256+
#[test]
257+
fn signature_in_the_middle_is_excluded_with_offsets_fixed() {
258+
let unsigned = zip(&FILES, true);
259+
let signed = zip(
260+
&[
261+
FILES[0],
262+
(".signature.p7s", b"PKCS7-signature-bytes"),
263+
FILES[1],
264+
FILES[2],
265+
],
266+
true,
267+
);
268+
assert_eq!(
269+
package_content_hash(&signed).unwrap(),
270+
crate::utils::digest::sha512_base64_of(&unsigned)
271+
);
272+
}
273+
274+
#[test]
275+
fn malformed_archives_are_refused() {
276+
assert!(package_content_hash(b"").is_err());
277+
assert!(package_content_hash(b"not a zip at all, just some bytes").is_err());
278+
let mut bytes = zip(&FILES, true);
279+
bytes.truncate(bytes.len() / 2);
280+
assert!(package_content_hash(&bytes).is_err());
281+
// A central-directory record whose extra/comment lengths run past
282+
// the end of the archive is refused, not sliced out of bounds.
283+
let mut with_sig: Vec<(&str, &[u8])> = FILES.to_vec();
284+
with_sig.push((".signature.p7s", b"sig"));
285+
let mut bytes = zip(&with_sig, true);
286+
let eocd = find_eocd(&bytes).unwrap();
287+
let cd = u32_at(&bytes, eocd + 16).unwrap() as usize;
288+
bytes[cd + 32..cd + 34].copy_from_slice(&u16::MAX.to_le_bytes());
289+
assert!(package_content_hash(&bytes).is_err());
290+
}
291+
}

0 commit comments

Comments
 (0)