Skip to content

Commit 1ca9640

Browse files
mikolalysenkoclaude
andcommitted
Merge origin/main into fix/telemetry-depscan-compat
Picks up #1369 (squashed) and #1343. scan/hosted.rs and scan/mod.rs on main are identical to the v5/json-envelope-unify tip already merged, so both conflicts resolve to the existing resolution. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2 parents 1df3963 + 69542db commit 1ca9640

5 files changed

Lines changed: 396 additions & 109 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)