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`). 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/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/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/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/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/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`].