Repository navigation
Restore NuGet contentHash, not catalog packageHash (#624) #1343
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Mikola Lysenko (mikolalysenko)
merged 9 commits into
main
from
agent/v5-nuget-content-hash
Oct 10, 2026
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
9263005
Start refactor for #594
claude 3f929fd
Wire vendored nuget.config via formats::nuget
claude fbb08ff
Merge remote-tracking branch 'origin/arch-refactor/594-vendored-nuget…
mikolalysenko 35fd42c
Start NuGet fix: nuget-content-hash
mikolalysenko a5742c8
Restore NuGet contentHash, not catalog hash
mikolalysenko 320ce9e
Refuse NuGet archives with out-of-bounds records
mikolalysenko 1313a11
Merge origin/main into agent/v5-nuget-content-hash
mikolalysenko d9de181
Merge remote-tracking branch 'origin/main' into HEAD
mikolalysenko 1862baf
Use upstream() in #1340's member-lock restore test
mikolalysenko File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,291 @@ | ||
| //! A `.nupkg`'s content hash: the `contentHash` NuGet writes into | ||
| //! `packages.lock.json` and `.nupkg.metadata` (#624). | ||
| //! | ||
| //! For an unsigned package it is the base64 SHA-512 of the file. For a | ||
| //! signed package — nuget.org repository-signs every package — NuGet hashes | ||
| //! the archive AS IF the `.signature.p7s` entry were absent | ||
| //! (`PackageArchiveReader.GetContentHash` → | ||
| //! `SignedPackageArchiveUtility.GetPackageContentHash`): | ||
| //! | ||
| //! 1. the bytes before the first (non-signature) local file entry; | ||
| //! 2. every non-signature file entry (local header, data, data | ||
| //! descriptor), in archive order; | ||
| //! 3. every non-signature central directory record, in directory order, | ||
| //! with its local-header offset moved back by the signature entry's size | ||
| //! when the entry it points at follows the signature; | ||
| //! 4. the end-of-central-directory record with the entry counts one lower, | ||
| //! the directory size less the signature's record and the directory | ||
| //! offset less the signature entry's size, then the rest of the file. | ||
| //! | ||
| //! So the catalog `packageHash` (SHA-512 of the signed file as served) is | ||
| //! NOT a lock's `contentHash`, and pinning it fails every restore NU1403. | ||
| //! Zip64 archives are refused rather than guessed at. | ||
|
|
||
| use sha2::{Digest, Sha512}; | ||
|
|
||
| /// The signature entry NuGet excludes (`SigningSpecifications.SignaturePath`). | ||
| const SIGNATURE_PATH: &[u8] = b".signature.p7s"; | ||
|
|
||
| const EOCD_SIG: u32 = 0x0605_4b50; | ||
| const ZIP64_LOCATOR_SIG: u32 = 0x0706_4b50; | ||
| const CENTRAL_SIG: u32 = 0x0201_4b50; | ||
| const LOCAL_SIG: u32 = 0x0403_4b50; | ||
| const DESCRIPTOR_SIG: u32 = 0x0807_4b50; | ||
| const EOCD_LEN: usize = 22; | ||
|
|
||
| fn u16_at(b: &[u8], at: usize) -> Result<u16, String> { | ||
| b.get(at..at + 2) | ||
| .map(|s| u16::from_le_bytes([s[0], s[1]])) | ||
| .ok_or_else(|| truncated(at)) | ||
| } | ||
|
|
||
| fn u32_at(b: &[u8], at: usize) -> Result<u32, String> { | ||
| b.get(at..at + 4) | ||
| .map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]])) | ||
| .ok_or_else(|| truncated(at)) | ||
| } | ||
|
|
||
| fn truncated(at: usize) -> String { | ||
| format!("the package archive is truncated at byte {at}") | ||
| } | ||
|
|
||
| /// One central directory record and the file entry it describes. | ||
| struct Record { | ||
| /// Offset of the central directory record. | ||
| position: usize, | ||
| header_size: usize, | ||
| local_offset: usize, | ||
| /// Local header + data + data descriptor. | ||
| entry_size: usize, | ||
| is_signature: bool, | ||
| } | ||
|
|
||
| /// The base64 SHA-512 NuGet records as `contentHash` for `nupkg`. | ||
| pub(crate) fn package_content_hash(nupkg: &[u8]) -> Result<String, String> { | ||
| use base64::Engine as _; | ||
| let eocd = find_eocd(nupkg)?; | ||
| if eocd >= 20 && u32_at(nupkg, eocd - 20)? == ZIP64_LOCATOR_SIG { | ||
| return Err("zip64 package archives are not supported".to_string()); | ||
| } | ||
| let entries_disk = u16_at(nupkg, eocd + 8)?; | ||
| let entries = u16_at(nupkg, eocd + 10)?; | ||
| let cd_size = u32_at(nupkg, eocd + 12)?; | ||
| let cd_offset = u32_at(nupkg, eocd + 16)?; | ||
| if entries == u16::MAX || cd_size == u32::MAX || cd_offset == u32::MAX { | ||
| return Err("zip64 package archives are not supported".to_string()); | ||
| } | ||
| if entries_disk != entries || u16_at(nupkg, eocd + 4)? != 0 || u16_at(nupkg, eocd + 6)? != 0 { | ||
| return Err("multi-disk package archives are not supported".to_string()); | ||
| } | ||
| let mut records = Vec::with_capacity(entries as usize); | ||
| let mut at = cd_offset as usize; | ||
| for _ in 0..entries { | ||
| if u32_at(nupkg, at)? != CENTRAL_SIG { | ||
| return Err(format!("no central directory record at byte {at}")); | ||
| } | ||
| let flags = u16_at(nupkg, at + 8)?; | ||
| let compressed = u32_at(nupkg, at + 20)? as usize; | ||
| let name_len = u16_at(nupkg, at + 28)? as usize; | ||
| let extra_len = u16_at(nupkg, at + 30)? as usize; | ||
| let comment_len = u16_at(nupkg, at + 32)? as usize; | ||
| let local_offset = u32_at(nupkg, at + 42)? as usize; | ||
| let name = nupkg | ||
| .get(at + 46..at + 46 + name_len) | ||
| .ok_or_else(|| truncated(at + 46))?; | ||
| if u32_at(nupkg, local_offset)? != LOCAL_SIG { | ||
| return Err(format!("no local file header at byte {local_offset}")); | ||
| } | ||
| let local_header = 30 | ||
| + u16_at(nupkg, local_offset + 26)? as usize | ||
| + u16_at(nupkg, local_offset + 28)? as usize; | ||
| let mut entry_size = local_header + compressed; | ||
| if flags & 0x0008 != 0 { | ||
| // A data descriptor follows the data, with or without its | ||
| // optional signature. | ||
| let d = local_offset + entry_size; | ||
| entry_size += if u32_at(nupkg, d)? == DESCRIPTOR_SIG { | ||
| 16 | ||
| } else { | ||
| 12 | ||
| }; | ||
| } | ||
| if local_offset + entry_size > nupkg.len() { | ||
| return Err(truncated(local_offset + entry_size)); | ||
| } | ||
| let header_size = 46 + name_len + extra_len + comment_len; | ||
| // The whole record is hashed below: it must lie inside the archive. | ||
| if at + header_size > nupkg.len() { | ||
| return Err(truncated(at + header_size)); | ||
| } | ||
| records.push(Record { | ||
| position: at, | ||
| header_size, | ||
| local_offset, | ||
| entry_size, | ||
| is_signature: name == SIGNATURE_PATH, | ||
| }); | ||
| at += header_size; | ||
| } | ||
| let mut signatures = records.iter().filter(|r| r.is_signature); | ||
| let signature = match (signatures.next(), signatures.next()) { | ||
| (None, _) => return Ok(crate::utils::digest::sha512_base64_of(nupkg)), | ||
| (Some(sig), None) => (sig.local_offset, sig.entry_size, sig.header_size), | ||
| (Some(_), Some(_)) => return Err("the package has two signature entries".to_string()), | ||
| }; | ||
| let (sig_offset, sig_entry_size, sig_header_size) = signature; | ||
| let mut rest: Vec<&Record> = records.iter().filter(|r| !r.is_signature).collect(); | ||
| if rest.is_empty() { | ||
| return Err("the package holds nothing but its signature".to_string()); | ||
| } | ||
|
|
||
| let inconsistent = | ||
| || "the package's signature entry is inconsistent with its directory".to_string(); | ||
| let mut hash = Sha512::new(); | ||
| rest.sort_by_key(|r| r.local_offset); | ||
| hash.update(&nupkg[..rest[0].local_offset]); | ||
| for r in &rest { | ||
| hash.update(&nupkg[r.local_offset..r.local_offset + r.entry_size]); | ||
| } | ||
| rest.sort_by_key(|r| r.position); | ||
| for r in &rest { | ||
| hash.update(&nupkg[r.position..r.position + 42]); | ||
| let offset = if r.local_offset > sig_offset { | ||
| r.local_offset - sig_entry_size | ||
| } else { | ||
| r.local_offset | ||
| }; | ||
| hash.update( | ||
| u32::try_from(offset) | ||
| .map_err(|_| inconsistent())? | ||
| .to_le_bytes(), | ||
| ); | ||
| hash.update(&nupkg[r.position + 46..r.position + r.header_size]); | ||
| } | ||
| hash.update(&nupkg[eocd..eocd + 8]); | ||
| hash.update((entries_disk - 1).to_le_bytes()); | ||
| hash.update((entries - 1).to_le_bytes()); | ||
| let cd_size = u32::try_from(sig_header_size) | ||
| .ok() | ||
| .and_then(|n| cd_size.checked_sub(n)) | ||
| .ok_or_else(inconsistent)?; | ||
| let cd_offset = u32::try_from(sig_entry_size) | ||
| .ok() | ||
| .and_then(|n| cd_offset.checked_sub(n)) | ||
| .ok_or_else(inconsistent)?; | ||
| hash.update(cd_size.to_le_bytes()); | ||
| hash.update(cd_offset.to_le_bytes()); | ||
| hash.update(&nupkg[eocd + 20..]); | ||
| Ok(base64::engine::general_purpose::STANDARD.encode(hash.finalize())) | ||
| } | ||
|
|
||
| /// Offset of the end-of-central-directory record: the last signature whose | ||
| /// comment length reaches exactly to the end of the file. | ||
| fn find_eocd(b: &[u8]) -> Result<usize, String> { | ||
| if b.len() < EOCD_LEN { | ||
| return Err("the package is not a zip archive".to_string()); | ||
| } | ||
| let floor = b.len().saturating_sub(EOCD_LEN + u16::MAX as usize); | ||
| (floor..=b.len() - EOCD_LEN) | ||
| .rev() | ||
| .find(|&at| { | ||
| u32_at(b, at) == Ok(EOCD_SIG) | ||
| && u16_at(b, at + 20).is_ok_and(|c| at + EOCD_LEN + c as usize == b.len()) | ||
| }) | ||
| .ok_or_else(|| "the package is not a zip archive".to_string()) | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
| use std::io::Write as _; | ||
|
|
||
| fn zip(entries: &[(&str, &[u8])], descriptor_free: bool) -> Vec<u8> { | ||
| let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); | ||
| let opts = zip::write::SimpleFileOptions::default() | ||
| .last_modified_time(zip::DateTime::default()) | ||
| .compression_method(if descriptor_free { | ||
| zip::CompressionMethod::Stored | ||
| } else { | ||
| zip::CompressionMethod::Deflated | ||
| }); | ||
| for (name, data) in entries { | ||
| zw.start_file(*name, opts).unwrap(); | ||
| zw.write_all(data).unwrap(); | ||
| } | ||
| zw.finish().unwrap().into_inner() | ||
| } | ||
|
|
||
| const FILES: [(&str, &[u8]); 3] = [ | ||
| ("[Content_Types].xml", b"<?xml version=\"1.0\"?><Types/>"), | ||
| ( | ||
| "pkg.nuspec", | ||
| b"<package><metadata><id>Pkg</id></metadata></package>", | ||
| ), | ||
| ( | ||
| "lib/net8.0/Pkg.dll", | ||
| b"MZ-not-really-an-assembly-but-long-enough", | ||
| ), | ||
| ]; | ||
|
|
||
| #[test] | ||
| fn unsigned_package_hashes_the_whole_file() { | ||
| let bytes = zip(&FILES, true); | ||
| assert_eq!( | ||
| package_content_hash(&bytes).unwrap(), | ||
| crate::utils::digest::sha512_base64_of(&bytes) | ||
| ); | ||
| } | ||
|
|
||
| /// A signature appended last (where NuGet places it) hashes exactly like | ||
| /// the same archive written without it. | ||
| #[test] | ||
| fn signed_package_hashes_as_if_unsigned() { | ||
| for stored in [true, false] { | ||
| let unsigned = zip(&FILES, stored); | ||
| let mut with_sig: Vec<(&str, &[u8])> = FILES.to_vec(); | ||
| with_sig.push((".signature.p7s", b"PKCS7-signature-bytes")); | ||
| let signed = zip(&with_sig, stored); | ||
| let hash = package_content_hash(&signed).unwrap(); | ||
| assert_ne!(hash, crate::utils::digest::sha512_base64_of(&signed)); | ||
| assert_eq!(hash, crate::utils::digest::sha512_base64_of(&unsigned)); | ||
| } | ||
| } | ||
|
|
||
| /// A signature that is not the last entry: the entries after it have | ||
| /// their offsets moved back by its size. | ||
| #[test] | ||
| fn signature_in_the_middle_is_excluded_with_offsets_fixed() { | ||
| let unsigned = zip(&FILES, true); | ||
| let signed = zip( | ||
| &[ | ||
| FILES[0], | ||
| (".signature.p7s", b"PKCS7-signature-bytes"), | ||
| FILES[1], | ||
| FILES[2], | ||
| ], | ||
| true, | ||
| ); | ||
| assert_eq!( | ||
| package_content_hash(&signed).unwrap(), | ||
| crate::utils::digest::sha512_base64_of(&unsigned) | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn malformed_archives_are_refused() { | ||
| assert!(package_content_hash(b"").is_err()); | ||
| assert!(package_content_hash(b"not a zip at all, just some bytes").is_err()); | ||
| let mut bytes = zip(&FILES, true); | ||
| bytes.truncate(bytes.len() / 2); | ||
| assert!(package_content_hash(&bytes).is_err()); | ||
| // A central-directory record whose extra/comment lengths run past | ||
| // the end of the archive is refused, not sliced out of bounds. | ||
| let mut with_sig: Vec<(&str, &[u8])> = FILES.to_vec(); | ||
| with_sig.push((".signature.p7s", b"sig")); | ||
| let mut bytes = zip(&with_sig, true); | ||
| let eocd = find_eocd(&bytes).unwrap(); | ||
| let cd = u32_at(&bytes, eocd + 16).unwrap() as usize; | ||
| bytes[cd + 32..cd + 34].copy_from_slice(&u16::MAX.to_le_bytes()); | ||
| assert!(package_content_hash(&bytes).is_err()); | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.