From d1b5daf1e07e74b2759d4b41ffe459225ea0248a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 19:25:26 +0000 Subject: [PATCH 1/5] Start fix for #956 Assisted-by: Claude Code:claude-opus-5-5 From 62a08617d83806253c488b7321364e825f112aa0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 19:38:32 +0000 Subject: [PATCH 2/5] Quote scoped names in pnpm 7/8 vendored locks Vendoring a scoped package (@scope/pkg) into a pnpm 7 (lock 5.4) or pnpm 8 (lock 6.0) project wrote `name: @scope/pkg` into the rekeyed packages entry. A bare `@` cannot start a YAML scalar, so pnpm refused the whole lock with ERR_PNPM_BROKEN_LOCKFILE: every frozen install failed after a scan that reported success, and lock-only VEX kept attesting not_affected from a lock pnpm could not read. The name is now written through the shared YAML scalar quoting, which gives `name: '@scope/pkg'`, byte-identical to what pnpm 7.33.7 and 8.15.9 serialize themselves for the same override. Tests: a byte-exact unit oracle captured from real pnpm 7/8 for @isaacs/string-locale-compare (vendor, in-sync re-run, revert), and scoped real-pnpm lifecycle legs (frozen install, moved checkout, manifest-less VEX, revert) in e2e_vendor_pnpm_build, also run in the pinned pnpm 7/8 matrix. Fixes #956. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/e2e_vendor_pnpm_build.rs | 90 ++++++++- .../src/vendor/pnpm_lock_legacy.rs | 174 +++++++++++++++++- 2 files changed, 253 insertions(+), 11 deletions(-) diff --git a/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs index 9f18fb746..2e4f0793b 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs @@ -888,9 +888,13 @@ fn assert_manifestless_vendored_vex( let state = fresh.join(".socket/vendor/state.json"); let lock_wired = std::fs::read(&lock).expect("fresh checkout lock"); let pkg_wired = std::fs::read(fresh.join("package.json")).unwrap(); + let (dep, version) = purl + .strip_prefix("pkg:npm/") + .and_then(|nv| nv.rsplit_once('@')) + .expect("an npm purl"); assert!( fresh - .join(format!(".socket/vendor/npm/{UUID}/{DEP}-{DEP_VERSION}.tgz")) + .join(format!(".socket/vendor/npm/{UUID}/{dep}-{version}.tgz")) .is_file(), "[{tag}] the committed tarball must travel with the checkout" ); @@ -1025,7 +1029,7 @@ fn assert_manifestless_vendored_vex( ); assert!(plain.status.success(), "[{tag}] plain install: {plain:?}"); assert_eq!( - std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")).unwrap(), + std::fs::read(fresh.join("node_modules").join(dep).join("index.js")).unwrap(), patched, "[{tag}] a plain install must re-apply the overrides (vendored bytes)" ); @@ -1062,8 +1066,26 @@ async fn pnpm_pinned_matrix_vendored_lifecycle_and_manifestless_vex() { run_pnpm_capstone(&pm, VendorDriver::VendorCli).await; run_pnpm_capstone(&pm, VendorDriver::GetUuid).await; } - 7 => off_runtime(|| run_legacy_capstone(&pm, "lockfileVersion: 5.4", "proj")), - 8 => off_runtime(|| run_legacy_capstone(&pm, "lockfileVersion: '6.0'", "proj")), + 7 => off_runtime(|| { + run_legacy_capstone(&pm, "lockfileVersion: 5.4", "proj"); + run_legacy_capstone_for( + &pm, + "lockfileVersion: 5.4", + "proj", + SCOPED_DEP, + SCOPED_DEP_VERSION, + ); + }), + 8 => off_runtime(|| { + run_legacy_capstone(&pm, "lockfileVersion: '6.0'", "proj"); + run_legacy_capstone_for( + &pm, + "lockfileVersion: '6.0'", + "proj", + SCOPED_DEP, + SCOPED_DEP_VERSION, + ); + }), _ => off_runtime(|| run_unsupported_lock_refusal(&pm)), } } @@ -1637,6 +1659,43 @@ fn pnpm8_real_lifecycle_under_yaml_indicator_paths() { } } +/// #956: a scoped package's rekeyed packages entry must keep the lock +/// loadable. An unquoted `name: @scope/pkg` is invalid YAML, so every +/// frozen install failed with ERR_PNPM_BROKEN_LOCKFILE after a successful +/// vendor. +const SCOPED_DEP: &str = "@isaacs/string-locale-compare"; +const SCOPED_DEP_VERSION: &str = "1.1.0"; + +#[test] +fn pnpm7_real_lifecycle_scoped_package() { + if !has_corepack_pm(PNPM_LEGACY_7) { + println!("SKIP: `corepack {PNPM_LEGACY_7}` unavailable"); + return; + } + run_legacy_capstone_for( + PNPM_LEGACY_7, + "lockfileVersion: 5.4", + "proj", + SCOPED_DEP, + SCOPED_DEP_VERSION, + ); +} + +#[test] +fn pnpm8_real_lifecycle_scoped_package() { + if !has_corepack_pm(PNPM_LEGACY_8) { + println!("SKIP: `corepack {PNPM_LEGACY_8}` unavailable"); + return; + } + run_legacy_capstone_for( + PNPM_LEGACY_8, + "lockfileVersion: '6.0'", + "proj", + SCOPED_DEP, + SCOPED_DEP_VERSION, + ); +} + /// Full lifecycle against the REAL pinned legacy pnpm, spike-proven flags: /// /// 1. online fixture install (skip when the registry is unreachable); @@ -1653,6 +1712,11 @@ fn pnpm8_real_lifecycle_under_yaml_indicator_paths() { /// 5. idempotent re-vendor (byte-stable, already_vendored); /// 6. revert restores both files byte-identical and removes .socket/vendor. fn run_legacy_capstone(pm: &str, lock_head: &str, proj_dir: &str) { + run_legacy_capstone_for(pm, lock_head, proj_dir, DEP, DEP_VERSION); +} + +/// [`run_legacy_capstone`] for any registry package `dep@version`. +fn run_legacy_capstone_for(pm: &str, lock_head: &str, proj_dir: &str, dep: &str, version: &str) { let tmp = tempfile::tempdir().unwrap(); let proj = tmp.path().join(proj_dir); std::fs::create_dir_all(&proj).unwrap(); @@ -1660,7 +1724,7 @@ fn run_legacy_capstone(pm: &str, lock_head: &str, proj_dir: &str) { "name": "pnpm-legacy-capstone", "version": "0.0.0", "private": true, - "dependencies": { DEP: DEP_VERSION }, + "dependencies": { dep: version }, }); std::fs::write( proj.join("package.json"), @@ -1688,10 +1752,10 @@ fn run_legacy_capstone(pm: &str, lock_head: &str, proj_dir: &str) { return; } - let installed_index = proj.join("node_modules").join(DEP).join("index.js"); + let installed_index = proj.join("node_modules").join(dep).join("index.js"); let orig = std::fs::read(&installed_index).expect("installed index.js"); let patched: Vec = [MARKER.as_bytes(), orig.as_slice()].concat(); - let purl = format!("pkg:npm/{DEP}@{DEP_VERSION}"); + let purl = format!("pkg:npm/{dep}@{version}"); stage_patch(&proj, &purl, "package/index.js", &orig, &patched); let lock_path = proj.join("pnpm-lock.yaml"); @@ -1722,18 +1786,24 @@ fn run_legacy_capstone(pm: &str, lock_head: &str, proj_dir: &str) { let env = parse_envelope(&stdout); assert_eq!(env["status"], "success", "envelope: {env}"); assert_eq!(env["summary"]["applied"], 1, "{env}"); - let tgz_rel = format!(".socket/vendor/npm/{UUID}/{DEP}-{DEP_VERSION}.tgz"); + let tgz_rel = format!(".socket/vendor/npm/{UUID}/{dep}-{version}.tgz"); assert!(proj.join(&tgz_rel).is_file()); assert!( !proj.join("pnpm-workspace.yaml").exists(), "legacy wiring must not create pnpm-workspace.yaml ({pm})" ); let lock_after = std::fs::read_to_string(&lock_path).unwrap(); + // pnpm single-quotes an `@`-leading (scoped) key. + let override_key = if dep.starts_with('@') { + format!("'{dep}@{version}'") + } else { + format!("{dep}@{version}") + }; let abs = socket_patch_core::vendor::pnpm_lock_legacy::normalize_canonical_root( &std::fs::canonicalize(&proj).unwrap().display().to_string(), ); assert!( - lock_after.contains(&format!("{DEP}@{DEP_VERSION}: file:{tgz_rel}")), + lock_after.contains(&format!("{override_key}: file:{tgz_rel}")), "lock overrides must point at the vendored tarball ({pm}):\n{lock_after}" ); assert!( @@ -1857,7 +1927,7 @@ fn run_legacy_capstone(pm: &str, lock_head: &str, proj_dir: &str) { String::from_utf8_lossy(&plain.stderr), ); let fresh_installed = - std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")).unwrap(); + std::fs::read(fresh.join("node_modules").join(dep).join("index.js")).unwrap(); assert_eq!( fresh_installed, patched, "moved-checkout install must land the patched bytes ({pm})" diff --git a/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs b/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs index f9ac237b2..f80f1f4b8 100644 --- a/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs +++ b/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs @@ -727,7 +727,9 @@ fn edit_packages( match field { "resolution" => { new_lines.push(expected_resolution.clone()); - new_lines.push(format!(" name: {}", ctx.name)); + // pnpm quotes a scoped `@scope/pkg` name: a bare + // `@` can't start a plain YAML scalar (#956). + new_lines.push(format!(" name: {}", yaml_value(ctx.name))); new_lines.push(format!(" version: {}", ctx.version)); replaced_resolution = true; continue; @@ -1910,6 +1912,176 @@ packages: assert!(!fx.root().join("pnpm-workspace.yaml").exists()); } + // ── scoped package (#956) ───────────────────────────────────────────── + // Provenance: `@isaacs/string-locale-compare@1.1.0` installed by REAL + // `pnpm@7.33.7` / `pnpm@8.15.9`, then a `file:` tarball pnpm.overrides + // entry added and re-installed. pnpm quotes the `@`-leading `name:` of + // the rekeyed packages entry; a bare `name: @isaacs/…` is not valid + // YAML and pnpm refuses the whole lock (ERR_PNPM_BROKEN_LOCKFILE). + + const SCOPED_PURL: &str = "pkg:npm/@isaacs/string-locale-compare@1.1.0"; + const SCOPED_PKG: &str = r#"{ + "name": "cell", + "version": "0.0.0", + "private": true, + "dependencies": { + "@isaacs/string-locale-compare": "1.1.0" + } +} +"#; + const S7_BEFORE_LOCK: &str = "lockfileVersion: 5.4 + +specifiers: + '@isaacs/string-locale-compare': 1.1.0 + +dependencies: + '@isaacs/string-locale-compare': 1.1.0 + +packages: + + /@isaacs/string-locale-compare/1.1.0: + resolution: {integrity: sha512-SQ7Kzhh9+D+ZW9MA0zkYv3VXhIDNx+LzM6EJ+/65I3QY+enU6Itte7E5XX7EWrqLW2FN4n06GWzBnPoC3th2aQ==} + dev: false +"; + const S7_AFTER_LOCK: &str = "lockfileVersion: 5.4 + +overrides: + '@isaacs/string-locale-compare@1.1.0': file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/@isaacs/string-locale-compare-1.1.0.tgz + +specifiers: + '@isaacs/string-locale-compare': file:__PROJECT_ROOT__/.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/@isaacs/string-locale-compare-1.1.0.tgz + +dependencies: + '@isaacs/string-locale-compare': file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/@isaacs/string-locale-compare-1.1.0.tgz + +packages: + + file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/@isaacs/string-locale-compare-1.1.0.tgz: + resolution: {integrity: sha512-pceaN98Av+E8ugNGKlqbfzvbJWVAdWx3RKI7kc7jPThP6QHZg7c2xbZhCqV8N42Jf9hKWdLW4ZNDnFHQinZ0Hw==, tarball: file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/@isaacs/string-locale-compare-1.1.0.tgz} + name: '@isaacs/string-locale-compare' + version: 1.1.0 + dev: false +"; + const S8_BEFORE_LOCK: &str = "lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +dependencies: + '@isaacs/string-locale-compare': + specifier: 1.1.0 + version: 1.1.0 + +packages: + + /@isaacs/string-locale-compare@1.1.0: + resolution: {integrity: sha512-SQ7Kzhh9+D+ZW9MA0zkYv3VXhIDNx+LzM6EJ+/65I3QY+enU6Itte7E5XX7EWrqLW2FN4n06GWzBnPoC3th2aQ==} + dev: false +"; + const S8_AFTER_LOCK: &str = "lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + '@isaacs/string-locale-compare@1.1.0': file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/@isaacs/string-locale-compare-1.1.0.tgz + +dependencies: + '@isaacs/string-locale-compare': + specifier: file:__PROJECT_ROOT__/.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/@isaacs/string-locale-compare-1.1.0.tgz + version: file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/@isaacs/string-locale-compare-1.1.0.tgz + +packages: + + file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/@isaacs/string-locale-compare-1.1.0.tgz: + resolution: {integrity: sha512-pceaN98Av+E8ugNGKlqbfzvbJWVAdWx3RKI7kc7jPThP6QHZg7c2xbZhCqV8N42Jf9hKWdLW4ZNDnFHQinZ0Hw==, tarball: file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/@isaacs/string-locale-compare-1.1.0.tgz} + name: '@isaacs/string-locale-compare' + version: 1.1.0 + dev: false +"; + + /// A project whose only dependency is the scoped package, installed + /// under `node_modules/@isaacs/string-locale-compare`. + async fn scoped_fixture(lock: &str) -> Fixture { + let fx = fixture_with(SCOPED_PKG, lock).await; + let installed = fx.root().join("node_modules/@isaacs/string-locale-compare"); + tokio::fs::create_dir_all(&installed).await.unwrap(); + tokio::fs::write( + installed.join("package.json"), + br#"{"name":"@isaacs/string-locale-compare","version":"1.1.0"}"#, + ) + .await + .unwrap(); + tokio::fs::write(installed.join("index.js"), ORIG_INDEX) + .await + .unwrap(); + fx + } + + async fn vendor_scoped(fx: &Fixture) -> VendorOutcome { + let blobs = fx.root().join(".socket/blobs"); + crate::vendor::test_support::vendor_pnpm_legacy( + SCOPED_PURL, + &fx.root().join("node_modules/@isaacs/string-locale-compare"), + fx.root(), + &fx.record, + &PatchSources::blobs_only(&blobs), + "2026-08-18T00:00:00Z", + false, + false, + None, + ) + .await + } + + /// [`Fixture::expected_lock`] for the scoped tarball. + async fn expected_scoped_lock(fx: &Fixture, fixture: &str) -> String { + let rel = format!(".socket/vendor/npm/{UUID}/@isaacs/string-locale-compare-1.1.0.tgz"); + let tgz = tokio::fs::read(fx.root().join(rel)).await.unwrap(); + let integrity = format!( + "sha512-{}", + base64::engine::general_purpose::STANDARD.encode(Sha512::digest(&tgz)) + ); + fixture + .replace(SPIKE_INTEGRITY, &integrity) + .replace(ROOT_TOKEN, &fx.canon_root_str()) + } + + /// #956: a scoped package's rekeyed packages entry carries a quoted + /// `name: '@scope/pkg'`, byte-identical to pnpm's own serialization, on + /// both legacy grammars — and the lock round-trips on revert. + #[tokio::test] + async fn scoped_package_name_is_yaml_quoted_both_grammars() { + for (before, after) in [ + (S7_BEFORE_LOCK, S7_AFTER_LOCK), + (S8_BEFORE_LOCK, S8_AFTER_LOCK), + ] { + let fx = scoped_fixture(before).await; + let (result, entry, _warnings) = expect_done(vendor_scoped(&fx).await); + assert!(result.success, "{:?}", result.error); + let entry = entry.expect("success carries a ledger entry"); + + let lock = fx.read(PNPM_LOCK).await; + assert!( + !lock.lines().any(|l| l.trim_start().starts_with("name: @")), + "unquoted scoped name is invalid YAML:\n{lock}" + ); + assert_eq!(lock, expected_scoped_lock(&fx, after).await); + + // A re-run is in sync and leaves the lock byte-stable. + let (rerun, _, _) = expect_done(vendor_scoped(&fx).await); + assert!(rerun.success, "{:?}", rerun.error); + assert_eq!(fx.read(PNPM_LOCK).await, lock); + + let outcome = revert_pnpm_legacy(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert_eq!(fx.read(PNPM_LOCK).await, before); + assert_eq!(fx.read(PACKAGE_JSON).await, SCOPED_PKG); + } + } + /// The transitive-ONLY capture (x7): no root section mentions the /// package, so nothing absolute is written and no portability warning /// fires — overrides + rekeyed packages entry + the consumer's dep ref From 8b245d804c65046e74a99a43211d986619312a24 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 19:44:55 +0000 Subject: [PATCH 3/5] Skip scoped legacy leg on pnpm 8.0.0-8.1.0 pnpm 8.0.0 and 8.1.0 refuse their own lock for a scoped file: tarball override under --frozen-lockfile (ERR_PNPM_LOCKFILE_MISSING_DEPENDENCY on the key they just wrote); 8.1.1 fixed it. Measured with real pnpm on Node 16: the lock pnpm itself writes fails the same way, so no vendored scoped lock can pass there. The pinned matrix keeps the unscoped leg on those versions and runs the scoped leg everywhere else. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/e2e_vendor_pnpm_build.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs index 2e4f0793b..9803ed6ef 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs @@ -149,6 +149,18 @@ fn absolutizes_file_overrides(pm: &str) -> bool { matches!(parts.as_slice(), [9, 0, patch] if *patch <= 4) } +/// pnpm 8.0.0-8.1.0 refuse their OWN lock for a scoped `file:` tarball +/// override under `--frozen-lockfile` (ERR_PNPM_LOCKFILE_MISSING_DEPENDENCY +/// on the `file:` key they just wrote; measured 2026-10-06: 8.1.0 refuses, +/// 8.1.1 accepts), so no vendored scoped lock can pass there. +fn refuses_own_scoped_file_override(pm: &str) -> bool { + let Some(v) = pm.strip_prefix("pnpm@") else { + return false; + }; + let parts: Vec = v.split('.').filter_map(|p| p.parse().ok()).collect(); + matches!(parts.as_slice(), [8, 0, _] | [8, 1, 0]) +} + fn has_corepack_pm(pm: &str) -> bool { // Isolated too: this probe is what actually downloads the package manager // the first time, and corepack stores it under `COREPACK_HOME`. @@ -1078,6 +1090,10 @@ async fn pnpm_pinned_matrix_vendored_lifecycle_and_manifestless_vex() { }), 8 => off_runtime(|| { run_legacy_capstone(&pm, "lockfileVersion: '6.0'", "proj"); + if refuses_own_scoped_file_override(&pm) { + println!("SKIP scoped legacy leg ({pm}): pnpm refuses its own scoped file: lock"); + return; + } run_legacy_capstone_for( &pm, "lockfileVersion: '6.0'", From 35ffa60075cffa5715b10cdf608b00edc7a0c424 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 18:24:21 +0000 Subject: [PATCH 4/5] Route Gradle digests through utils::digest main has failed socket-patch-core's lib tests since Gradle support (#646) and the digest helpers (#865) both landed. The guard test production_digests_go_through_the_helpers flags three files #646 added that still hash inline: crawlers/gradle_cache.rs, patch/jvm_jar.rs and patch/sidecars/maven.rs. That breaks test, test-release and coverage on every open PR. Each inline sha1/sha256 call now goes through sha1_hex_of or sha256_hex_of, which compute the same lowercase hex. Behaviour is unchanged. (cherry picked from commit 659ac2c24e5c5904e743b4bc98ea4645da2ed6a1) Ported from #878 so this PR's CI is green while main's digest guard test is red; it no-ops once #878 lands. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/crawlers/gradle_cache.rs | 9 ++++----- crates/socket-patch-core/src/patch/jvm_jar.rs | 7 ++----- crates/socket-patch-core/src/patch/sidecars/maven.rs | 4 +--- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/gradle_cache.rs b/crates/socket-patch-core/src/crawlers/gradle_cache.rs index ef295ee27..afd7c4fba 100644 --- a/crates/socket-patch-core/src/crawlers/gradle_cache.rs +++ b/crates/socket-patch-core/src/crawlers/gradle_cache.rs @@ -70,8 +70,7 @@ pub fn hash_eq(dir_name: &str, sha1_hex: &str) -> bool { /// Whether `bytes` are the pristine download Gradle stored in the hash /// directory `dir_name` (their sha1 names it). pub fn pristine(dir_name: &str, bytes: &[u8]) -> bool { - use sha1::{Digest, Sha1}; - hash_eq(dir_name, &hex::encode(Sha1::digest(bytes))) + hash_eq(dir_name, &crate::utils::digest::sha1_hex_of(bytes)) } /// Whether `path` is a version directory of a `files-2.1` tree @@ -432,8 +431,6 @@ impl DerivedIndex { /// The [`DerivedCopies`] of the jar `jar_leaf` whose pristine bytes /// hash to `pristine_sha1`. pub fn query(&self, jar_leaf: &str, pristine_sha1: &str) -> DerivedCopies { - use sha1::{Digest, Sha1}; - let instrumented = format!("instrumented-{jar_leaf}"); let mut out = DerivedCopies { incomplete: self.incomplete, @@ -460,7 +457,9 @@ impl DerivedIndex { out.stale.push(path.clone()); } else if name == jar_leaf || name == instrumented { match crate::utils::fs::read_regular_to_bytes_sync(path) { - Ok(bytes) if hash_eq(&hex::encode(Sha1::digest(&bytes)), pristine_sha1) => { + Ok(bytes) + if hash_eq(&crate::utils::digest::sha1_hex_of(&bytes), pristine_sha1) => + { out.stale.push(path.clone()) } Ok(_) => out.unknown.push(path.clone()), diff --git a/crates/socket-patch-core/src/patch/jvm_jar.rs b/crates/socket-patch-core/src/patch/jvm_jar.rs index 82d679406..f38a84403 100644 --- a/crates/socket-patch-core/src/patch/jvm_jar.rs +++ b/crates/socket-patch-core/src/patch/jvm_jar.rs @@ -25,8 +25,6 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use sha1::Digest as _; - use crate::crawlers::gradle_cache; use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::PatchFileInfo; @@ -353,12 +351,11 @@ fn unpatched_members( } fn sha256_hex(bytes: &[u8]) -> String { - use sha2::Digest as _; - hex::encode(sha2::Sha256::digest(bytes)) + crate::utils::digest::sha256_hex_of(bytes) } fn sha1_hex(bytes: &[u8]) -> String { - hex::encode(sha1::Sha1::digest(bytes)) + crate::utils::digest::sha1_hex_of(bytes) } /// `/jvm-originals/.jar`. diff --git a/crates/socket-patch-core/src/patch/sidecars/maven.rs b/crates/socket-patch-core/src/patch/sidecars/maven.rs index f2f5a2466..8798bfce6 100644 --- a/crates/socket-patch-core/src/patch/sidecars/maven.rs +++ b/crates/socket-patch-core/src/patch/sidecars/maven.rs @@ -17,8 +17,6 @@ use std::path::{Path, PathBuf}; -use sha1::Digest as _; - use super::{ SidecarAdvisory, SidecarAdvisoryCode, SidecarError, SidecarFile, SidecarFileAction, SidecarPayload, SidecarSeverity, @@ -44,7 +42,7 @@ impl Algo { fn digest(self, bytes: &[u8]) -> String { match self { - Algo::Sha1 => hex::encode(sha1::Sha1::digest(bytes)), + Algo::Sha1 => crate::utils::digest::sha1_hex_of(bytes), Algo::Md5 => hex::encode(md5(bytes)), } } From 5667ec921a4336d589e19f9eb9bb40c573babf2b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 20:12:21 +0000 Subject: [PATCH 5/5] Re-vendor rewrites a stale unquoted scoped name edit_packages treated a packages entry as in sync once its file: key and resolution matched, without looking at name:. A lock vendored by a release before the #956 fix still carries `name: @scope/pkg`, which pnpm 7/8 can't load, so a later vendor reported the package already vendored and left the lock broken. The in-sync check now also requires the canonical quoted name: line, so the old spelling is rewritten like any other stale wiring. The new test revendor_heals_an_unquoted_scoped_name fails without this change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UepoBazrbnBjy7HkD9YVJN --- .../src/vendor/pnpm_lock_legacy.rs | 43 +++++++++++++++++-- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs b/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs index f80f1f4b8..660c980d2 100644 --- a/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs +++ b/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs @@ -715,7 +715,14 @@ fn edit_packages( " resolution: {{integrity: {}, tarball: {}}}", ctx.integrity, ctx.spec ); - if block.key == new_key && original_lines.iter().any(|l| l == &expected_resolution) { + // pnpm quotes a scoped `@scope/pkg` name: a bare `@` can't start a + // plain YAML scalar (#956). An older release wrote it unquoted, so a + // lock carrying that spelling is stale, not in sync. + let expected_name = format!(" name: {}", yaml_value(ctx.name)); + if block.key == new_key + && original_lines.iter().any(|l| l == &expected_resolution) + && original_lines.iter().any(|l| l == &expected_name) + { return Ok(false); // in sync } let mut new_lines = Vec::with_capacity(original_lines.len() + 2); @@ -727,9 +734,7 @@ fn edit_packages( match field { "resolution" => { new_lines.push(expected_resolution.clone()); - // pnpm quotes a scoped `@scope/pkg` name: a bare - // `@` can't start a plain YAML scalar (#956). - new_lines.push(format!(" name: {}", yaml_value(ctx.name))); + new_lines.push(expected_name.clone()); new_lines.push(format!(" version: {}", ctx.version)); replaced_resolution = true; continue; @@ -2082,6 +2087,36 @@ packages: } } + /// A lock vendored by a release before #956 carries the unquoted + /// `name: @scope/pkg` that pnpm can't load. A re-vendor must treat it + /// as stale and rewrite it, not report the wiring in sync. + #[tokio::test] + async fn revendor_heals_an_unquoted_scoped_name() { + for (before, after) in [ + (S7_BEFORE_LOCK, S7_AFTER_LOCK), + (S8_BEFORE_LOCK, S8_AFTER_LOCK), + ] { + let fx = scoped_fixture(before).await; + let (result, _, _) = expect_done(vendor_scoped(&fx).await); + assert!(result.success, "{:?}", result.error); + let vendored = fx.read(PNPM_LOCK).await; + + let quoted = " name: '@isaacs/string-locale-compare'"; + assert!(vendored.contains(quoted), "{vendored}"); + let stale = vendored.replace(quoted, " name: @isaacs/string-locale-compare"); + tokio::fs::write(fx.root().join(PNPM_LOCK), &stale) + .await + .unwrap(); + + let (rerun, _, _) = expect_done(vendor_scoped(&fx).await); + assert!(rerun.success, "{:?}", rerun.error); + assert_eq!( + fx.read(PNPM_LOCK).await, + expected_scoped_lock(&fx, after).await + ); + } + } + /// The transitive-ONLY capture (x7): no root section mentions the /// package, so nothing absolute is written and no portability warning /// fires — overrides + rekeyed packages entry + the consumer's dep ref