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..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`. @@ -888,9 +900,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 +1041,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 +1078,30 @@ 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"); + 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'", + "proj", + SCOPED_DEP, + SCOPED_DEP_VERSION, + ); + }), _ => off_runtime(|| run_unsupported_lock_refusal(&pm)), } } @@ -1637,6 +1675,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 +1728,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 +1740,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 +1768,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 +1802,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 +1943,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/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)), } } 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..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,7 +734,7 @@ fn edit_packages( match field { "resolution" => { new_lines.push(expected_resolution.clone()); - new_lines.push(format!(" name: {}", ctx.name)); + new_lines.push(expected_name.clone()); new_lines.push(format!(" version: {}", ctx.version)); replaced_resolution = true; continue; @@ -1910,6 +1917,206 @@ 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); + } + } + + /// 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