From adda01170ddd891ef9fb3f30ad5ec3821070bf35 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 01:25:09 +0000 Subject: [PATCH 1/4] Start fix for #903, #904, #905 Assisted-by: Claude Code:claude-opus-5-5 From f81d6c19eb6ef92a22ba1f8eb8133d44d46583e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 01:33:44 +0000 Subject: [PATCH 2/4] Read BOM-prefixed pnpm locks and workspace files A pnpm-lock.yaml or pnpm-workspace.yaml saved with a UTF-8 BOM (as Windows editors do with "UTF-8 with signature") is read normally by pnpm, but socket-patch's pnpm readers matched column-0 text and missed the first line: - hosted scan skipped the trustLockfile auto-config, so frozen pnpm 11/12 installs failed; rollback, remove and list then refused the lock the scan had just pinned, and vendored mode refused it as having no lockfileVersion (#903) - a BOM first key in pnpm-workspace.yaml was missed, so hosted and vendored appended a duplicate trustLockfile / overrides key that pnpm refuses to parse (#904) Add formats::text with one split_bom/strip_bom pair, replace the four named copies with it, and make the pnpm lock sniffs and the workspace key reader skip one leading BOM. Splices keep the original line, so the BOM stays byte-exact. lockfileDir is now read through the same workspace key reader (#905). Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/npm_crawler.rs | 6 +- crates/socket-patch-core/src/formats/mod.rs | 9 +- .../src/formats/pnpm/grammar.rs | 9 +- .../socket-patch-core/src/formats/pnpm/mod.rs | 155 +++++++++++++++--- .../src/formats/pnpm/workspace.rs | 38 ++++- crates/socket-patch-core/src/formats/text.rs | 45 +++++ .../socket-patch-core/src/formats/yarn/mod.rs | 11 +- crates/socket-patch-core/src/gradle/dsl.rs | 7 - crates/socket-patch-core/src/gradle/graph.rs | 28 ++-- crates/socket-patch-core/src/gradle/locks.rs | 2 +- crates/socket-patch-core/src/hosted/engine.rs | 15 +- .../src/hosted/governing_root.rs | 42 +++-- .../socket-patch-core/src/hosted/guidance.rs | 28 +++- .../src/patch/redirect/gradle.rs | 14 +- .../socket-patch-core/src/update/channel.rs | 2 +- crates/socket-patch-core/src/utils/serde.rs | 9 - .../src/vendor/cargo_manifest.rs | 10 +- .../socket-patch-core/src/vendor/npm_dir.rs | 2 +- .../socket-patch-core/src/vendor/pnpm_lock.rs | 50 ++++++ .../socket-patch-core/src/vendor/vlt_lock.rs | 4 +- crates/socket-patch-core/src/vex/product.rs | 2 +- 21 files changed, 382 insertions(+), 106 deletions(-) create mode 100644 crates/socket-patch-core/src/formats/text.rs diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index 443ad9f14..08e5ac117 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -108,7 +108,7 @@ fn pnpm_modules_dir_setting(start_path: &Path) -> Option { .ancestors() .find_map(|dir| read(dir.join("pnpm-workspace.yaml"))) .and_then(|yaml| { - crate::utils::serde::strip_bom(&yaml) + crate::formats::text::strip_bom(&yaml) .lines() .filter_map(crate::formats::pnpm::workspace::top_level_key) .rfind(|(key, _)| key == "modulesDir") @@ -367,7 +367,7 @@ fn parse_package_json_identity(content: &str) -> Option<(String, String)> { // (Windows-authored packages ship them), but serde_json rejects it — // a BOM'd install would be invisible to scan and unpatchable. let pkg: PackageJsonPartial = - serde_json::from_str(crate::utils::serde::strip_bom(content)).ok()?; + serde_json::from_str(crate::formats::text::strip_bom(content)).ok()?; let name = pkg.name?; let version = pkg.version?; if name.is_empty() || version.is_empty() { @@ -591,7 +591,7 @@ const PNPM_MODULES_YAML: &str = ".modules.yaml"; /// The `virtualStoreDir` value of a `.modules.yaml`: JSON on pnpm 10+, /// YAML before (a top-level `virtualStoreDir:` scalar, maybe quoted). fn parse_modules_yaml_virtual_store_dir(text: &str) -> Option { - let text = crate::utils::serde::strip_bom(text); + let text = crate::formats::text::strip_bom(text); if let Ok(value) = serde_json::from_str::(text) { return value .get("virtualStoreDir")? diff --git a/crates/socket-patch-core/src/formats/mod.rs b/crates/socket-patch-core/src/formats/mod.rs index f3ea013a3..5cfd21aca 100644 --- a/crates/socket-patch-core/src/formats/mod.rs +++ b/crates/socket-patch-core/src/formats/mod.rs @@ -26,14 +26,15 @@ //! [`registry()`] is the one table of which project files carry a lock or //! its wiring, and in which roles. +pub(crate) mod bun; pub mod cargo; pub mod composer; pub mod gem; pub(crate) mod maven; pub(crate) mod nuget; pub mod pnpm; -pub(crate) mod bun; pub mod registry; +pub mod text; pub mod yarn; pub use registry::registry; @@ -81,7 +82,11 @@ mod architecture_tests { .filter(|l| !l.trim_start().starts_with("//")) .collect::>() .join("\n"); - let used: Vec<&str> = IMPURE.iter().copied().filter(|n| code.contains(n)).collect(); + let used: Vec<&str> = IMPURE + .iter() + .copied() + .filter(|n| code.contains(n)) + .collect(); assert!( used.is_empty(), "{}: a format model uses {used:?} — models are pure (module docs)", diff --git a/crates/socket-patch-core/src/formats/pnpm/grammar.rs b/crates/socket-patch-core/src/formats/pnpm/grammar.rs index 4b84251dd..c6b435bfe 100644 --- a/crates/socket-patch-core/src/formats/pnpm/grammar.rs +++ b/crates/socket-patch-core/src/formats/pnpm/grammar.rs @@ -7,10 +7,13 @@ use std::ops::Range; +use crate::formats::text::strip_bom; + /// Early pnpm 1 writes shrinkwrapVersion 3 without a minor version and /// unconditionally drops registry tarball URLs on install. Its frozen flag /// cannot preserve this redirect (verified with pnpm 1.0.0). pub(crate) fn unsupported_early_shrinkwrap(content: &str) -> bool { + let content = strip_bom(content); let version = content .lines() .find_map(|line| line.strip_prefix("shrinkwrapVersion:")); @@ -77,9 +80,11 @@ pub(crate) fn unquote(s: &str) -> &str { /// Whether `text` is a pnpm lock at all: a column-0 `lockfileVersion:` /// (pnpm >= 3) or `shrinkwrapVersion:` (pnpm 1 / 2) line — lockfile -/// discovery's sniff before it reads any entry. +/// discovery's sniff before it reads any entry. A leading BOM is encoding, +/// not key text: pnpm reads a BOM lock like its plain twin (#903). pub(crate) fn is_pnpm_lock_text(text: &str) -> bool { - text.lines() + strip_bom(text) + .lines() .any(|line| line.starts_with("lockfileVersion:") || line.starts_with("shrinkwrapVersion:")) } diff --git a/crates/socket-patch-core/src/formats/pnpm/mod.rs b/crates/socket-patch-core/src/formats/pnpm/mod.rs index c7d47c1f2..75dcb1d43 100644 --- a/crates/socket-patch-core/src/formats/pnpm/mod.rs +++ b/crates/socket-patch-core/src/formats/pnpm/mod.rs @@ -35,11 +35,11 @@ pub(crate) use hosted::plan_hosted; use std::collections::HashSet; use crate::constants::npm_family::PNPM_LOCK; +use crate::formats::text::strip_bom; use crate::utils::digest::is_sri_pin; use crate::vendor::lock_inventory::{http_url, LockIntegrity, LockfileEntry}; use crate::vendor::path::parse_vendor_path; - // ── entry model ── /// One `packages:` entry of a pnpm lock, read with the entry grammar @@ -235,9 +235,10 @@ impl PnpmLockGrammar { } /// The `lockfileVersion:` a lock head (its first five lines) declares, -/// unquoted. +/// unquoted. A leading BOM is encoding, not key text (#903). fn head_lock_version(text: &str) -> Option { - text.lines() + strip_bom(text) + .lines() .take(5) .find_map(|line| line.strip_prefix("lockfileVersion:")) .map(|rest| rest.trim().trim_matches(['\'', '"']).to_string()) @@ -276,14 +277,18 @@ pub fn sniff_lock_grammar(text: &str) -> Result { } /// The `(major, minor)` of every `lockfileVersion:` line of a lock (the -/// first one decides), unquoted; a missing minor reads as 0. +/// first one decides), unquoted; a missing minor reads as 0. A leading BOM +/// is encoding, not key text (#903). fn lock_versions(text: &str) -> impl Iterator, u32)> + '_ { - text.lines().filter_map(|line| { + strip_bom(text).lines().filter_map(|line| { let rest = line.strip_prefix("lockfileVersion:")?; let value = rest.trim().trim_matches(|c| c == '\'' || c == '"'); let mut parts = value.split('.'); let major = parts.next().and_then(|m| m.parse::().ok()); - let minor = parts.next().and_then(|m| m.parse::().ok()).unwrap_or(0); + let minor = parts + .next() + .and_then(|m| m.parse::().ok()) + .unwrap_or(0); Some((major, minor)) }) } @@ -303,10 +308,18 @@ pub fn lock_version_major(text: &str) -> Option { /// rejects it): a `shrinkwrapVersion` lock (pnpm 1–2) or lockfileVersion /// 5.0–5.2 (pnpm 3–5). Later locks never get the `--store` note. pub fn may_need_store_flag(text: &str) -> bool { - text.lines().any(|line| line.starts_with("shrinkwrapVersion:")) + is_shrinkwrap_lock(text) || lock_versions(text).any(|(major, minor)| major == Some(5) && minor <= 2) } +/// Whether a pnpm lock is a pnpm 1–2 `shrinkwrapVersion:` lock (a leading +/// BOM skipped). +pub fn is_shrinkwrap_lock(text: &str) -> bool { + strip_bom(text) + .lines() + .any(|line| line.starts_with("shrinkwrapVersion:")) +} + /// The lockfileVersion the v9 vendored planner splices. const V9_LOCK_VERSION: &str = "9.0"; @@ -491,7 +504,9 @@ pub(crate) fn vendored_npm_uuids(text: &str) -> HashSet { if !in_section { continue; } - if let Some(uuid) = lines::parse_key_line(line, 2).and_then(|(key, _, _)| vendored_npm_uuid(key)) { + if let Some(uuid) = + lines::parse_key_line(line, 2).and_then(|(key, _, _)| vendored_npm_uuid(key)) + { out.insert(uuid); } } @@ -508,17 +523,52 @@ mod tests { fn resolves_reads_every_key_generation_boundary_anchored() { let lock = |keys: &str| format!("lockfileVersion: '9.0'\n\npackages:\n\n{keys}"); let yes = [ - (" left-pad@1.3.0:\n resolution: {integrity: sha512-x}\n", "left-pad", "1.3.0"), - (" /left-pad@1.3.0:\n resolution: {}\n", "left-pad", "1.3.0"), - (" /left-pad/1.3.0:\n resolution: {}\n", "left-pad", "1.3.0"), - (" 'left-pad@1.3.0(react@18.0.0)':\n dev: false\n", "left-pad", "1.3.0"), - (" /left-pad/1.3.0_react@18.0.0:\n dev: false\n", "left-pad", "1.3.0"), - (" '@scope/name@1.0.0':\n dev: false\n", "@scope/name", "1.0.0"), - (" /@scope/name@1.0.0:\n dev: false\n", "@scope/name", "1.0.0"), - (" /@scope/name/1.0.0:\n dev: false\n", "@scope/name", "1.0.0"), + ( + " left-pad@1.3.0:\n resolution: {integrity: sha512-x}\n", + "left-pad", + "1.3.0", + ), + ( + " /left-pad@1.3.0:\n resolution: {}\n", + "left-pad", + "1.3.0", + ), + ( + " /left-pad/1.3.0:\n resolution: {}\n", + "left-pad", + "1.3.0", + ), + ( + " 'left-pad@1.3.0(react@18.0.0)':\n dev: false\n", + "left-pad", + "1.3.0", + ), + ( + " /left-pad/1.3.0_react@18.0.0:\n dev: false\n", + "left-pad", + "1.3.0", + ), + ( + " '@scope/name@1.0.0':\n dev: false\n", + "@scope/name", + "1.0.0", + ), + ( + " /@scope/name@1.0.0:\n dev: false\n", + "@scope/name", + "1.0.0", + ), + ( + " /@scope/name/1.0.0:\n dev: false\n", + "@scope/name", + "1.0.0", + ), ]; for (keys, name, version) in yes { - assert!(PnpmLock::parse(&lock(keys)).resolves(name, version), "{keys}"); + assert!( + PnpmLock::parse(&lock(keys)).resolves(name, version), + "{keys}" + ); } let no = [ (" left-pad@1.3.0-beta.1:\n dev: false\n", "left-pad", "1.3.0"), @@ -534,7 +584,10 @@ mod tests { ), ]; for (keys, name, version) in no { - assert!(!PnpmLock::parse(&lock(keys)).resolves(name, version), "{keys}"); + assert!( + !PnpmLock::parse(&lock(keys)).resolves(name, version), + "{keys}" + ); } // Keys outside `packages:` (importers, overrides) resolve nothing. let importers = "lockfileVersion: '9.0'\n\nimporters:\n\n left-pad@1.3.0:\n x: y\n"; @@ -557,7 +610,10 @@ mod tests { let other = "22222222-2222-4222-8222-222222222222"; assert!(!PnpmLock::parse(text).vendored_in_use(other)); let crlf = text.replace('\n', "\r\n"); - assert!(PnpmLock::parse(&crlf).vendored_in_use(UUID), "CRLF reads like LF"); + assert!( + PnpmLock::parse(&crlf).vendored_in_use(UUID), + "CRLF reads like LF" + ); } // An overrides declaration alone is not usage. let overrides = format!( @@ -582,4 +638,65 @@ mod tests { ); assert_eq!(PnpmLock::parse(&neighbour).wired_integrity(&rel), None); } + + /// #903 / #905: a leading UTF-8 BOM is encoding, not content — pnpm + /// reads a BOM lock like its plain twin, so every sniff here must too. + /// Before the fix the BOM twin read as "not a pnpm lock", unversioned + /// and unsupported while its entries still parsed. + #[test] + fn bom_lock_reads_like_its_plain_twin() { + let v9 = "lockfileVersion: '9.0'\n\nimporters:\n\n .:\n dependencies:\n left-pad:\n specifier: 1.3.0\n version: 1.3.0\n\npackages:\n\n left-pad@1.3.0:\n resolution: {integrity: sha512-x}\n"; + let v6 = "lockfileVersion: '6.0'\n\ndependencies:\n left-pad:\n specifier: 1.3.0\n version: 1.3.0\n\npackages:\n\n /left-pad@1.3.0:\n resolution: {integrity: sha512-x}\n dev: false\n"; + let v54 = "lockfileVersion: 5.4\n\nspecifiers:\n left-pad: 1.3.0\n\ndependencies:\n left-pad: 1.3.0\n\npackages:\n\n /left-pad/1.3.0:\n resolution: {integrity: sha512-x}\n dev: false\n"; + let v52 = "lockfileVersion: 5.2\n\npackages:\n\n /left-pad/1.3.0:\n resolution: {integrity: sha512-x}\n"; + let shrinkwrap = "shrinkwrapVersion: 3\nshrinkwrapMinorVersion: 7\n\npackages:\n\n /left-pad/1.3.0:\n resolution: {integrity: sha512-x}\n"; + for plain in [v9, v6, v54, v52, shrinkwrap] { + let bom = format!("\u{feff}{plain}"); + assert!(PnpmLock::parse(plain).is_pnpm_lock(), "{plain}"); + assert!(PnpmLock::parse(&bom).is_pnpm_lock(), "BOM twin of {plain}"); + assert!(is_pnpm_lock_text(&bom), "{plain}"); + assert_eq!( + sniff_lock_grammar(&bom), + sniff_lock_grammar(plain), + "{plain}" + ); + assert_eq!( + lock_version_major(&bom), + lock_version_major(plain), + "{plain}" + ); + assert_eq!( + may_need_store_flag(&bom), + may_need_store_flag(plain), + "{plain}" + ); + assert_eq!( + check_v9_lock_version(&bom), + check_v9_lock_version(plain), + "{plain}" + ); + let keys = |text: &str| { + PnpmLock::parse(text).entries().map(|e| { + e.into_iter() + .map(|e| (e.name, e.version)) + .collect::>() + }) + }; + assert_eq!(keys(&bom), keys(plain), "{plain}"); + } + assert_eq!( + sniff_lock_grammar(&format!("\u{feff}{v9}")), + Ok(PnpmLockGrammar::V9) + ); + assert_eq!(lock_version_major(&format!("\u{feff}{v9}")), Some(9)); + assert!(may_need_store_flag(&format!("\u{feff}{v52}"))); + assert!(may_need_store_flag(&format!("\u{feff}{shrinkwrap}"))); + assert!(grammar::unsupported_early_shrinkwrap( + "\u{feff}shrinkwrapVersion: 3\n" + )); + // Exactly one BOM is encoding; a second one is content, as for pnpm. + assert!(!is_pnpm_lock_text( + "\u{feff}\u{feff}lockfileVersion: '9.0'\n" + )); + } } diff --git a/crates/socket-patch-core/src/formats/pnpm/workspace.rs b/crates/socket-patch-core/src/formats/pnpm/workspace.rs index f30c28fc4..c93f45295 100644 --- a/crates/socket-patch-core/src/formats/pnpm/workspace.rs +++ b/crates/socket-patch-core/src/formats/pnpm/workspace.rs @@ -13,11 +13,16 @@ //! //! Pure text in, answers out; the editors own the reads and writes. +use crate::formats::text::strip_bom; + /// The parsed key (quotes removed) and its inline value (comment and /// surrounding blanks stripped; `""` for a block-valued key) when `line` is /// a top-level mapping key. Indented lines, comments, sequence items and -/// document markers are not keys. +/// document markers are not keys. A leading BOM (the file's first line) is +/// encoding, not key text, as for pnpm's YAML parser (#904); a splice that +/// keeps the line itself keeps the BOM byte-exact. pub(crate) fn top_level_key(line: &str) -> Option<(String, &str)> { + let line = strip_bom(line); let line = line.strip_suffix('\r').unwrap_or(line); let first = *line.as_bytes().first()?; if matches!(first, b' ' | b'\t' | b'#' | b'-' | b'{' | b'[' | b'%') || is_marker(line, "...") { @@ -61,7 +66,8 @@ pub(crate) fn block_insert_point(lines: &[String]) -> Result { let mut end_marker = None; let mut last = None; // the last non-blank line of the document for (i, raw) in lines.iter().enumerate() { - let line = raw.strip_suffix('\r').unwrap_or(raw); + let line = if i == 0 { strip_bom(raw) } else { raw }; + let line = line.strip_suffix('\r').unwrap_or(line); let comment = line.trim_start().starts_with('#'); if end_marker.is_some() { if !(line.trim().is_empty() || comment) { @@ -280,4 +286,32 @@ mod tests { let inline = lines("overrides : {a: 1}\n"); assert_eq!(block_section_bounds(&inline, "overrides"), None); } + + /// #904: a BOM-prefixed first line is the same key pnpm reads — it must + /// not hide `trustLockfile:` / `overrides:` from the splices (which + /// then appended a duplicate key pnpm refuses to parse). + #[test] + fn top_level_key_skips_a_leading_bom() { + assert_eq!( + top_level_key("\u{feff}trustLockfile: false"), + Some(("trustLockfile".to_string(), "false")) + ); + assert_eq!( + top_level_key("\u{feff}overrides:"), + Some(("overrides".to_string(), "")) + ); + assert_eq!( + top_level_key("\u{feff}'trustLockfile': true"), + Some(("trustLockfile".to_string(), "true")) + ); + assert_eq!(top_level_key("\u{feff} trustLockfile: true"), None); + assert_eq!(top_level_key("\u{feff}# trustLockfile: true"), None); + let bom = lines("\u{feff}overrides:\n is-number: 7.0.0\npackages:\n - .\n"); + assert_eq!(block_insert_point(&bom), Ok(4)); + assert_eq!(block_section_bounds(&bom, "overrides"), Some((0, 2))); + assert_eq!( + block_insert_point(&lines("\u{feff}{packages: [.]}\n")), + Err("is a flow-style YAML document".to_string()) + ); + } } diff --git a/crates/socket-patch-core/src/formats/text.rs b/crates/socket-patch-core/src/formats/text.rs new file mode 100644 index 000000000..f52bd8df4 --- /dev/null +++ b/crates/socket-patch-core/src/formats/text.rs @@ -0,0 +1,45 @@ +//! The one rule every text reader shares: a leading UTF-8 byte-order mark +//! is encoding, not content (#905). +//! +//! The package managers whose files socket-patch reads strip it before they +//! parse (npm and Node for package.json, pnpm's and yarn's YAML parsers, +//! cargo, Bundler, Gradle), and Windows editors that save "UTF-8 with +//! signature" add it. A reader that matches a column-0 literal +//! (`lockfileVersion:`, a top-level YAML key) or hands the text to a strict +//! parser (serde_json) must therefore skip it first, and an editor that +//! rewrites the file must put it back. Exactly one BOM is encoding; a +//! second one is content, as for every tool above. + +/// `(bom, rest)`: a leading UTF-8 BOM split off (`""` when there is none), +/// so an edit can read `rest` and restore `bom` byte-exact on write. +pub fn split_bom(text: &str) -> (&str, &str) { + match text.strip_prefix('\u{feff}') { + Some(rest) => ("\u{feff}", rest), + None => ("", text), + } +} + +/// `text` without a leading UTF-8 BOM. +pub fn strip_bom(text: &str) -> &str { + split_bom(text).1 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn split_bom_splits_exactly_one_bom() { + assert_eq!(split_bom("\u{feff}a: 1\n"), ("\u{feff}", "a: 1\n")); + assert_eq!(split_bom("a: 1\n"), ("", "a: 1\n")); + assert_eq!(split_bom("\u{feff}\u{feff}a"), ("\u{feff}", "\u{feff}a")); + assert_eq!(split_bom(""), ("", "")); + } + + #[test] + fn strip_bom_drops_one_leading_bom_only() { + assert_eq!(strip_bom("\u{feff}x"), "x"); + assert_eq!(strip_bom("x\u{feff}"), "x\u{feff}"); + assert_eq!(strip_bom("\u{feff}\u{feff}x"), "\u{feff}x"); + } +} diff --git a/crates/socket-patch-core/src/formats/yarn/mod.rs b/crates/socket-patch-core/src/formats/yarn/mod.rs index 389dadde0..003581938 100644 --- a/crates/socket-patch-core/src/formats/yarn/mod.rs +++ b/crates/socket-patch-core/src/formats/yarn/mod.rs @@ -10,6 +10,8 @@ pub(crate) mod berry_entry; +use super::text::strip_bom; + /// Which grammar a `yarn.lock` head declares. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum YarnLockGrammar { @@ -54,17 +56,16 @@ pub fn is_berry_lock(content: &str) -> bool { .any(|line| line.starts_with("__metadata:")) } -fn strip_bom(text: &str) -> &str { - text.strip_prefix('\u{feff}').unwrap_or(text) -} - #[cfg(test)] mod tests { use super::*; #[test] fn sniff_prefers_berry_and_skips_a_bom() { - assert_eq!(sniff_grammar("__metadata:\n version: 8\n"), Some(YarnLockGrammar::Berry)); + assert_eq!( + sniff_grammar("__metadata:\n version: 8\n"), + Some(YarnLockGrammar::Berry) + ); assert_eq!( sniff_grammar("\u{feff}# yarn lockfile v1\r\n"), Some(YarnLockGrammar::Classic) diff --git a/crates/socket-patch-core/src/gradle/dsl.rs b/crates/socket-patch-core/src/gradle/dsl.rs index 11e74cf07..038fed266 100644 --- a/crates/socket-patch-core/src/gradle/dsl.rs +++ b/crates/socket-patch-core/src/gradle/dsl.rs @@ -30,11 +30,6 @@ pub fn dsl_of(rel: &str) -> Option { } } -/// `s` without a leading UTF-8 byte-order mark. -pub fn strip_bom(s: &str) -> &str { - s.strip_prefix('\u{feff}').unwrap_or(s) -} - /// Script bytes as text: a leading BOM is dropped and anything that is not /// UTF-8 is `None` (unparseable), never decoded lossily. pub fn decode(bytes: &[u8]) -> Option { @@ -745,8 +740,6 @@ mod tests { fn bom_is_skipped() { let src = "\u{feff}include ':a'\n"; assert_eq!(render(src, Dsl::Groovy), ["I:include", "S::a"]); - assert_eq!(strip_bom(src), "include ':a'\n"); - assert_eq!(strip_bom("x"), "x"); assert_eq!( decode(b"\xef\xbb\xbfinclude ':a'").as_deref(), Some("include ':a'") diff --git a/crates/socket-patch-core/src/gradle/graph.rs b/crates/socket-patch-core/src/gradle/graph.rs index 486660fa0..8b74e690e 100644 --- a/crates/socket-patch-core/src/gradle/graph.rs +++ b/crates/socket-patch-core/src/gradle/graph.rs @@ -854,7 +854,7 @@ impl Collector<'_> { self.unresolved(rel, 0, Site::Script, Reason::TooLarge, String::new()); return None; } - Some(dsl::strip_bom(&text).to_string()) + Some(crate::formats::text::strip_bom(&text).to_string()) } /// The Groovy (preferred, as Gradle does) or Kotlin `` script of @@ -1185,7 +1185,7 @@ impl ScriptGraph { continue; } c.files += 1; - let text = dsl::strip_bom(text).to_string(); + let text = crate::formats::text::strip_bom(text).to_string(); let dsl = dsl::dsl_of(tag).unwrap_or(Dsl::Groovy); if !dsl::well_formed(&text, dsl) { c.graph.init_unparseable.push(tag.clone()); @@ -1414,7 +1414,7 @@ pub fn subproject_owner( problems.push(issue(Reason::TooLarge)); return None; } - let text = dsl::strip_bom(&text).to_string(); + let text = crate::formats::text::strip_bom(&text).to_string(); let dsl = dsl::dsl_of(r).unwrap_or(Dsl::Groovy); if !dsl::well_formed(&text, dsl) { problems.push(issue(Reason::Unparseable)); @@ -1451,16 +1451,18 @@ pub fn subproject_owner( /// `gradle/wrapper/gradle-wrapper.properties`. pub fn wrapper_version(read: TextReadFn<'_>, root: &str) -> Option<(u32, u32, u32)> { let text = read(&join_rel(root, "gradle/wrapper/gradle-wrapper.properties"))?; - let url = dsl::strip_bom(&text).lines().find_map(|line| { - let line = line.trim_start(); - let rest = line.strip_prefix("distributionUrl")?; - let rest = rest.trim_start(); - let rest = rest - .strip_prefix('=') - .or_else(|| rest.strip_prefix(':')) - .unwrap_or(rest); - Some(rest.trim().replace('\\', "")) - })?; + let url = crate::formats::text::strip_bom(&text) + .lines() + .find_map(|line| { + let line = line.trim_start(); + let rest = line.strip_prefix("distributionUrl")?; + let rest = rest.trim_start(); + let rest = rest + .strip_prefix('=') + .or_else(|| rest.strip_prefix(':')) + .unwrap_or(rest); + Some(rest.trim().replace('\\', "")) + })?; let re = regex::Regex::new(r"gradle-(\d+)\.(\d+)(?:\.(\d+))?").ok()?; let caps = re.captures(&url)?; Some(( diff --git a/crates/socket-patch-core/src/gradle/locks.rs b/crates/socket-patch-core/src/gradle/locks.rs index 118c21abb..5b9eebe34 100644 --- a/crates/socket-patch-core/src/gradle/locks.rs +++ b/crates/socket-patch-core/src/gradle/locks.rs @@ -153,7 +153,7 @@ fn split_confs(confs: &str) -> Vec { /// Parse a lock file (either format; CRLF and a BOM are fine). pub fn parse(text: &str) -> LockState { let mut state = LockState::default(); - for (idx, raw) in super::dsl::strip_bom(text).lines().enumerate() { + for (idx, raw) in crate::formats::text::strip_bom(text).lines().enumerate() { let line = raw.trim(); if line.is_empty() || line.starts_with('#') { continue; diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index f84f61b1a..42e8df7a9 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -45,11 +45,12 @@ use super::guidance::{ npm_allow_remote_env_set_detail, npm_allow_remote_manual_detail, npm_allow_remote_outer_set_detail, npm_allow_remote_unreadable_detail, npm_allow_remote_user_set_detail, npm_lock_url_needles, plan_workspace_trust, pnpm_heal_root, - pnpm_lock_may_need_store_flag, pnpm_lock_version_major, pnpm_trust_configured_detail, - pnpm_trust_legacy_detail, pnpm_trust_manual_guidance, pnpm_trust_policy_preamble, - pnpm_trust_workspace_unreadable_detail, pnpm_trust_workspace_unsupported_detail, - read_npmrc_for_allow_remote, read_workspace_for_trust, url_host, TrustPlan, NPM_LOCKS, - PNPM_TRUST_TRADEOFF_AND_CAUTION, PNPM_WORKSPACE_REL, REDIRECT_PNPM_WORKSPACE_TRUST_EDIT_KIND, + pnpm_is_shrinkwrap_lock, pnpm_lock_may_need_store_flag, pnpm_lock_version_major, + pnpm_trust_configured_detail, pnpm_trust_legacy_detail, pnpm_trust_manual_guidance, + pnpm_trust_policy_preamble, pnpm_trust_workspace_unreadable_detail, + pnpm_trust_workspace_unsupported_detail, read_npmrc_for_allow_remote, read_workspace_for_trust, + url_host, TrustPlan, NPM_LOCKS, PNPM_TRUST_TRADEOFF_AND_CAUTION, PNPM_WORKSPACE_REL, + REDIRECT_PNPM_WORKSPACE_TRUST_EDIT_KIND, }; use super::vlt::bun_lockb_present; @@ -1306,9 +1307,7 @@ fn pnpm_trust( // needed" for a lock whose era is unknown. let all_locks_legacy = pnpm_lock_texts.iter().all(|text| { pnpm_lock_version_major(text).is_some_and(|major| major < 9) - || text - .lines() - .any(|line| line.starts_with("shrinkwrapVersion:")) + || pnpm_is_shrinkwrap_lock(text) }); let detail = if all_locks_legacy { pnpm_trust_legacy_detail(&server) diff --git a/crates/socket-patch-core/src/hosted/governing_root.rs b/crates/socket-patch-core/src/hosted/governing_root.rs index a45954310..fdca5aaa5 100644 --- a/crates/socket-patch-core/src/hosted/governing_root.rs +++ b/crates/socket-patch-core/src/hosted/governing_root.rs @@ -178,24 +178,24 @@ async fn lock_elsewhere(project: &Path, base: &Path, dir: &str) -> Option Option { - let yaml = yaml.strip_prefix('\u{feff}').unwrap_or(yaml); - // The last assignment wins: scan from the end. - yaml.lines().rev().find_map(|line| { - let rest = ["lockfileDir", "\"lockfileDir\"", "'lockfileDir'"] - .iter() - .find_map(|key| line.strip_prefix(key))? - .trim_start(); - let value = rest.strip_prefix(':')?.trim(); - let value = match value.chars().next() { - Some(q @ ('"' | '\'')) => value[1..].split(q).next().unwrap_or(""), - _ => value.split(" #").next().unwrap_or("").trim(), - }; - (!value.is_empty()).then(|| value.to_string()) - }) + yaml.lines() + .filter_map(crate::formats::pnpm::workspace::top_level_key) + .rfind(|(key, _)| key == "lockfileDir") + .map(|(_, value)| unquote_scalar(value).to_string()) + .filter(|value| !value.is_empty()) +} + +/// A YAML scalar value without its surrounding quotes. +fn unquote_scalar(value: &str) -> &str { + match value.as_bytes() { + [q @ (b'"' | b'\''), .., last] if last == q => &value[1..value.len() - 1], + _ => value, + } } #[cfg(test)] @@ -456,5 +456,13 @@ mod tests { workspace_lockfile_dir("lockfileDir: ../a\nlockfileDir: ../b\n").as_deref(), Some("../b") ); + // #905: read through the shared `top_level_key` grammar, so every + // spelling the workspace splices accept is read here too. + assert_eq!( + workspace_lockfile_dir("lockfileDir : ../x # shared lock\n").as_deref(), + Some("../x") + ); + assert_eq!(workspace_lockfile_dir("lockfileDir: \"\"\n"), None); + assert_eq!(workspace_lockfile_dir("# lockfileDir: ..\n"), None); } } diff --git a/crates/socket-patch-core/src/hosted/guidance.rs b/crates/socket-patch-core/src/hosted/guidance.rs index 51ec85483..666035319 100644 --- a/crates/socket-patch-core/src/hosted/guidance.rs +++ b/crates/socket-patch-core/src/hosted/guidance.rs @@ -217,7 +217,7 @@ pub fn pnpm_trust_configured_detail(server: &str, created: bool, dry_run: bool) // The pnpm lock-version sniffs live with the format's model. pub use crate::formats::pnpm::{ - lock_version_major as pnpm_lock_version_major, + is_shrinkwrap_lock as pnpm_is_shrinkwrap_lock, lock_version_major as pnpm_lock_version_major, may_need_store_flag as pnpm_lock_may_need_store_flag, }; @@ -431,3 +431,29 @@ pub fn read_npmrc_for_allow_remote(path: &std::path::Path) -> Result assert_eq!(value, "false"), + _ => panic!("expected UserSet(false)"), + } + assert!(matches!( + plan_workspace_trust(Some("\u{feff}trustLockfile: true\npackages:\n - .\n")), + TrustPlan::AlreadyTrue + )); + match plan_workspace_trust(Some("\u{feff}packages:\n - .\n")) { + TrustPlan::Append(text) => { + assert_eq!(text, "\u{feff}packages:\n - .\ntrustLockfile: true\n") + } + _ => panic!("expected Append"), + } + } +} diff --git a/crates/socket-patch-core/src/patch/redirect/gradle.rs b/crates/socket-patch-core/src/patch/redirect/gradle.rs index fb797e047..6ae1f877f 100644 --- a/crates/socket-patch-core/src/patch/redirect/gradle.rs +++ b/crates/socket-patch-core/src/patch/redirect/gradle.rs @@ -469,7 +469,7 @@ impl GradleFiles { fn read(&self, rel: &str, misses: &std::cell::RefCell>) -> Option { if let Some(t) = self.files.get(rel) { - return Some(dsl::strip_bom(t).to_string()); + return Some(crate::formats::text::strip_bom(t).to_string()); } if !self.absent.contains(rel) { misses.borrow_mut().insert(rel.to_string()); @@ -637,7 +637,11 @@ fn key_list(files: &BTreeMap, dir: &str) -> Vec { /// The script graph of the build `files` holds (BOMs stripped for the /// tokenizer). pub fn graph_of(files: &BTreeMap) -> ScriptGraph { - let read = |rel: &str| files.get(rel).map(|t| dsl::strip_bom(t).to_string()); + let read = |rel: &str| { + files + .get(rel) + .map(|t| crate::formats::text::strip_bom(t).to_string()) + }; let list = |dir: &str| key_list(files, dir); ScriptGraph::collect(&read, &list, "", &[]) } @@ -694,7 +698,11 @@ fn project_refusal( graph: &ScriptGraph, index: &Result, String>, ) -> Option { - let read = |rel: &str| files.get(rel).map(|t| dsl::strip_bom(t).to_string()); + let read = |rel: &str| { + files + .get(rel) + .map(|t| crate::formats::text::strip_bom(t).to_string()) + }; if let Some((maj, min, patch)) = wrapper_version(&read, "") { if (maj, min) < (6, 8) { return Some(refusal( diff --git a/crates/socket-patch-core/src/update/channel.rs b/crates/socket-patch-core/src/update/channel.rs index 0b5e332aa..1ce8c26c1 100644 --- a/crates/socket-patch-core/src/update/channel.rs +++ b/crates/socket-patch-core/src/update/channel.rs @@ -151,7 +151,7 @@ fn is_vlx_cache_dir(dir: &Path) -> bool { crate::utils::fs::read_regular_to_string_sync(&dir.join("package.json")) .ok() .and_then(|text| { - serde_json::from_str::(crate::utils::serde::strip_bom(&text)).ok() + serde_json::from_str::(crate::formats::text::strip_bom(&text)).ok() }) .is_some_and(|pkg| pkg.get("name").and_then(|n| n.as_str()) == Some("vlx")) } diff --git a/crates/socket-patch-core/src/utils/serde.rs b/crates/socket-patch-core/src/utils/serde.rs index 666563d25..7f5f288c5 100644 --- a/crates/socket-patch-core/src/utils/serde.rs +++ b/crates/socket-patch-core/src/utils/serde.rs @@ -19,12 +19,3 @@ where { map.iter().collect::>().serialize(serializer) } - -/// Strip a leading UTF-8 BOM. npm and Node tolerate (and strip) a BOM in -/// package.json, and cargo accepts one in Cargo.toml — files saved by Windows -/// editors commonly carry one — but serde_json (and vex's TOML line scanner) -/// reject it, so every parse of user-supplied manifest content must go through -/// this first or toolchain-valid manifests error out. -pub(crate) fn strip_bom(content: &str) -> &str { - content.strip_prefix('\u{feff}').unwrap_or(content) -} diff --git a/crates/socket-patch-core/src/vendor/cargo_manifest.rs b/crates/socket-patch-core/src/vendor/cargo_manifest.rs index 8b35f5fa9..ddb4eae21 100644 --- a/crates/socket-patch-core/src/vendor/cargo_manifest.rs +++ b/crates/socket-patch-core/src/vendor/cargo_manifest.rs @@ -57,6 +57,7 @@ use std::path::Path; use toml_edit::{DocumentMut, InlineTable, Item, Value}; +use crate::formats::text::split_bom; use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_string}; use super::cargo_config::{ensure_table_like, patch_entries, path_is_socket_owned}; @@ -363,15 +364,6 @@ pub fn parse_manifest(content: &str) -> Result { .map_err(|e| ManifestError::Unparseable(format!("Cargo.toml is not valid TOML: {e}"))) } -/// `(bom, rest)`: a leading UTF-8 BOM (`toml_edit` accepts it but never -/// renders it back), split off so an edit can restore it. -fn split_bom(content: &str) -> (&str, &str) { - match content.strip_prefix('\u{feff}') { - Some(rest) => ("\u{feff}", rest), - None => ("", content), - } -} - /// `edited` (the `toml_edit` rendering of `original` after an edit) mapped /// back onto `original`'s BOM and line endings. fn render_like(original: &str, edited: &str) -> String { diff --git a/crates/socket-patch-core/src/vendor/npm_dir.rs b/crates/socket-patch-core/src/vendor/npm_dir.rs index 399afe22c..43dcee5ee 100644 --- a/crates/socket-patch-core/src/vendor/npm_dir.rs +++ b/crates/socket-patch-core/src/vendor/npm_dir.rs @@ -777,7 +777,7 @@ async fn read_manifest(dir: &Path) -> Result { let text = crate::utils::fs::read_regular_to_string(&dir.join("package.json")) .await .map_err(|e| format!("package.json unreadable: {e}"))?; - serde_json::from_str(crate::utils::serde::strip_bom(&text)) + serde_json::from_str(crate::formats::text::strip_bom(&text)) .map_err(|e| format!("package.json is not parseable JSON: {e}")) } diff --git a/crates/socket-patch-core/src/vendor/pnpm_lock.rs b/crates/socket-patch-core/src/vendor/pnpm_lock.rs index 88777f0c7..6d76535c2 100644 --- a/crates/socket-patch-core/src/vendor/pnpm_lock.rs +++ b/crates/socket-patch-core/src/vendor/pnpm_lock.rs @@ -5630,6 +5630,56 @@ snapshots: ); } + /// #903 / #905 (vendored): a BOM-prefixed lock is the lock pnpm reads. + /// The flavor sniff used to refuse it as having "no lockfileVersion"; + /// it now vendors like its plain twin, keeps the BOM, and reverts + /// byte-exact. + #[tokio::test] + async fn bom_lock_vendors_and_reverts_byte_exact() { + let bom_lock = format!("\u{feff}{P1_BEFORE_LOCK}"); + let fx = fixture_with(P1_BEFORE_PKG, &bom_lock).await; + + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + let lock = fx.read(PNPM_LOCK).await; + assert!(lock.starts_with("\u{feff}lockfileVersion:"), "{lock}"); + assert!(lock.contains(&fx.rel_tgz()), "{lock}"); + assert_eq!(lock.matches('\u{feff}').count(), 1, "{lock}"); + + let outcome = revert_pnpm(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert_eq!(fx.read(PNPM_LOCK).await, bom_lock, "revert is byte-exact"); + assert_eq!(fx.read(PACKAGE_JSON).await, P1_BEFORE_PKG); + } + + /// #904 (vendored): a BOM-prefixed `overrides:` first line is the + /// user's existing section. The override goes in beside theirs and the + /// BOM stays byte-exact; before the fix the section was missed and a + /// duplicate top-level `overrides:` appended, which pnpm refuses to parse. + #[tokio::test] + async fn bom_workspace_override_inserted_beside_existing_not_duplicated() { + let fx = fixture_with(P1_BEFORE_PKG, P1_BEFORE_LOCK).await; + let original = "\u{feff}overrides:\n other-pkg: 2.0.0\npackages:\n - 'packages/*'\n"; + write_ws(&fx, original).await; + + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + let spec = format!("file:{}", fx.rel_tgz()); + assert_eq!( + fx.read(PNPM_WORKSPACE).await, + format!("\u{feff}overrides:\n other-pkg: 2.0.0\n left-pad@1.3.0: {spec}\npackages:\n - 'packages/*'\n"), + ); + assert!(!entry.pnpm.as_ref().unwrap().created_workspace_overrides); + + let outcome = revert_pnpm(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert_eq!( + fx.read(PNPM_WORKSPACE).await, + original, + "revert is byte-exact" + ); + } + /// [`P1_BEFORE_LOCK`] as pnpm 10.5+ writes it when the user's /// `is-number: 6.0.0` override lives in pnpm-workspace.yaml: the lock's /// `overrides:` records the workspace-file override. diff --git a/crates/socket-patch-core/src/vendor/vlt_lock.rs b/crates/socket-patch-core/src/vendor/vlt_lock.rs index 9f44da401..3939052bd 100644 --- a/crates/socket-patch-core/src/vendor/vlt_lock.rs +++ b/crates/socket-patch-core/src/vendor/vlt_lock.rs @@ -599,7 +599,7 @@ fn check_declarations( format!("{pkg_rel} is missing; run `vlt install` first"), ) })?; - let value: Value = serde_json::from_str(crate::utils::serde::strip_bom(text)) + let value: Value = serde_json::from_str(crate::formats::text::strip_bom(text)) .map_err(|_| (OUT_OF_SYNC, format!("{pkg_rel} is not valid JSON")))?; let declared = ["dependencies", "devDependencies", "optionalDependencies"] .iter() @@ -731,7 +731,7 @@ pub async fn vlt_vendor_preflight( .join(&name) .join(PACKAGE_JSON); if let Ok(text) = read_regular_to_string(&store).await { - if let Ok(pkg) = serde_json::from_str::(crate::utils::serde::strip_bom(&text)) { + if let Ok(pkg) = serde_json::from_str::(crate::formats::text::strip_bom(&text)) { if super::npm_common::declares_bundled_deps(&pkg) { return Err(( "vendor_bundled_deps_unsupported", diff --git a/crates/socket-patch-core/src/vex/product.rs b/crates/socket-patch-core/src/vex/product.rs index 3273b6e6e..897d1de41 100644 --- a/crates/socket-patch-core/src/vex/product.rs +++ b/crates/socket-patch-core/src/vex/product.rs @@ -37,7 +37,7 @@ use std::path::Path; // git reads a BOM'd `.git/config`, but serde_json and the line scanners all // reject it — without this, files the user's own toolchain accepts yield no // PURL. -use crate::utils::serde::strip_bom; +use crate::formats::text::strip_bom; /// Version-extracting parser for one manifest flavor, keyed by file name in /// the priority table inside [`detect_product`]. From 7235dc3b8139b3cc4d9cd5adc08deac6e9acf7fe Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 01:35:27 +0000 Subject: [PATCH 3/4] Test hosted pnpm scan/rollback on BOM files Covers the #903 and #904 user flows end to end: a BOM lock gets the trustLockfile auto-config and rolls back byte-exact, and a BOM first trustLockfile key in pnpm-workspace.yaml is respected instead of duplicated. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/in_process_redirect_pnpm.rs | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs index 4519d1d4d..edf722813 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs @@ -405,6 +405,78 @@ async fn hosted_trust_edit_reads_the_workspace_yaml_shape() { } } +/// #903 / #904: a `pnpm-lock.yaml` and `pnpm-workspace.yaml` saved with a +/// UTF-8 BOM read like their plain twins. The BOM lock gets the +/// `trustLockfile: true` auto-config (it used to read as unversioned and +/// skip it), `rollback` unwinds the pin it just wrote (it used to refuse the +/// lock as "not a pnpm lockfile") byte-exact, BOM included, and a BOM first +/// `trustLockfile: false` key is the user's explicit opt-out, not a missing +/// key a duplicate is appended after. +#[tokio::test] +#[serial] +async fn hosted_bom_lock_and_workspace_read_like_their_plain_twins() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + + // A BOM lock, no workspace file: the trust scaffold is created and the + // rollback restores the lock byte for byte. + let tmp = tempfile::tempdir().unwrap(); + write_pnpm_project(tmp.path()); + let lock_path = tmp.path().join("pnpm-lock.yaml"); + let pristine = format!("\u{feff}{}", std::fs::read_to_string(&lock_path).unwrap()); + std::fs::write(&lock_path, &pristine).unwrap(); + + let code = run(hosted_args(tmp.path(), server.uri())).await; + assert_eq!(code, 0, "scan --mode hosted should succeed on a BOM lock"); + let lock = std::fs::read_to_string(&lock_path).unwrap(); + assert!(lock.starts_with("\u{feff}lockfileVersion:"), "{lock}"); + assert!(lock.contains(HOSTED_URL), "the BOM lock is redirected: {lock}"); + let ws_path = tmp.path().join("pnpm-workspace.yaml"); + assert_eq!( + std::fs::read_to_string(&ws_path).ok().as_deref(), + Some("packages:\n - '.'\ntrustLockfile: true\n"), + "a BOM v9 lock gets the trustLockfile auto-config" + ); + + let code = rollback_hosted(tmp.path(), &server).await; + assert_eq!(code, 0, "rollback must unwind the pin on a BOM lock"); + assert_eq!( + std::fs::read_to_string(&lock_path).unwrap(), + pristine, + "rollback restores the BOM lock byte for byte" + ); + assert!(!ws_path.exists(), "the auto-created workspace file goes too"); + + // A BOM workspace file whose first key is the user's opt-out: left + // byte-identical (no duplicate `trustLockfile`), lock still redirected. + // One whose first key is something else gains the key once, BOM kept. + for (user_ws, want) in [ + ("\u{feff}trustLockfile: false\npackages:\n - '.'\n", None), + ("\u{feff}trustLockfile: true\npackages:\n - '.'\n", None), + ( + "\u{feff}packages:\n - '.'\n", + Some("\u{feff}packages:\n - '.'\ntrustLockfile: true\n"), + ), + ] { + let tmp = tempfile::tempdir().unwrap(); + write_pnpm_project(tmp.path()); + std::fs::write(tmp.path().join("pnpm-workspace.yaml"), user_ws).unwrap(); + + let code = run(hosted_args(tmp.path(), server.uri())).await; + assert_eq!(code, 0, "scan --mode hosted should succeed for {user_ws:?}"); + assert!( + std::fs::read_to_string(tmp.path().join("pnpm-lock.yaml")) + .unwrap() + .contains(HOSTED_URL), + "the lock is still redirected for {user_ws:?}" + ); + let ws = std::fs::read_to_string(tmp.path().join("pnpm-workspace.yaml")).unwrap(); + assert_eq!(ws, want.unwrap_or(user_ws), "workspace file for {user_ws:?}"); + assert_eq!(ws.matches("trustLockfile").count(), 1, "{ws:?}"); + } +} + /// `--dry-run` previews: NOTHING lands on disk — no lock rewrite, no /// pnpm-workspace.yaml, no ledger — while the envelope still reports both /// files as would-be-rewritten (`dryRun: true`). From 7515fa773d8d10ca2967409d3700e6590250edd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 18:24:21 +0000 Subject: [PATCH 4/4] 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. Assisted-by: Claude Code:claude-opus-5-5 (cherry picked from commit 659ac2c24e5c5904e743b4bc98ea4645da2ed6a1) --- 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)), } }