diff --git a/crates/socket-patch-core/src/formats/nuget/mod.rs b/crates/socket-patch-core/src/formats/nuget/mod.rs index fcfee128c..6a2e949fb 100644 --- a/crates/socket-patch-core/src/formats/nuget/mod.rs +++ b/crates/socket-patch-core/src/formats/nuget/mod.rs @@ -11,6 +11,7 @@ //! attribute or a mismatched close tag makes the whole file `None`. pub(crate) mod lock; +pub(crate) mod package; use std::collections::BTreeSet; use std::ops::Range; diff --git a/crates/socket-patch-core/src/formats/nuget/package.rs b/crates/socket-patch-core/src/formats/nuget/package.rs new file mode 100644 index 000000000..ecaf1f11d --- /dev/null +++ b/crates/socket-patch-core/src/formats/nuget/package.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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""), + ( + "pkg.nuspec", + b"Pkg", + ), + ( + "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()); + } +} diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/client.rs b/crates/socket-patch-core/src/patch/redirect/upstream/client.rs index 9e0c2a36d..e7cf71df7 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/client.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/client.rs @@ -559,10 +559,12 @@ impl UpstreamClient { result } - /// The `packages.lock.json` `contentHash` of `id@version` on nuget.org - /// (base64 sha512 of the `.nupkg`): the `packageHash` of the catalog - /// entry its registration leaf points at — the hash nuget.org computed - /// over the repository-signed package, without downloading it. + /// The `packages.lock.json` `contentHash` of `id@version` on nuget.org: + /// NuGet's content hash of the `.nupkg` its flat container serves, + /// which excludes the repository signature + /// ([`crate::formats::nuget::package::package_content_hash`]). The + /// catalog's `packageHash` is the hash of the signed file as served, so + /// it never matches a lock's `contentHash` (#624). pub(crate) async fn nuget_content_hash( &self, id: &str, @@ -577,67 +579,26 @@ impl UpstreamClient { return Err(OFFLINE.to_string()); } let (id_lower, version_lower) = &key; + let (id_seg, version_seg) = ( + crate::utils::uri::encode_uri_component(id_lower), + crate::utils::uri::encode_uri_component(version_lower), + ); let url = format!( - "{}/v3/registration5-gz-semver2/{}/{}.json", + "{}/v3-flatcontainer/{id_seg}/{version_seg}/{id_seg}.{version_seg}.nupkg", nuget_api_base(), - crate::utils::uri::encode_uri_component(id_lower), - crate::utils::uri::encode_uri_component(version_lower) ); - let leaf = self.get_json_maybe_gzip(&url).await?; - let catalog = leaf - .get("catalogEntry") - .and_then(Value::as_str) - .ok_or_else(|| format!("{url} names no catalog entry"))?; - let entry = self.get_json_maybe_gzip(catalog).await?; - let same_id = entry - .get("id") - .and_then(Value::as_str) - .is_some_and(|i| i.eq_ignore_ascii_case(id_lower)); - let same_version = entry - .get("version") - .and_then(Value::as_str) - .is_some_and(|v| { - crate::vendor::nuget_feed::normalize_nuget_version(v) - .eq_ignore_ascii_case(version_lower) - }); - if !same_id || !same_version { - return Err(format!( - "{catalog} is not the catalog entry of {id} {version}" - )); - } - let sha512 = entry - .get("packageHashAlgorithm") - .and_then(Value::as_str) - .is_some_and(|a| a.eq_ignore_ascii_case("SHA512")); - match entry.get("packageHash").and_then(Value::as_str) { - Some(hash) if sha512 && is_base64_digest(hash) => Ok(hash.to_string()), - _ => Err(format!("{catalog} records no SHA512 packageHash")), - } + let bytes = crate::vendor::registry_fetch::download(&self.http, &url).await?; + crate::formats::nuget::package::package_content_hash(&bytes) + .map_err(|why| format!("{url}: {why}")) } .await; self.nuget.lock().await.insert(key, result.clone()); result } - - /// A JSON document that nuget.org may serve gzip-encoded whatever the - /// request asked for (the `registration5-gz-*` hives). - async fn get_json_maybe_gzip(&self, url: &str) -> Result { - use std::io::Read as _; - let mut bytes = crate::vendor::registry_fetch::download(&self.http, url).await?; - if bytes.starts_with(&[0x1f, 0x8b]) { - let mut plain = Vec::new(); - flate2::read::GzDecoder::new(bytes.as_slice()) - .take(crate::vendor::registry_fetch::MAX_DOWNLOAD_BYTES) - .read_to_end(&mut plain) - .map_err(|e| format!("{url}: bad gzip body: {e}"))?; - bytes = plain; - } - serde_json::from_slice(&bytes).map_err(|e| format!("{url} is not JSON: {e}")) - } } /// nuget.org's API host; `SOCKET_NUGET_URL` names another (tests, mirrors -/// serving the same `/v3/registration5-gz-semver2/` hive). +/// serving the same `/v3-flatcontainer/` hive). pub(crate) const DEFAULT_NUGET_API: &str = "https://api.nuget.org"; fn nuget_api_base() -> String { @@ -648,17 +609,6 @@ fn nuget_api_base() -> String { .unwrap_or_else(|| DEFAULT_NUGET_API.to_string()) } -/// A base64 digest token (the alphabet and padding only: the lock stores -/// whatever nuget.org recorded, so its length is not second-guessed). -fn is_base64_digest(s: &str) -> bool { - let body = s.trim_end_matches('='); - !body.is_empty() - && s.len() - body.len() <= 2 - && body - .bytes() - .all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/') -} - /// The release files of a PyPI JSON API version document, sorted by /// filename. fn pypi_release_files(doc: &Value) -> Result, String> { diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/nuget.rs b/crates/socket-patch-core/src/patch/redirect/upstream/nuget.rs index 98eefae63..387dc006d 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/nuget.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/nuget.rs @@ -10,8 +10,9 @@ //! one the user wrote), and a config it created from scratch (identical to //! a user's default config, so it is kept and a warning says so). //! -//! Every lock entry of the id gets nuget.org's `contentHash` back (the -//! catalog `packageHash`, see [`UpstreamClient::nuget_content_hash`]) — only +//! Every lock entry of the id gets nuget.org's `contentHash` back (NuGet's +//! signature-excluded content hash of the `.nupkg` nuget.org serves — not +//! the catalog `packageHash`, see [`UpstreamClient::nuget_content_hash`]) — only //! when the restored config resolves the id from nuget.org alone: another //! feed (or several) may serve different bytes, and socket-patch cannot //! tell which one the original lock came from, so such a pin is refused. @@ -458,8 +459,6 @@ mod tests { use wiremock::{Mock, MockServer, ResponseTemplate}; const UUID: &str = "66666666-6666-6666-6666-666666666666"; - const UPSTREAM: &str = - "ckEKf1MtNHGmiyXVMOQUWA1NhmENd95EZ8h2znGTaccCdgF/RgjlfKWRH+iEdgEx68wOpY+UFhWisuq3tHFA=="; const PATCHED: &str = "PATCHEDcontenthashPATCHEDcontenthashAA=="; fn index_url() -> String { @@ -488,27 +487,41 @@ mod tests { ) } + /// A tiny `.nupkg`, with or without a repository signature entry + /// (appended last, where NuGet's signer puts it). + fn nupkg(signed: bool) -> Vec { + use std::io::Write as _; + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let opts = + zip::write::SimpleFileOptions::default().last_modified_time(zip::DateTime::default()); + let mut files: Vec<(&str, &[u8])> = vec![ + ("newtonsoft.json.nuspec", b""), + ("LICENSE.md", b"The MIT License (MIT)"), + ]; + if signed { + files.push((".signature.p7s", b"repository-signature")); + } + for (name, data) in files { + zw.start_file(name, opts).unwrap(); + zw.write_all(data).unwrap(); + } + zw.finish().unwrap().into_inner() + } + + /// The lock's original `contentHash`: NuGet's content hash, which + /// excludes the signature, i.e. the hash of the unsigned archive. + fn upstream() -> String { + crate::utils::digest::sha512_base64_of(&nupkg(false)) + } + + /// nuget.org's flat container serving the SIGNED package. async fn nuget_org() -> MockServer { let server = MockServer::start().await; - let catalog = format!("{}/catalog0/data/newtonsoft.json.13.0.3.json", server.uri()); Mock::given(method("GET")) .and(path( - "/v3/registration5-gz-semver2/newtonsoft.json/13.0.3.json", + "/v3-flatcontainer/newtonsoft.json/13.0.3/newtonsoft.json.13.0.3.nupkg", )) - .respond_with( - ResponseTemplate::new(200) - .set_body_json(serde_json::json!({ "catalogEntry": catalog })), - ) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/catalog0/data/newtonsoft.json.13.0.3.json")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "id": "Newtonsoft.Json", - "version": "13.0.3", - "packageHash": UPSTREAM, - "packageHashAlgorithm": "SHA512", - }))) + .respond_with(ResponseTemplate::new(200).set_body_bytes(nupkg(true))) .mount(&server) .await; server @@ -549,7 +562,13 @@ mod tests { outcome.pins ); assert_eq!(config, USER_MAPPING); - assert_eq!(lock_after, lock(UPSTREAM)); + // #624: the signature-excluded content hash, never the hash of the + // signed file as served (what the catalog's packageHash records). + assert_ne!( + upstream(), + crate::utils::digest::sha512_base64_of(&nupkg(true)) + ); + assert_eq!(lock_after, lock(&upstream())); assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); } @@ -613,7 +632,7 @@ mod tests { ); assert_eq!( std::fs::read_to_string(app.join(PACKAGES_LOCK)).unwrap(), - lock(UPSTREAM) + lock(&upstream()) ); } @@ -624,7 +643,7 @@ mod tests { let (outcome, config, lock_after) = run(&hosted_config(&default), false).await; assert_eq!(outcome.pins[0].status, PinStatus::Restored); assert_eq!(config, default); - assert_eq!(lock_after, lock(UPSTREAM)); + assert_eq!(lock_after, lock(&upstream())); assert!(outcome .warnings .iter() diff --git a/crates/socket-patch-core/tests/upstream_restore_golden.rs b/crates/socket-patch-core/tests/upstream_restore_golden.rs index 9e2bc7606..0d2f98faf 100644 --- a/crates/socket-patch-core/tests/upstream_restore_golden.rs +++ b/crates/socket-patch-core/tests/upstream_restore_golden.rs @@ -2535,36 +2535,60 @@ async fn maven_config_merge_keeps_the_resolver_lines() { ); } -/// Serve nuget.org's registration leaf and catalog entry for every package -/// the `input/` lock pins, with the contentHash it records. -async fn nuget_mock(case: &Case) -> MockServer { +/// A deterministic repository-signed `.nupkg` for `id@version` and its +/// NuGet content hash (the signature excluded, so: the hash of the same +/// archive without the signature entry, #624). +fn signed_nupkg(id: &str, version: &str) -> (Vec, String) { + use std::io::Write as _; + let build = |signed: bool| { + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let opts = + zip::write::SimpleFileOptions::default().last_modified_time(zip::DateTime::default()); + zw.start_file(format!("{id}.nuspec"), opts).unwrap(); + write!( + zw, + "{id}{version}" + ) + .unwrap(); + if signed { + zw.start_file(".signature.p7s", opts).unwrap(); + zw.write_all(b"repository-signature").unwrap(); + } + zw.finish().unwrap().into_inner() + }; + let unsigned = build(false); + let hash = { + use base64::Engine as _; + use sha2::Digest as _; + base64::engine::general_purpose::STANDARD.encode(sha2::Sha512::digest(&unsigned)) + }; + (build(true), hash) +} + +/// Serve nuget.org's flat-container `.nupkg` for every package the +/// `input/` lock pins, and re-key the case's locks to that package's +/// content hash. +async fn nuget_mock(case: &mut Case) -> MockServer { let server = MockServer::start().await; let lock: serde_json::Value = serde_json::from_str(case.input.get("packages.lock.json").unwrap()).unwrap(); for fw in lock["dependencies"].as_object().unwrap().values() { for (id, entry) in fw.as_object().unwrap() { let (id, version) = (id.to_lowercase(), entry["resolved"].as_str().unwrap()); - let catalog = format!("{}/catalog0/data/{id}.{version}.json", server.uri()); + let (bytes, hash) = signed_nupkg(&id, version); Mock::given(method("GET")) .and(path(format!( - "/v3/registration5-gz-semver2/{id}/{version}.json" + "/v3-flatcontainer/{id}/{version}/{id}.{version}.nupkg" ))) - .respond_with( - ResponseTemplate::new(200) - .set_body_json(serde_json::json!({ "catalogEntry": catalog })), - ) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path(format!("/catalog0/data/{id}.{version}.json"))) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "id": id, - "version": version, - "packageHash": entry["contentHash"], - "packageHashAlgorithm": "SHA512", - }))) + .respond_with(ResponseTemplate::new(200).set_body_bytes(bytes)) .mount(&server) .await; + let original = entry["contentHash"].as_str().unwrap(); + for files in [&mut case.input, &mut case.expected] { + for text in files.values_mut() { + *text = text.replace(original, &hash); + } + } } } server @@ -2591,7 +2615,8 @@ async fn nuget_goldens_round_trip() { if NUGET_NOT_INVERTIBLE.contains(&name.as_str()) { continue; } - let server = nuget_mock(&case).await; + let mut case = case; + let server = nuget_mock(&mut case).await; let _env = EnvGuard::set(&[("SOCKET_NUGET_URL", server.uri())]); let (after, statuses) = run_case(&case).await; assert_round_trip(&case, &after, &statuses); @@ -2608,7 +2633,8 @@ async fn nuget_non_invertible_goldens_restore_or_refuse_as_documented() { .into_iter() .find(|c| c.dir.ends_with(name)) .unwrap(); - let server = nuget_mock(&case).await; + let mut case = case; + let server = nuget_mock(&mut case).await; let _env = EnvGuard::set(&[("SOCKET_NUGET_URL", server.uri())]); let (after, statuses) = run_case(&case).await; let [(_, status)] = &statuses[..] else {