From e31da36753eedb050464c2bcb896d287f8e4db7e Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 23 Sep 2026 14:33:50 -0400 Subject: [PATCH 1/9] fix(core): hosted/vendored rewriter fixes found by the real-PM matrices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First of seven commits that land the manifest-less VEX branch (feat/vex-lockfile-inventory) on top of #247. The branch's ~165-commit history, built on the pre-#247 main 9489b18 with per-PM merge commits, was squashed onto 09956d9 and re-split by concern; the full pre-rebase history is preserved on branch backup/vex-lockfile-pre-rebase. These are the product bugs the per-PM real-toolchain matrices turned up while the manifest-less VEX suites were written. Each is independent of VEX and pinned by a core regression test: - composer (hosted): the rewriter drops the entry's `source` block when it immediately precedes `dist` (one `redirect_composer_dist` fragment edit spanning both, reverted byte-for-byte). Composer 1 and 2.2 LTS silently installed the pristine upstream commit from git whenever the hosted download failed; a hand-ordered source that cannot be dropped warns `redirect_composer_source_kept`. Golden fixture: `source-and-dist`. - gem (hosted): the patch-registry `GEM` section is inserted in bundler's source order (sorted by remote), so a frozen `bundle install` on bundler >= 4.0.19 accepts the converged lock. The `basic` golden and the exact lock expectations move the Socket section first. - cargo (hosted + vendored): a v1 `Cargo.lock` (checksums in `[metadata]`, dependents naming the crate by its full package id) is redirected and vendored correctly: the `[metadata]` line and every dependent's full-id reference follow the source, each fragment its own ledger edit, and `cargo --locked` accepts the result; the vendored detach/restore of a v1 entry is byte-identical. `plan_cargo_lock` keeps #247's multi-source twin disambiguation (`Ambiguous`) and hoisted regexes; the block end now also stops at a trailing `[metadata]` / `[[patch.unused]]`, and the checksum is inserted after a block-final `source` line too. - yarn berry (hosted + vendored): written checksums follow the lock's own spelling — yarn 4.0.0–4.0.2 spell `10c0` checksums as bare hex, so the prefixed form failed `yarn install --immutable` with YN0028. - npm (vendored): npm 12 reifies from the `package-lock.json` it creates beside a committed shrinkwrap, so `vendor` now rewires every present npm lock (siblings first, primary last; an unrewirable sibling warns `vendor_npm_sibling_lock_unwired`), and the in-use/revert probes read every npm lock before deleting an artifact. `select_lockfile` reads through #247's guarded `read_regular_to_bytes`. - npm (hosted): a lockfileVersion 1 redirect warns `redirect_npm_legacy_client` — npm 6 ignores a v1 lock's `resolved` and fails EINTEGRITY against the patched pin. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/patch/redirect/mod.rs | 729 ++++++++++++++++-- .../src/vendor/cargo_lock.rs | 138 +++- .../src/vendor/npm_flavor.rs | 24 +- .../socket-patch-core/src/vendor/npm_lock.rs | 490 +++++++++--- .../src/vendor/yarn_berry_lock.rs | 97 ++- .../source-and-dist/expected-edits.json | 10 + .../source-and-dist/expected-warnings.json | 1 + .../source-and-dist/expected/composer.lock | 34 + .../source-and-dist/input/composer.lock | 39 + .../source-and-dist/overrides.json | 14 + .../gem/bundler/basic/expected/Gemfile.lock | 8 +- 11 files changed, 1416 insertions(+), 168 deletions(-) create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-and-dist/expected-edits.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-and-dist/expected-warnings.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-and-dist/expected/composer.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-and-dist/input/composer.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-and-dist/overrides.json diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index bce1ecdf..685d11d1 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -594,6 +594,25 @@ fn rewrite_one_npm_lock( } } if changed { + // npm <= 6 (the only writer of lockfileVersion 1) installs a registry + // dependency from the CONFIGURED registry and ignores the entry's + // `resolved` — verified against real npm 6.14.18, while npm 7 / 11 + // fetch the rewritten url from the same v1 lock. Under npm 6 the + // redirected lock therefore fails EINTEGRITY against the patched + // sha512 pin (fail-closed: the unpatched bytes never install). Say + // so instead of letting an npm 6 CI discover it. + if lock.get("lockfileVersion").and_then(Value::as_u64) == Some(1) { + result.warnings.push(RewriteWarning { + code: "redirect_npm_legacy_client".into(), + detail: format!( + "{lockfile} is lockfileVersion 1 (written by npm <= 6). npm <= 6 installs \ + registry dependencies from the configured registry and ignores the \ + redirected `resolved` url, so its installs fail EINTEGRITY against the \ + patched sha512 pin (the unpatched bytes are never installed); install \ + with npm >= 7, which fetches the hosted patch (and upgrades the lock)" + ), + }); + } result.files.insert(lockfile.into(), serialize_json(&lock)); } } @@ -826,13 +845,13 @@ fn rewrite_cargo( // resolution through the managed registry, which serves the patched // checksum. enum LockCommit { - Write(String, Box), + Write(String, Vec), InPlace, Absent, } let lock_commit = if let Some(lock_text) = cargo_lock.as_ref() { match plan_cargo_lock(lock_text, &dep.name, &dep.version, index_url, &cksum) { - CargoLockPlan::Rewritten { content, edit } => LockCommit::Write(content, edit), + CargoLockPlan::Rewritten { content, edits } => LockCommit::Write(content, edits), CargoLockPlan::AlreadyRedirected => LockCommit::InPlace, CargoLockPlan::NotFound => { result.warnings.push(RewriteWarning { @@ -880,9 +899,9 @@ fn rewrite_cargo( toml_changed = true; } match lock_commit { - LockCommit::Write(content, edit) => { + LockCommit::Write(content, edits) => { cargo_lock = Some(content); - result.edits.push(*edit); + result.edits.extend(edits); lock_changed = true; } LockCommit::InPlace | LockCommit::Absent => {} @@ -1496,15 +1515,34 @@ fn plan_cargo_toml( } static CARGO_LOCK_SOURCE_LINE_RE: LazyLock = LazyLock::new(|| { - Regex::new(r#"(?m)^source = "[^"]*"$"#).expect("static lock source-line regex is valid") + Regex::new(r#"(?m)^source = "([^"]*)"$"#).expect("static lock source-line regex is valid") }); static CARGO_LOCK_CHECKSUM_LINE_RE: LazyLock = LazyLock::new(|| { Regex::new(r#"(?m)^checksum = "[^"]*"$"#).expect("static lock checksum-line regex is valid") }); +// `$` (not `\n`) so it also anchors a source line that ENDS the block: the +// trailing newline sits outside the block region. static CARGO_LOCK_AFTER_SOURCE_RE: LazyLock = LazyLock::new(|| { - Regex::new(r#"(?m)^(source = "[^"]*"\n)"#).expect("static source-line anchor regex is valid") + Regex::new(r#"(?m)^(source = "[^"]*")$"#).expect("static source-line anchor regex is valid") }); +/// Repoint the crate's `[[package]]` at the hosted index with the patched +/// `.crate`'s checksum, in whichever Cargo.lock format the file is: +/// +/// * v2–v4: `source` + an inline `checksum` in the entry; +/// * v1 (cargo < 1.41, still read by every cargo): the entry carries only +/// `source`; the checksum lives in the trailing `[metadata]` table under +/// `"checksum ()"`, and every dependent names the +/// crate by its FULL package id `" ()"`. Both are +/// keyed by the source, so both must follow it — a v1 lock with only the +/// entry repointed names a package that no longer exists (cargo discards +/// the lock and re-resolves; `--locked` fails) and pins nothing. +/// +/// Full-id references are rewritten in any format (v2+ spells them that way +/// when a name + version is ambiguous). Each changed fragment is its own +/// `redirect_cargo_lock_entry` edit (unique text, so the fragment revert is +/// unambiguous): the entry, the `[metadata]` line, and each dependent's +/// whole `[[package]]` block. fn plan_cargo_lock( content: &str, crate_name: &str, @@ -1513,22 +1551,9 @@ fn plan_cargo_lock( cksum: &str, ) -> CargoLockPlan { // Rust's regex has NO lookahead, so bound the [[package]] block by string - // search: from its header to the next `\n[[package]]` (or EOF), so the - // trailing bytes after the block (incl. the final newline) are preserved. - // Trailing newline(s) are excluded from the block region so the recorded - // original/new strings stop after the last content byte (mirrors the TS - // rewriter's `(?=\n*$)` lookahead), while the file keeps its trailing - // newline (it stays outside the replaced region). - let block_end_after = |body_start: usize| -> usize { - let mut block_end = match content[body_start..].find("\n[[package]]") { - Some(rel) => body_start + rel, - None => content.len(), - }; - while block_end > body_start && content.as_bytes()[block_end - 1] == b'\n' { - block_end -= 1; - } - block_end - }; + // search (see [`lock_block_end`]): from its header to the next block or + // trailing table (or EOF), so the bytes after the block (incl. the final + // newline) are preserved. let head = format!("[[package]]\nname = \"{crate_name}\"\nversion = \"{version}\"\n"); // Every line-anchored header for this name@version. A Cargo.lock may // legitimately hold TWO blocks for one name@version from different @@ -1550,7 +1575,7 @@ fn plan_cargo_lock( let target_source = format!("source = \"{index_url}\""); let mut ours = twins.iter().copied().filter(|&at| { let body_start = at + head.len(); - content[body_start..block_end_after(body_start)] + content[body_start..lock_block_end(content, body_start)] .lines() .any(|line| line == target_source) }); @@ -1561,52 +1586,138 @@ fn plan_cargo_lock( } }; let body_start = block_start + head.len(); - let block_end = block_end_after(body_start); + let block_end = lock_block_end(content, body_start); let original = content[block_start..block_end].to_string(); let mut body = content[body_start..block_end].to_string(); - if CARGO_LOCK_SOURCE_LINE_RE.is_match(&body) { + let old_source = CARGO_LOCK_SOURCE_LINE_RE + .captures(&body) + .map(|c| c[1].to_string()); + if old_source.is_some() { body = CARGO_LOCK_SOURCE_LINE_RE .replace(&body, format!("source = \"{index_url}\"").as_str()) .to_string(); } else { body = format!("source = \"{index_url}\"\n{body}"); } - if CARGO_LOCK_CHECKSUM_LINE_RE.is_match(&body) { - body = CARGO_LOCK_CHECKSUM_LINE_RE - .replace(&body, format!("checksum = \"{cksum}\"").as_str()) - .to_string(); - } else { - body = CARGO_LOCK_AFTER_SOURCE_RE - .replace(&body, format!("${{1}}checksum = \"{cksum}\"\n").as_str()) - .to_string(); + // A v1 lock keeps the checksum in `[metadata]`, keyed by the package id + // — the chosen block's OWN source when it has one, so a multi-source + // twin's line is never taken for ours. + let metadata_source = old_source + .as_deref() + .map_or_else(|| r#"[^)"]*"#.to_string(), regex::escape); + let metadata_re = Regex::new(&format!( + r#"(?m)^"checksum {} {} \({metadata_source}\)" = "[^"]*"$"#, + regex::escape(crate_name), + regex::escape(version) + )) + .expect("escaped lock metadata-line regex is valid"); + let metadata_line = metadata_re.find(content).map(|m| m.as_str().to_string()); + if metadata_line.is_none() { + if CARGO_LOCK_CHECKSUM_LINE_RE.is_match(&body) { + body = CARGO_LOCK_CHECKSUM_LINE_RE + .replace(&body, format!("checksum = \"{cksum}\"").as_str()) + .to_string(); + } else { + body = CARGO_LOCK_AFTER_SOURCE_RE + .replace(&body, format!("${{1}}\nchecksum = \"{cksum}\"").as_str()) + .to_string(); + } } let rebuilt = format!("{head}{body}"); - // Already redirected (re-run): the block is at the target values; a + let key = format!("{crate_name}@{version}"); + let edit = |original: &str, new: &str| FileEdit { + path: "Cargo.lock".into(), + kind: "redirect_cargo_lock_entry".into(), + action: "rewritten".into(), + key: Some(key.clone()), + original: Some(Value::String(original.to_string())), + new: Some(Value::String(new.to_string())), + }; + let mut edits = Vec::new(); + let mut new_content = content.to_string(); + if rebuilt != original { + new_content.replace_range(block_start..block_end, &rebuilt); + edits.push(edit(&original, &rebuilt)); + } + if let Some(line) = metadata_line { + let pinned = format!("\"checksum {crate_name} {version} ({index_url})\" = \"{cksum}\""); + if line != pinned { + new_content = new_content.replacen(&line, &pinned, 1); + edits.push(edit(&line, &pinned)); + } + } + // Dependents' full-id references to the OLD source. + if let Some(old) = old_source.filter(|old| old != index_url) { + let from = format!("\"{crate_name} {version} ({old})\""); + let to = format!("\"{crate_name} {version} ({index_url})\""); + let mut cursor = 0; + while let Some((start, end)) = next_lock_block(&new_content, cursor) { + let block = new_content[start..end].to_string(); + if block.contains(&from) { + let repointed = block.replace(&from, &to); + new_content.replace_range(start..end, &repointed); + edits.push(edit(&block, &repointed)); + cursor = start + repointed.len(); + } else { + cursor = end; + } + } + } + // Already redirected (re-run): every fragment is at the target values; a // recorded edit would have original == new and grow the ledger forever. - if rebuilt == original { + if edits.is_empty() { return CargoLockPlan::AlreadyRedirected; } - let new_content = content.replacen(&original, &rebuilt, 1); CargoLockPlan::Rewritten { content: new_content, - edit: Box::new(FileEdit { - path: "Cargo.lock".into(), - kind: "redirect_cargo_lock_entry".into(), - action: "rewritten".into(), - key: Some(format!("{crate_name}@{version}")), - original: Some(Value::String(original)), - new: Some(Value::String(rebuilt)), - }), + edits, } } +/// The next `[[package]]` block starting at or after `from`, as +/// [`lock_block_end`] bounds it. +fn next_lock_block(content: &str, from: usize) -> Option<(usize, usize)> { + let rel = content.get(from..)?.find("[[package]]\n")?; + let start = from + rel; + if start != 0 && content.as_bytes()[start - 1] != b'\n' { + return next_lock_block(content, start + 1); + } + Some(( + start, + lock_block_end(content, start + "[[package]]\n".len()), + )) +} + +/// End of the `[[package]]` block whose body starts at `body_start`, +/// excluding the newline(s) before the next block / trailing table / EOF (so +/// a recorded original/new stops after the block's last content byte — the +/// TS rewriter's `(?=\n*$)` lookahead — while the file keeps its newlines). +fn lock_block_end(content: &str, body_start: usize) -> usize { + // The next block, or the `[metadata]` / `[[patch.unused]]` tables that + // trail the packages. + let mut end = [ + "\n[[package]]", + "\n[metadata]", + "\n[[patch.unused]]", + "\n[patch", + ] + .iter() + .filter_map(|marker| content[body_start..].find(marker)) + .min() + .map_or(content.len(), |rel| body_start + rel); + while end > body_start && content.as_bytes()[end - 1] == b'\n' { + end -= 1; + } + end +} + /// Outcome of the Cargo.lock `[[package]]` plan — distinguishes a re-run /// over an already-redirected block (no edit, no warning) from a genuinely /// missing package (the caller warns AND skips the dep entirely). enum CargoLockPlan { Rewritten { content: String, - edit: Box, + edits: Vec, }, AlreadyRedirected, NotFound, @@ -2251,7 +2362,14 @@ fn rewrite_yarn_berry( let mut changed = false; for dep in &npm { let fname = full_name(dep); - let Some(checksum) = dep.integrity.yarn_berry10c0.clone() else { + // The API hands the prefixed `10c0/`; a yarn 4.0.x lock spells + // its checksums bare, and `--immutable` rejects a respelled one. + let Some(checksum) = dep + .integrity + .yarn_berry10c0 + .as_deref() + .map(|c| crate::vendor::yarn_berry_lock::checksum_in_lock_spelling(content, c)) + else { result.warnings.push(RewriteWarning { code: "redirect_yarn_berry_missing_checksum".into(), detail: format!( @@ -3114,6 +3232,22 @@ static COMPOSER_DIST_SHASUM_RE: LazyLock = LazyLock::new(|| { Regex::new(r#"("shasum": ")[^"]*(")"#).expect("static dist shasum regex is valid") }); +/// Byte offset of the entry's `"source": {` key when that object is the +/// dist block's IMMEDIATE predecessor (only `,` + whitespace between them) — +/// the layout composer itself always writes (`source` then `dist`). +/// `None` when the entry has no source object there. +fn composer_source_before_dist( + content: &str, + entry_start: usize, + dist_start: usize, +) -> Option { + const SOURCE_KEY: &str = "\"source\": {"; + let source_start = entry_start + content[entry_start..dist_start].rfind(SOURCE_KEY)?; + let source_end = json_object_end_from(content, source_start + SOURCE_KEY.len())?; + (source_end < dist_start && content[source_end + 1..dist_start].trim() == ",") + .then_some(source_start) +} + fn rewrite_composer_lock( files: &BTreeMap, overrides: &[DepOverride], @@ -3226,10 +3360,38 @@ fn rewrite_composer_lock( } else { append_composer_shasum(&rewritten, &sha1) }; - if rewritten != block { + // Drop the entry's `source` (the vendored backend does the same): + // when the dist download fails — checksum mismatch, an expired grant + // token, a patch-server outage — composer 1 and composer 2 before its + // source-fallback cutoff (2.2 LTS included) print "Now trying to + // download from source" and silently install the PRISTINE upstream + // commit from git, and `--prefer-source` / `preferred-install: + // source` always does. With the source gone the hosted archive is + // the only way to install the package, so a failed fetch fails the + // install instead of shipping the vulnerable code. The edit then + // spans `"source": {…},\n"dist": {…}`, so the ledger's + // fragment revert puts both blocks back byte-for-byte. + let (edit_start, original) = + match composer_source_before_dist(&content, entry_start, dist_start) { + Some(source_start) => (source_start, content[source_start..=dist_end].to_string()), + None => { + if content[entry_start..=entry_end].contains("\"source\": {") { + result.warnings.push(RewriteWarning { + code: "redirect_composer_source_kept".into(), + detail: format!( + "{composer_name}'s source block does not directly precede its \ + dist and was left in place; a failed hosted download may fall \ + back to it" + ), + }); + } + (dist_start, block.clone()) + } + }; + if rewritten != original { content = format!( "{}{}{}", - &content[..dist_start], + &content[..edit_start], rewritten, &content[dist_end + 1..] ); @@ -3239,7 +3401,7 @@ fn rewrite_composer_lock( kind: "redirect_composer_dist".into(), action: "rewritten".into(), key: Some(composer_name), - original: Some(Value::String(block)), + original: Some(Value::String(original)), new: Some(Value::String(rewritten)), }); } @@ -3763,10 +3925,11 @@ fn gem_lock_dependency_name(entry: &str) -> &str { entry.trim_end_matches('!') } -/// One parsed `GEM` section of a Gemfile.lock: its `remote:` lines (index + -/// URL) and the exclusive end index — the start of the next column-0 header -/// (trailing blank separator included) or EOF. +/// One parsed `GEM` section of a Gemfile.lock: its header line index, its +/// `remote:` lines (index + URL) and the exclusive end index — the start of +/// the next column-0 header (trailing blank separator included) or EOF. struct GemLockSection { + start: usize, remotes: Vec<(usize, String)>, end: usize, } @@ -3834,7 +3997,11 @@ fn converge_gem_lock_source( j += 1; } if header_is_gem { - sections.push(GemLockSection { remotes, end: j }); + sections.push(GemLockSection { + start, + remotes, + end: j, + }); } else if c == "DEPENDENCIES" { deps_range = Some((start + 1, j)); } @@ -3933,7 +4100,19 @@ fn converge_gem_lock_source( } } else { // Move the spec (+ sublines) into a patch-registry section of its - // own, inserted where the section it leaves ends. + // own, inserted where bundler itself writes it: bundler emits the + // rubygems `GEM` sections sorted by source identifier + // (`SourceList#lock_rubygems_sources`: `sort_by(&:identifier)`, i.e. + // by the section's remote URLs), so the new section goes before the + // first `GEM` section whose remotes sort after the index URL, else + // after the last one. A frozen install re-renders the lock, and + // since bundler 4.0.19 (rubygems#9750, "fail instead of warning when + // frozen mode can't update the lockfile") any difference is fatal: + // "Your lockfile needs to be updated, but it can't be because frozen + // mode is set". Appending after `https://rubygems.org/` when the + // patch registry (`https://patch.socket.dev/…`) sorts first broke + // every converged hosted pair under `BUNDLE_FROZEN` / deployment + // mode (verified: 4.0.15 installs it, 4.0.21 refuses it). let mut last = spec_idx; while last + 1 < lines.len() && gem_lock_line_content(&lines[last + 1]).starts_with(" ") @@ -3941,7 +4120,29 @@ fn converge_gem_lock_source( last += 1; } let moved: Vec = lines.drain(spec_idx..=last).collect(); - let insert_at = sections[sec_idx].end - moved.len(); + let n = moved.len(); + // Section bounds after the drain (every drained line sat inside + // section `sec_idx`, which keeps its start). + let bounds = |k: usize| -> (usize, usize) { + let s = §ions[k]; + match k.cmp(&sec_idx) { + std::cmp::Ordering::Less => (s.start, s.end), + std::cmp::Ordering::Equal => (s.start, s.end - n), + std::cmp::Ordering::Greater => (s.start - n, s.end - n), + } + }; + let identifier = |k: usize| -> String { + sections[k] + .remotes + .iter() + .map(|(_, url)| url.as_str()) + .collect::>() + .join(", ") + }; + let insert_at = (0..sections.len()) + .find(|&k| identifier(k).as_str() > index_url) + .map(|k| bounds(k).0) + .unwrap_or_else(|| bounds(sections.len() - 1).1); let mut block: Vec = Vec::with_capacity(moved.len() + 4); block.push(format!("GEM{eol}")); block.push(format!(" remote: {index_url}{eol}")); @@ -6254,6 +6455,37 @@ mod tests { ) } + /// REGRESSION (yarn 4.0.x): a lock that spells its `10c0` checksums + /// bare (yarn 4.0.0–4.0.2) gets the hosted entry's checksum spelled bare + /// — the API's prefixed `yarnBerry10c0` made `yarn install --immutable` + /// reject the rewritten lock (YN0028). A 4.1+ (prefixed) lock keeps it. + #[test] + fn yarn_berry_checksum_follows_the_lock_spelling() { + let hex = "7".repeat(128); + let ovr = berry_override( + "left-pad", + "1.3.0", + "http://p.test/lp.tgz", + &format!("10c0/{hex}"), + ); + for (lock, want) in [ + ( + berry_lock("10c0").replace("checksum: 10c0/", "checksum: "), + format!("\n checksum: {hex}\n"), + ), + (berry_lock("10c0"), format!("\n checksum: 10c0/{hex}\n")), + ] { + let mut files = BTreeMap::new(); + files.insert("yarn.lock".to_string(), lock.clone()); + let mut r = RewriteResult::default(); + rewrite_yarn_berry(&files, std::slice::from_ref(&ovr), &mut r); + let out = &r.files["yarn.lock"]; + assert!(out.contains("::__archiveUrl="), "{out}"); + assert!(out.contains(&want), "want {want:?} in:\n{out}"); + assert_eq!(out.matches("checksum:").count(), 1, "{out}"); + } + } + #[test] fn yarn_berry_warning_branches() { let checksum = format!("10c0/{}", "7".repeat(128)); @@ -8931,8 +9163,8 @@ mod tests { ); let r = rewrite_registry_redirect(&files, &[gem_override("rails", "7.0.0")]); let expected = format!( - "GEM\n remote: https://rubygems.org/\n specs:\n\n\ - GEM\n remote: https://patch.test/gem/tok/uuid/\n specs:\n rails (7.0.0)\n\n\ + "GEM\n remote: https://patch.test/gem/tok/uuid/\n specs:\n rails (7.0.0)\n\n\ + GEM\n remote: https://rubygems.org/\n specs:\n\n\ PLATFORMS\n ruby\n\nDEPENDENCIES\n rails (= 7.0.0)!\n\n\ CHECKSUMS\n rails (7.0.0) sha256={}\n\nBUNDLED WITH\n 2.6.2\n", "f".repeat(64) @@ -8976,6 +9208,96 @@ mod tests { ); } + /// REGRESSION (bundler 4.0.19+): the patch-registry `GEM` section must + /// land where bundler itself renders it — rubygems sections sorted by + /// remote (`SourceList#lock_rubygems_sources`) — because a frozen install + /// re-renders the lock and, since rubygems#9750, FAILS on any difference. + /// Appending after the upstream section produced a lock bundler 4.0.21 + /// refuses under `BUNDLE_FROZEN=true` whenever the patch registry sorts + /// first (`https://patch.socket.dev/` < `https://rubygems.org/`), i.e. on + /// every production pair. Pinned both ways, with a third section present. + #[test] + fn gem_converged_section_is_inserted_in_bundler_source_order() { + for (upstream, other, want) in [ + // Patch registry sorts before both: first. + ( + "https://rubygems.org/", + "https://zz.example/", + ["patch", "up", "other"], + ), + // Between the two. + ( + "https://rubygems.org/", + "https://aa.example/", + ["other", "patch", "up"], + ), + // After both: appended after the last GEM section. + ( + "https://aa.example/", + "https://ab.example/", + ["up", "other", "patch"], + ), + ] { + let (first, second) = if upstream < other { + (upstream, other) + } else { + (other, upstream) + }; + let section = |url: &str| { + if url == upstream { + format!("GEM\n remote: {url}\n specs:\n rails (7.0.0)\n\n") + } else { + format!("GEM\n remote: {url}\n specs:\n puma (6.0.0)\n\n") + } + }; + let lock = format!( + "{}{}PLATFORMS\n ruby\n\nDEPENDENCIES\n puma\n rails (= 7.0.0)\n\n\ + CHECKSUMS\n puma (6.0.0) sha256={}\n rails (7.0.0) sha256={}\n\n\ + BUNDLED WITH\n 4.0.21\n", + section(first), + section(second), + "1".repeat(64), + "2".repeat(64) + ); + let mut files = BTreeMap::new(); + files.insert( + "Gemfile".to_string(), + format!( + "source \"{upstream}\"\n\ngem \"rails\", \"7.0.0\"\n\ + source \"{other}\" do\n gem \"puma\"\nend\n" + ), + ); + files.insert("Gemfile.lock".to_string(), lock); + let r = rewrite_registry_redirect(&files, &[gem_override("rails", "7.0.0")]); + let out = r.files.get("Gemfile.lock").expect("lock rewritten"); + let remotes: Vec<&str> = out + .lines() + .filter_map(|l| l.strip_prefix(" remote: ")) + .map(|url| match url { + u if u == upstream => "up", + u if u == other => "other", + u if u.starts_with("https://patch.test/") => "patch", + u => panic!("unexpected remote {u}"), + }) + .collect(); + assert_eq!(remotes, want, "{upstream} / {other}:\n{out}"); + let urls: Vec<&str> = out + .lines() + .filter_map(|l| l.strip_prefix(" remote: ")) + .collect(); + let mut sorted = urls.clone(); + sorted.sort_unstable(); + assert_eq!( + urls, sorted, + "bundler's sort_by(&:identifier) order:\n{out}" + ); + assert!( + out.contains("GEM\n remote: https://patch.test/gem/tok/uuid/\n specs:\n rails (7.0.0)\n\n"), + "{out}" + ); + } + } + /// Feeding the converged pair back must be a true no-op (the ledger would /// otherwise grow forever) — and the converged lock shape must be /// RECOGNIZED, not re-converged into a duplicate section. @@ -9093,8 +9415,8 @@ mod tests { ); let r = rewrite_registry_redirect(&files, &[gem_override("rails", "7.0.0")]); let expected = format!( - "GEM\n remote: https://rubygems.org/\n specs:\n rack (3.0.0)\n\n\ - GEM\n remote: https://patch.test/gem/tok/uuid/\n specs:\n rails (7.0.0)\n rack (>= 2)\n\n\ + "GEM\n remote: https://patch.test/gem/tok/uuid/\n specs:\n rails (7.0.0)\n rack (>= 2)\n\n\ + GEM\n remote: https://rubygems.org/\n specs:\n rack (3.0.0)\n\n\ PLATFORMS\n ruby\n\nDEPENDENCIES\n rack (= 3.0.0)\n rails (= 7.0.0)!\n\n\ CHECKSUMS\n rack (3.0.0) sha256={}\n rails (7.0.0) sha256={}\n\nBUNDLED WITH\n 2.6.2\n", "4".repeat(64), @@ -9538,8 +9860,8 @@ mod tests { r.warnings ); let expected = format!( - "GEM\n remote: https://rubygems.org/\n specs:\n\n\ - GEM\n remote: https://patch.test/gem/tok/uuid/\n specs:\n rails (7.0.0)\n\n\ + "GEM\n remote: https://patch.test/gem/tok/uuid/\n specs:\n rails (7.0.0)\n\n\ + GEM\n remote: https://rubygems.org/\n specs:\n\n\ PLATFORMS\n ruby\n\nDEPENDENCIES\n rails (= 7.0.0)!\n\n\ CHECKSUMS\n rails (7.0.0) sha256={}\n\nBUNDLED WITH\n 2.6.2\n", "f".repeat(64) @@ -9606,6 +9928,64 @@ mod tests { ); } + /// REGRESSION (npm 6): a lockfileVersion 1 lock is only ever written by + /// npm <= 6, which ignores `resolved` for registry deps (verified against + /// real npm 6.14.18) — so its installs of the redirected lock fail + /// EINTEGRITY. The rewrite still happens (npm >= 7 installs it), but the + /// run must say so; a v2/v3 lock (npm >= 7) gets no such caveat. + #[test] + fn npm_v1_lock_redirect_warns_about_npm_6_clients() { + let ovr = npm_override( + "left-pad", + "1.3.0", + "http://patch.test/left-pad-1.3.0.tgz", + "sha512-PATCHED==", + ); + let v1 = r#"{ + "name": "app", + "version": "0.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "left-pad": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "integrity": "sha512-UPSTREAM==" + } + } +} +"#; + let mut files = BTreeMap::new(); + files.insert("package-lock.json".to_string(), v1.to_string()); + let r = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); + let out = r.files.get("package-lock.json").expect("v1 lock rewritten"); + assert!( + out.contains("http://patch.test/left-pad-1.3.0.tgz"), + "{out}" + ); + let w = r + .warnings + .iter() + .find(|w| w.code == "redirect_npm_legacy_client") + .unwrap_or_else(|| panic!("missing legacy-client caveat: {:?}", r.warnings)); + assert!( + w.detail.contains("npm <= 6") && w.detail.contains("EINTEGRITY"), + "{}", + w.detail + ); + + let v3 = v1.replace("\"lockfileVersion\": 1", "\"lockfileVersion\": 3"); + let mut files = BTreeMap::new(); + files.insert("package-lock.json".to_string(), v3); + let r = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); + assert!(r.files.contains_key("package-lock.json")); + assert!( + !warning_codes(&r).contains(&"redirect_npm_legacy_client"), + "{:?}", + r.warnings + ); + } + /// npm 12 removed `npm shrinkwrap` and now auto-creates a /// `package-lock.json` beside any committed `npm-shrinkwrap.json` on first /// install — and reifies the install from `package-lock.json`. So a @@ -10346,6 +10726,123 @@ snapshots: ); } + /// composer writes `source` right before `dist`, and composer 1 / 2.2 + /// LTS fall back to it ("Now trying to download from source") whenever + /// the hosted dist fails its checksum or cannot be fetched — silently + /// installing the pristine upstream commit. The redirect must drop the + /// target's source (only the target's), record ONE fragment edit + /// spanning both blocks, and that fragment's inverse must restore the + /// original lock byte-for-byte. A re-run over the output is a no-op. + #[test] + fn composer_redirect_drops_the_target_source_fallback_and_reverts_it() { + let target_source = " + \"source\": { + \"type\": \"git\", + \"url\": \"https://github.com/acme/target.git\", + \"reference\": \"cafe\" + },"; + let lock = composer_lock_with(&format!( + "{target_source} + \"dist\": {{ + \"type\": \"zip\", + \"url\": \"https://api.github.com/repos/acme/target/zipball/cafe\", + \"reference\": \"cafe\", + \"shasum\": \"\" + }}" + )) + .replace( + "\"version\": \"2.0.0\",\n \"dist\": {", + "\"version\": \"2.0.0\",\n \"source\": {\n \"type\": \"git\",\n \"url\": \"https://github.com/innocent/bystander.git\",\n \"reference\": \"beef\"\n },\n \"dist\": {", + ); + let r = composer_result(&lock, "1.0.0"); + assert!(r.warnings.is_empty(), "no warnings: {:?}", r.warnings); + let out = r + .files + .get("composer.lock") + .expect("the dist is redirected"); + let doc: Value = serde_json::from_str(out).expect("valid JSON"); + let target = &doc["packages"][0]; + assert_eq!(target["name"], "acme/target"); + assert!( + target.get("source").is_none(), + "the target's git source must be dropped so a failed hosted download cannot \ + fall back to the pristine upstream: {target}" + ); + assert_eq!(target["dist"]["url"], COMPOSER_ARTIFACT_URL); + assert_eq!(target["dist"]["shasum"], COMPOSER_SHA1); + assert_eq!( + doc["packages"][1]["source"]["url"], "https://github.com/innocent/bystander.git", + "a bystander's source is untouched" + ); + + // One edit whose fragments invert the whole change (the ledger's + // ReplaceFragment revert: `new` → `original`). + assert_eq!(r.edits.len(), 1, "{:?}", r.edits); + let edit = &r.edits[0]; + assert_eq!(edit.kind, "redirect_composer_dist"); + let original = edit.original.as_ref().and_then(Value::as_str).unwrap(); + let new = edit.new.as_ref().and_then(Value::as_str).unwrap(); + assert!(original.starts_with("\"source\": {") && original.contains("acme/target.git")); + assert!(new.starts_with("\"dist\": {") && !new.contains("\"source\"")); + assert_eq!(out.matches(new).count(), 1, "the fragment is unambiguous"); + assert_eq!( + out.replacen(new, original, 1), + lock, + "revert restores the lock" + ); + + // Re-run over the redirected lock: nothing left to change. + let mut again = BTreeMap::new(); + again.insert("composer.lock".to_string(), out.clone()); + let second = rewrite_registry_redirect(&again, &[composer_override("1.0.0")]); + assert!( + second.files.is_empty() && second.edits.is_empty() && second.warnings.is_empty(), + "re-run must be a no-op: {:?} {:?}", + second.edits, + second.warnings + ); + } + + /// A hand-ordered entry whose `source` does NOT directly precede its + /// `dist` keeps the source (the fragment edit cannot span it losslessly) + /// and says so; the dist is still redirected and pinned. + #[test] + fn composer_non_adjacent_source_is_kept_with_a_warning() { + let lock = composer_lock_with( + " + \"dist\": { + \"type\": \"zip\", + \"url\": \"https://api.github.com/repos/acme/target/zipball/cafe\", + \"reference\": \"cafe\", + \"shasum\": \"\" + }, + \"source\": { + \"type\": \"git\", + \"url\": \"https://github.com/acme/target.git\", + \"reference\": \"cafe\" + }", + ); + let r = composer_result(&lock, "1.0.0"); + assert_eq!(warning_codes(&r), vec!["redirect_composer_source_kept"]); + let out = r + .files + .get("composer.lock") + .expect("the dist is redirected"); + let doc: Value = serde_json::from_str(out).expect("valid JSON"); + assert_eq!(doc["packages"][0]["dist"]["url"], COMPOSER_ARTIFACT_URL); + assert!(doc["packages"][0].get("source").is_some()); + let edit = &r.edits[0]; + let (original, new) = ( + edit.original.as_ref().and_then(Value::as_str).unwrap(), + edit.new.as_ref().and_then(Value::as_str).unwrap(), + ); + assert_eq!( + out.replacen(new, original, 1), + lock, + "revert restores the lock" + ); + } + /// pnpm lockfileVersion 6 embeds resolved peers in the `packages:` key /// itself, so one name@version can appear as BOTH `/pkg@1.0.0:` and /// `/pkg@1.0.0(peer@2.0.0):`. Rewriting only the plain entry would be @@ -12419,6 +12916,116 @@ packages: /// git-sourced entry) or missing BOTH `source` and `checksum` are rebuilt /// with the lines inserted in canonical order, and the neighbor blocks /// stay byte-identical. + /// Cargo.lock v1 (cargo < 1.41; every cargo still reads it and, under + /// `--locked`, never rewrites it): the checksum lives in `[metadata]` + /// keyed by the source, and dependents reference the crate by its full + /// `"name version (source)"` id. REGRESSION: only the entry's `source` + /// was repointed — the dependent's reference then named a package no + /// longer in the lock (real cargo discards the lock and re-resolves; + /// `cargo fetch --locked` fails) and nothing pinned the patched + /// `.crate` (the v1 entry has no inline checksum, and the insert after a + /// block-final `source` line never matched). Every fragment now follows + /// the source, each as its own revertible edit. + #[test] + fn cargo_lock_v1_repoints_metadata_checksum_and_full_id_references() { + const CRATES_IO: &str = "registry+https://github.com/rust-lang/crates.io-index"; + let manifest = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n\ + [dependencies]\nserde = \"1.0.190\"\nlog = \"0.4\"\n"; + let cksum = "e".repeat(64); + let lock = format!( + "[[package]]\nname = \"app\"\nversion = \"0.1.0\"\ndependencies = [\n \ + \"log 0.4.20 ({CRATES_IO})\",\n \"serde 1.0.190 ({CRATES_IO})\",\n]\n\n\ + [[package]]\nname = \"log\"\nversion = \"0.4.20\"\nsource = \"{CRATES_IO}\"\n\ + dependencies = [\n \"serde 1.0.190 ({CRATES_IO})\",\n]\n\n\ + [[package]]\nname = \"serde\"\nversion = \"1.0.190\"\nsource = \"{CRATES_IO}\"\n\n\ + [metadata]\n\"checksum log 0.4.20 ({CRATES_IO})\" = \"{a}\"\n\ + \"checksum serde 1.0.190 ({CRATES_IO})\" = \"{b}\"\n", + a = "a".repeat(64), + b = "b".repeat(64), + ); + let mut files = BTreeMap::new(); + files.insert("Cargo.toml".to_string(), manifest.to_string()); + files.insert("Cargo.lock".to_string(), lock.clone()); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + let out = r.files.get("Cargo.lock").expect("lock rewritten"); + let idx = cargo_index_url(); + let want = format!( + "[[package]]\nname = \"app\"\nversion = \"0.1.0\"\ndependencies = [\n \ + \"log 0.4.20 ({CRATES_IO})\",\n \"serde 1.0.190 ({idx})\",\n]\n\n\ + [[package]]\nname = \"log\"\nversion = \"0.4.20\"\nsource = \"{CRATES_IO}\"\n\ + dependencies = [\n \"serde 1.0.190 ({idx})\",\n]\n\n\ + [[package]]\nname = \"serde\"\nversion = \"1.0.190\"\nsource = \"{idx}\"\n\n\ + [metadata]\n\"checksum log 0.4.20 ({CRATES_IO})\" = \"{a}\"\n\ + \"checksum serde 1.0.190 ({idx})\" = \"{cksum}\"\n", + a = "a".repeat(64), + ); + assert_eq!(out, &want, "v1 lock stays v1, fully repointed"); + assert!(r.confirmed_cargo_uuids.contains(CARGO_UUID)); + + // Four fragment edits (entry, metadata line, two dependents), each + // unique in the rewritten file, and reverting them newest-first (the + // replay order) restores the original byte-for-byte. + let edits: Vec<&FileEdit> = r + .edits + .iter() + .filter(|e| e.kind == "redirect_cargo_lock_entry") + .collect(); + assert_eq!(edits.len(), 4, "{edits:#?}"); + let mut reverted = out.clone(); + for e in edits.iter().rev() { + assert_eq!(e.key.as_deref(), Some("serde@1.0.190")); + let new = e.new.as_ref().and_then(Value::as_str).unwrap(); + let orig = e.original.as_ref().and_then(Value::as_str).unwrap(); + assert_eq!(reverted.matches(new).count(), 1, "unique fragment: {new}"); + reverted = reverted.replacen(new, orig, 1); + } + assert_eq!(reverted, lock); + + // Re-run over the redirected lock: nothing to do, no new edits. + files.insert("Cargo.lock".to_string(), out.clone()); + files.insert( + "Cargo.toml".to_string(), + r.files.get("Cargo.toml").expect("manifest pinned").clone(), + ); + files.insert( + ".cargo/config.toml".to_string(), + r.files.get(".cargo/config.toml").expect("config").clone(), + ); + let again = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + assert!( + !again + .edits + .iter() + .any(|e| e.kind == "redirect_cargo_lock_entry"), + "{:?}", + again.edits + ); + } + + /// A checksum-less entry whose `source` line ends the block (the + /// trailing newline sits outside the block region) still gets its pin. + #[test] + fn cargo_lock_checksum_is_inserted_after_a_block_final_source_line() { + let manifest = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n\ + [dependencies]\nserde = \"1.0.190\"\n"; + let cksum = "e".repeat(64); + let lock = "version = 3\n\n[[package]]\nname = \"serde\"\nversion = \"1.0.190\"\n\ + source = \"registry+https://github.com/rust-lang/crates.io-index\"\n"; + let mut files = BTreeMap::new(); + files.insert("Cargo.toml".to_string(), manifest.to_string()); + files.insert("Cargo.lock".to_string(), lock.to_string()); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + let out = r.files.get("Cargo.lock").expect("lock rewritten"); + assert_eq!( + out, + &format!( + "version = 3\n\n[[package]]\nname = \"serde\"\nversion = \"1.0.190\"\n\ + source = \"{}\"\nchecksum = \"{cksum}\"\n", + cargo_index_url() + ) + ); + } + #[test] fn cargo_lock_blocks_without_source_or_checksum_lines_are_rebuilt() { let manifest = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n\ diff --git a/crates/socket-patch-core/src/vendor/cargo_lock.rs b/crates/socket-patch-core/src/vendor/cargo_lock.rs index f54e43c7..4c61d5df 100644 --- a/crates/socket-patch-core/src/vendor/cargo_lock.rs +++ b/crates/socket-patch-core/src/vendor/cargo_lock.rs @@ -23,6 +23,17 @@ //! `version = 4` line keep their exact bytes — zero formatting churn in the //! committed diff. //! +//! Lock format v1 (cargo < 1.41, still read by every cargo and never +//! rewritten under `--locked`) spells the pair differently: the entry holds +//! only `source`, the checksum sits in the trailing `[metadata]` table as +//! `"checksum ()"`, and dependents reference the +//! crate by that full `" ()"` id. Detaching there +//! also drops the `[metadata]` key and rewrites the references to the +//! sourceless `" "` form cargo v1 uses for path packages — +//! otherwise they name a package the lock no longer has and cargo refuses +//! the lock under `--locked` (real cargo 1.93: "cannot update the lock +//! file … because --locked was passed"). Restore reverses all three. +//! //! The removed `source`/`checksum` pair is not recoverable offline (the //! checksum is the sha256 of the registry `.crate` tarball, not of the //! extracted tree), so [`detach_lock_entry`] returns it as the vendor ledger's @@ -99,6 +110,39 @@ fn find_package_mut<'a>( }) } +/// The `[metadata]` key a v1 lock files `name`+`version`'s checksum under. +fn metadata_checksum_key(name: &str, version: &str, source: &str) -> String { + format!("checksum {name} {version} ({source})") +} + +/// A v1 lock: no top-level `version` key and a `[metadata]` table (kept, +/// even emptied, by [`detach_lock_entry`] — so a detached v1 lock still +/// reads as v1 on restore). +fn is_v1_lock(doc: &DocumentMut) -> bool { + doc.get("version").is_none() && doc.get("metadata").is_some_and(Item::is_table_like) +} + +/// Rewrite every `dependencies` entry spelled exactly `from` to `to`, +/// keeping each entry's formatting. +fn rewrite_dependency_refs(doc: &mut DocumentMut, from: &str, to: &str) { + let Some(pkgs) = doc + .get_mut("package") + .and_then(Item::as_array_of_tables_mut) + else { + return; + }; + for pkg in pkgs.iter_mut() { + let Some(deps) = pkg.get_mut("dependencies").and_then(Item::as_array_mut) else { + continue; + }; + for i in 0..deps.len() { + if deps.get(i).and_then(toml_edit::Value::as_str) == Some(from) { + deps.replace(i, to); + } + } + } +} + /// Commit the edited lock atomically (stage + fsync + rename). The lock is a /// committed file shared with cargo itself; a torn write would corrupt the /// whole project's resolution, so never truncate-in-place. Mode-preserving: @@ -132,7 +176,7 @@ pub async fn detach_lock_entry( Some(s) => s.to_string(), None => return Err(LockEditError::NotRegistry), }; - let checksum = table + let mut checksum = table .get("checksum") .and_then(Item::as_str) .map(str::to_string); @@ -140,6 +184,20 @@ pub async fn detach_lock_entry( table.remove("source"); table.remove("checksum"); + // v1: the checksum lives in `[metadata]`, and dependents name the crate + // by its full id (any format may spell an ambiguous ref that way). + let key = metadata_checksum_key(name, version, &source); + if let Some(meta) = doc.get_mut("metadata").and_then(Item::as_table_like_mut) { + if let Some(sum) = meta.remove(&key) { + checksum = checksum.or_else(|| sum.as_str().map(str::to_string)); + } + } + rewrite_dependency_refs( + &mut doc, + &format!("{name} {version} ({source})"), + &format!("{name} {version}"), + ); + if !dry_run { write_lock(&path, &doc).await?; } @@ -159,6 +217,7 @@ pub async fn restore_lock_entry( dry_run: bool, ) -> Result { let (path, mut doc) = read_lock(project_root).await?; + let v1 = is_v1_lock(&doc); let Some(table) = find_package_mut(&mut doc, name, version) else { return Ok(false); }; @@ -167,7 +226,7 @@ pub async fn restore_lock_entry( } table.insert("source", toml_edit::value(original.source.as_str())); - if let Some(checksum) = &original.checksum { + if let (Some(checksum), false) = (&original.checksum, v1) { table.insert("checksum", toml_edit::value(checksum.as_str())); } // `insert` appends, but cargo's canonical key order is @@ -183,6 +242,26 @@ pub async fn restore_lock_entry( }; table.sort_values_by(|k1, _, k2, _| rank(k1.get()).cmp(&rank(k2.get()))); + if v1 { + // Back into `[metadata]` (cargo writes its keys sorted) and the + // dependents' references back to the full id. + if let (Some(checksum), Some(meta)) = ( + &original.checksum, + doc.get_mut("metadata").and_then(Item::as_table_mut), + ) { + meta.insert( + &metadata_checksum_key(name, version, &original.source), + toml_edit::value(checksum.as_str()), + ); + meta.sort_values(); + } + rewrite_dependency_refs( + &mut doc, + &format!("{name} {version}"), + &format!("{name} {version} ({})", original.source), + ); + } + if !dry_run { write_lock(&path, &doc).await?; } @@ -300,6 +379,61 @@ mod tests { dir } + /// Cargo.lock v1: checksum in `[metadata]`, dependents referencing the + /// crate by full id. REGRESSION: detach removed only the entry's + /// `source`, leaving `"cfg-if 1.0.4 (registry+…)"` references (and the + /// `[metadata]` checksum) naming a package the lock no longer has — + /// real cargo then refuses the vendored lock under `--locked`. + #[tokio::test] + async fn detach_and_restore_a_v1_lock_follow_metadata_and_full_id_refs() { + let other = "a".repeat(64); + let v1 = format!( + "[[package]]\nname = \"app\"\nversion = \"0.1.0\"\ndependencies = [\n \ + \"cfg-if 1.0.4 ({SOURCE})\",\n \"log 0.4.20 ({SOURCE})\",\n]\n\n\ + [[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\nsource = \"{SOURCE}\"\n\n\ + [[package]]\nname = \"log\"\nversion = \"0.4.20\"\nsource = \"{SOURCE}\"\n\ + dependencies = [\n \"cfg-if 1.0.4 ({SOURCE})\",\n]\n\n\ + [metadata]\n\"checksum cfg-if 1.0.4 ({SOURCE})\" = \"{CHECKSUM}\"\n\ + \"checksum log 0.4.20 ({SOURCE})\" = \"{other}\"\n" + ); + let dir = tempfile::tempdir().unwrap(); + let lock = dir.path().join("Cargo.lock"); + tokio::fs::write(&lock, &v1).await.unwrap(); + + let orig = detach_lock_entry(dir.path(), "cfg-if", "1.0.4", false) + .await + .unwrap(); + assert_eq!(orig.source, SOURCE); + assert_eq!( + orig.checksum.as_deref(), + Some(CHECKSUM), + "read from [metadata]" + ); + let detached = tokio::fs::read_to_string(&lock).await.unwrap(); + assert_eq!( + detached, + format!( + "[[package]]\nname = \"app\"\nversion = \"0.1.0\"\ndependencies = [\n \ + \"cfg-if 1.0.4\",\n \"log 0.4.20 ({SOURCE})\",\n]\n\n\ + [[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\n\n\ + [[package]]\nname = \"log\"\nversion = \"0.4.20\"\nsource = \"{SOURCE}\"\n\ + dependencies = [\n \"cfg-if 1.0.4\",\n]\n\n\ + [metadata]\n\"checksum log 0.4.20 ({SOURCE})\" = \"{other}\"\n" + ) + ); + assert_eq!( + probe_lock_entry(dir.path(), "cfg-if", "1.0.4").await, + LockEntryProbe::Detached + ); + + assert!( + restore_lock_entry(dir.path(), "cfg-if", "1.0.4", &orig, false) + .await + .unwrap() + ); + assert_eq!(tokio::fs::read_to_string(&lock).await.unwrap(), v1); + } + #[tokio::test] async fn detach_removes_only_source_and_checksum() { let dir = fixture().await; diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index 8fe75281..7b3e8c55 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -393,8 +393,9 @@ pub async fn vendored_entry_in_use(entry: &VendorEntry, project_root: &Path) -> // The remaining flavors wire resolutions into the lock itself // (resolved URLs / file: ranges / package tuples), so a textual // probe for the uuid dir is exact: the path appears iff some - // resolution still points at the artifact. shrinkwrap wins over - // package-lock, mirroring the vendor/revert lockfile selection. + // resolution still points at the artifact. Both npm locks are + // probed: npm <= 11 installs from the shrinkwrap, npm 12 from the + // package-lock beside it. None | Some("package-lock") => { lock_text_mentions_uuid( project_root, @@ -431,21 +432,32 @@ pub async fn vendored_entry_in_use(entry: &VendorEntry, project_root: &Path) -> } } -/// First readable lockfile from `names`, probed for the uuid artifact dir. -/// Shared with the textual backends' unwired-revert guard +/// Every readable lockfile from `names`, probed for the uuid artifact dir: +/// `Some(true)` when ANY of them mentions it, `Some(false)` when at least one +/// was readable and none does, `None` when none was readable. Shared with +/// the textual backends' unwired-revert guard /// ([`super::npm_lock::guard_unwired_textual_revert`]). +/// +/// It used to stop at the FIRST readable name (npm <= 11's shrinkwrap-wins +/// rule), but npm 12 installs from package-lock.json beside a committed +/// npm-shrinkwrap.json, so a mention in either lock can be the one an +/// install resolves through. pub(super) async fn lock_text_mentions_uuid( project_root: &Path, names: &[&str], uuid: &str, ) -> Option { let needle = format!(".socket/vendor/npm/{uuid}/"); + let mut any_readable = false; for name in names { if let Ok(text) = read_regular_to_string(&project_root.join(name)).await { - return Some(text.contains(&needle)); + if text.contains(&needle) { + return Some(true); + } + any_readable = true; } } - None + any_readable.then_some(false) } /// Revert one recorded npm vendor entry through the flavor that wired it. diff --git a/crates/socket-patch-core/src/vendor/npm_lock.rs b/crates/socket-patch-core/src/vendor/npm_lock.rs index 7a6a145d..1518d815 100644 --- a/crates/socket-patch-core/src/vendor/npm_lock.rs +++ b/crates/socket-patch-core/src/vendor/npm_lock.rs @@ -100,7 +100,7 @@ pub async fn vendor_npm( let base_purl = coords.base_purl; // ── 2. Lockfile selection ─────────────────────────────────────────── - let (lock_name, lock_bytes) = match select_lockfile(project_root).await { + let (lock_name, lock_bytes, sibling_locks) = match select_lockfile(project_root).await { Ok(Some(found)) => found, Ok(None) => { return refused( @@ -196,6 +196,31 @@ pub async fn vendor_npm( ); } + // ── 3b. Sibling lock (npm 12) ─────────────────────────────────────── + // npm 12 removed `npm shrinkwrap`, auto-creates a package-lock.json + // beside a committed npm-shrinkwrap.json on first install and then + // reifies FROM package-lock.json (verified against real npm 12.0.0 / + // 12.1.0; npm <= 11 installs from the shrinkwrap). Wiring only the + // shrinkwrap in that dual-lock state was a silent false success under + // npm 12 — the unpatched registry bytes kept installing. Every other + // present npm lock is therefore rewired identically (the hosted + // rewriter's rule), and one that cannot be is SAID. + let mut siblings: Vec = Vec::new(); + for (sib_name, sib_bytes) in sibling_locks { + match sibling_lock_target(sib_name, sib_bytes, name, version, &mut warnings) { + Ok(sib) => siblings.push(sib), + Err(why) => warnings.push(VendorWarning::new( + "vendor_npm_sibling_lock_unwired", + format!( + "{sib_name} beside {lock_name} was NOT rewired for {name}@{version} \ + ({why}) — npm >= 12 installs from {sib_name} when both exist, so those \ + installs stay UNPATCHED; regenerate it from {lock_name} (or delete it) \ + and re-run vendor" + ), + )), + } + } + // ── 4–7. Stage → patch → pack (shared flavor-agnostic pipeline: // tempdir stage outside the project, nested node_modules prune, // bundled-deps refusal, hardened apply, deterministic pack) ──── @@ -240,71 +265,62 @@ pub async fn vendor_npm( let mut wiring: Vec = Vec::new(); let mut changed = false; let mut recomputed_deps = false; - { - let Some(packages) = lock.get_mut("packages").and_then(Value::as_object_mut) else { - return done_failure_unstage( - purl, - "lock `packages` object vanished mid-rewrite".to_string(), - project_root, - &uuid_dir_rel, - uuid_dir_preexisted, - ) + let rewire = LockRewire { + name, + version, + resolved: &resolved, + integrity: &packed.integrity, + staged_pkg_json: staged_pkg_json.as_ref(), + }; + if let Err(e) = rewire.apply( + &mut lock, + lock_version, + &matches, + &lock_name, + &mut wiring, + &mut changed, + &mut recomputed_deps, + &mut warnings, + ) { + return done_failure_unstage(purl, e, project_root, &uuid_dir_rel, uuid_dir_preexisted) .await; - }; - for m in &matches { - let Some(live) = packages.get_mut(&m.key).and_then(Value::as_object_mut) else { - continue; - }; - // Idempotency: an instance already carrying our exact spec needs - // no edit and no wiring record. - if entry_in_sync(live, &resolved, &packed.integrity) { - continue; - } - // Never record one of our own (stale) edits as the "original" — - // revert must restore the pre-vendor registry fragment, not a - // dangling `.socket/vendor/` pointer from an earlier uuid. - let was_vendored = entry_points_into_vendor(live); - live.insert("resolved".to_string(), Value::String(resolved.clone())); - live.insert( - "integrity".to_string(), - Value::String(packed.integrity.clone()), - ); - if let Some(pkg) = &staged_pkg_json { - recompute_dep_fields(live, pkg); - recomputed_deps = true; - } - wiring.push(WiringRecord { - file: lock_name.clone(), - kind: KIND_LOCK_ENTRY.to_string(), - action: WiringAction::Rewritten, - key: Some(m.key.clone()), - original: if was_vendored { - None - } else { - Some(m.original.clone()) - }, - new: Some(Value::Object(live.clone())), - }); - changed = true; - } } - // lockfileVersion 2 keeps a legacy `dependencies` mirror (read by npm 6); - // leaving the registry resolved/integrity there would let an old client - // silently install unpatched bytes. - if lock_version == Some(2) { - if let Some(deps) = lock.get_mut("dependencies").and_then(Value::as_object_mut) { - rewrite_legacy_tree( - deps, - "/dependencies", - name, - version, - &resolved, - &packed.integrity, - &lock_name, - &mut wiring, - &mut changed, - &mut warnings, - ); + let primary_changed = changed; + // Sibling locks get the identical rewrite; their wiring records name + // their own file, so revert (which walks records per file) restores + // each. + let mut sibling_writes: Vec<(String, Vec, Vec)> = Vec::new(); + for sib in &mut siblings { + let mut sib_changed = false; + if let Err(e) = rewire.apply( + &mut sib.lock, + sib.lock_version, + &sib.matches, + &sib.name, + &mut wiring, + &mut sib_changed, + &mut recomputed_deps, + &mut warnings, + ) { + return done_failure_unstage(purl, e, project_root, &uuid_dir_rel, uuid_dir_preexisted) + .await; + } + if sib_changed { + changed = true; + let indent = detect_indent(&String::from_utf8_lossy(&sib.bytes)); + match serialize_json(&sib.lock, &indent) { + Ok(out) => sibling_writes.push((sib.name.clone(), sib.bytes.clone(), out)), + Err(e) => { + return done_failure_unstage( + purl, + format!("cannot serialize {}: {e}", sib.name), + project_root, + &uuid_dir_rel, + uuid_dir_preexisted, + ) + .await + } + } } } if recomputed_deps { @@ -346,15 +362,34 @@ pub async fn vendor_npm( .await } }; - if let Err(e) = atomic_write_bytes_preserving_mode(&project_root.join(&lock_name), &out).await { - return done_failure_unstage( - purl, - format!("cannot write {lock_name}: {e}"), - project_root, - &uuid_dir_rel, - uuid_dir_preexisted, - ) - .await; + // Siblings first, the primary lock last (still the final mutation); a + // failed write restores every sibling already written, so no lock is + // left resolving through an artifact the unstage removes. + let mut written: Vec<(&str, &[u8])> = Vec::new(); + let mut write_err: Option = None; + for (sib_name, original, out) in &sibling_writes { + if let Err(e) = atomic_write_bytes_preserving_mode(&project_root.join(sib_name), out).await + { + write_err = Some(format!("cannot write {sib_name}: {e}")); + break; + } + written.push((sib_name, original)); + } + if write_err.is_none() && primary_changed { + if let Err(e) = + atomic_write_bytes_preserving_mode(&project_root.join(&lock_name), &out).await + { + write_err = Some(format!("cannot write {lock_name}: {e}")); + } + } + if let Some(e) = write_err { + for (sib_name, original) in written { + // Best effort: the original bytes were read moments ago. + let _ = + atomic_write_bytes_preserving_mode(&project_root.join(sib_name), original).await; + } + return done_failure_unstage(purl, e, project_root, &uuid_dir_rel, uuid_dir_preexisted) + .await; } // ── 9. Marker + ledger entry ───────────────────────────────────────── @@ -967,15 +1002,166 @@ fn revert_one_record( // ───────────────────────────── small helpers ───────────────────────────── // (the flavor-agnostic coordinate/staging helpers live in `npm_common`) -async fn select_lockfile(project_root: &Path) -> std::io::Result)>> { +/// The primary lock (`npm-shrinkwrap.json` wins, like npm <= 11 installs) +/// plus every OTHER present npm lock (npm 12's dual-lock state: the +/// package-lock.json npm 12 reifies from). A sibling that exists but cannot +/// be read is returned as `Err` inside the list so the caller can say so. +/// Reads go through the guarded `read_regular_to_bytes` (flavor detection is +/// existence-only for the npm locks, so this read is the FIRST open — a FIFO +/// planted as a lockfile must fail fast, never wedge vendor or revert). +#[allow(clippy::type_complexity)] +async fn select_lockfile( + project_root: &Path, +) -> std::io::Result< + Option<( + String, + Vec, + Vec<(&'static str, std::io::Result>)>, + )>, +> { + let mut primary: Option<(String, Vec)> = None; + let mut siblings = Vec::new(); for lock_name in [SHRINKWRAP, PACKAGE_LOCK] { match read_regular_to_bytes(&project_root.join(lock_name)).await { - Ok(bytes) => return Ok(Some((lock_name.to_string(), bytes))), + Ok(bytes) if primary.is_none() => primary = Some((lock_name.to_string(), bytes)), + Ok(bytes) => siblings.push((lock_name, Ok(bytes))), Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, - Err(e) => return Err(e), + Err(e) if primary.is_none() => return Err(e), + Err(e) => siblings.push((lock_name, Err(e))), } } - Ok(None) + Ok(primary.map(|(name, bytes)| (name, bytes, siblings))) +} + +/// A present npm lock beside the primary one, parsed and scanned. +struct SiblingLock { + name: String, + bytes: Vec, + lock: Value, + lock_version: Option, + matches: Vec, +} + +/// Parse + validate + scan a sibling lock with the primary's rules; `Err` +/// is the human reason it cannot be rewired. +fn sibling_lock_target( + sib_name: &str, + sib_bytes: std::io::Result>, + name: &str, + version: &str, + warnings: &mut Vec, +) -> Result { + let bytes = sib_bytes.map_err(|e| format!("it cannot be read: {e}"))?; + let lock: Value = + serde_json::from_slice(&bytes).map_err(|e| format!("it is not parseable JSON: {e}"))?; + let lock_version = lock.get("lockfileVersion").and_then(Value::as_u64); + if !matches!(lock_version, Some(2) | Some(3)) + || !lock.get("packages").is_some_and(Value::is_object) + { + return Err(format!( + "lockfileVersion {lock_version:?}; only v2/v3 locks are supported" + )); + } + match scan_lock_matches(&lock, name, version, warnings) { + LockScan::Matches(matches) if !matches.is_empty() => Ok(SiblingLock { + name: sib_name.to_string(), + bytes, + lock, + lock_version, + matches, + }), + LockScan::Matches(_) => Err("it has no rewritable entry for the package".to_string()), + LockScan::WorkspaceMember { key } => Err(format!("`{key}` is a workspace member there")), + } +} + +/// The per-lock rewrite of step 8: every matched `packages` instance (and, +/// for v2, the legacy `dependencies` mirror) is pointed at the vendored +/// tarball, recording one wiring record per edit under `lock_name`. +struct LockRewire<'a> { + name: &'a str, + version: &'a str, + resolved: &'a str, + integrity: &'a str, + staged_pkg_json: Option<&'a Value>, +} + +impl LockRewire<'_> { + #[allow(clippy::too_many_arguments)] + fn apply( + &self, + lock: &mut Value, + lock_version: Option, + matches: &[LockMatch], + lock_name: &str, + wiring: &mut Vec, + changed: &mut bool, + recomputed_deps: &mut bool, + warnings: &mut Vec, + ) -> Result<(), String> { + let Some(packages) = lock.get_mut("packages").and_then(Value::as_object_mut) else { + return Err("lock `packages` object vanished mid-rewrite".to_string()); + }; + for m in matches { + let Some(live) = packages.get_mut(&m.key).and_then(Value::as_object_mut) else { + continue; + }; + // Idempotency: an instance already carrying our exact spec needs + // no edit and no wiring record. + if entry_in_sync(live, self.resolved, self.integrity) { + continue; + } + // Never record one of our own (stale) edits as the "original" — + // revert must restore the pre-vendor registry fragment, not a + // dangling `.socket/vendor/` pointer from an earlier uuid. + let was_vendored = entry_points_into_vendor(live); + live.insert( + "resolved".to_string(), + Value::String(self.resolved.to_string()), + ); + live.insert( + "integrity".to_string(), + Value::String(self.integrity.to_string()), + ); + if let Some(pkg) = self.staged_pkg_json { + recompute_dep_fields(live, pkg); + *recomputed_deps = true; + } + wiring.push(WiringRecord { + file: lock_name.to_string(), + kind: KIND_LOCK_ENTRY.to_string(), + action: WiringAction::Rewritten, + key: Some(m.key.clone()), + original: if was_vendored { + None + } else { + Some(m.original.clone()) + }, + new: Some(Value::Object(live.clone())), + }); + *changed = true; + } + // lockfileVersion 2 keeps a legacy `dependencies` mirror (read by + // npm 6); leaving the registry resolved/integrity there would let an + // old client silently install unpatched bytes. + if lock_version == Some(2) { + if let Some(deps) = lock.get_mut("dependencies").and_then(Value::as_object_mut) { + rewrite_legacy_tree( + deps, + "/dependencies", + self.name, + self.version, + self.resolved, + self.integrity, + lock_name, + wiring, + changed, + warnings, + ); + } + } + Ok(()) + } } #[cfg(test)] @@ -1982,23 +2168,24 @@ mod tests { } #[tokio::test] - async fn shrinkwrap_wins_over_package_lock() { + async fn shrinkwrap_only_project_rewires_the_shrinkwrap() { let fx = fixture().await; - // Same content as the package-lock, but under the shrinkwrap name. - tokio::fs::write(fx.root().join(SHRINKWRAP), &fx.lock_bytes) + // npm <= 11's `npm shrinkwrap` RENAMES the lock: shrinkwrap only. + tokio::fs::rename(fx.lock_path(), fx.root().join(SHRINKWRAP)) .await .unwrap(); - let (result, entry, _) = expect_done(fx.vendor(false).await); + let (result, entry, warnings) = expect_done(fx.vendor(false).await); assert!(result.success); let entry = entry.unwrap(); assert!(entry.wiring.iter().all(|r| r.file == SHRINKWRAP)); - - // package-lock.json byte-untouched; shrinkwrap rewritten. - assert_eq!( - tokio::fs::read(fx.lock_path()).await.unwrap(), - fx.lock_bytes + assert!( + !warnings + .iter() + .any(|w| w.code == "vendor_npm_sibling_lock_unwired"), + "{warnings:?}" ); + assert!(!fx.lock_path().exists(), "no package-lock.json is invented"); let shrink: Value = serde_json::from_slice(&tokio::fs::read(fx.root().join(SHRINKWRAP)).await.unwrap()) .unwrap(); @@ -2008,6 +2195,96 @@ mod tests { ); } + /// REGRESSION (npm 12): npm 12 auto-creates package-lock.json beside a + /// committed npm-shrinkwrap.json and installs FROM package-lock.json + /// (verified against real npm 12.0.0 / 12.1.0), so wiring only the + /// shrinkwrap was a silent false success there. BOTH locks are rewired + /// identically, each wiring record names its own file, a re-run is a + /// byte-stable no-op, and revert restores both byte-for-byte. + #[tokio::test] + async fn dual_npm_locks_are_both_rewired_and_both_reverted() { + let fx = fixture().await; + tokio::fs::write(fx.root().join(SHRINKWRAP), &fx.lock_bytes) + .await + .unwrap(); + + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success); + let entry = entry.unwrap(); + for lock in [SHRINKWRAP, PACKAGE_LOCK] { + assert!( + entry.wiring.iter().any(|r| r.file == lock), + "{lock} wiring recorded: {:?}", + entry.wiring + ); + let v: Value = + serde_json::from_slice(&tokio::fs::read(fx.root().join(lock)).await.unwrap()) + .unwrap(); + assert_eq!( + v["packages"]["node_modules/left-pad"]["resolved"], + json!(format!("file:{}", fx.expected_rel_tgz())), + "{lock} rewired" + ); + } + let wired_shrink = tokio::fs::read(fx.root().join(SHRINKWRAP)).await.unwrap(); + let wired_lock = tokio::fs::read(fx.lock_path()).await.unwrap(); + assert_eq!(wired_shrink, wired_lock, "identical rewrite in both locks"); + + // Re-run: in sync everywhere, nothing rewritten. + let (result, again, _) = expect_done(fx.vendor(false).await); + assert!(result.success); + assert!(again.is_none(), "in-sync re-run records nothing"); + assert_eq!(tokio::fs::read(fx.lock_path()).await.unwrap(), wired_lock); + + let outcome = revert_npm(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + for lock in [SHRINKWRAP, PACKAGE_LOCK] { + assert_eq!( + tokio::fs::read(fx.root().join(lock)).await.unwrap(), + fx.lock_bytes, + "{lock} restored byte-for-byte" + ); + } + assert!(!fx.root().join(".socket/vendor/npm").join(UUID).exists()); + } + + /// Only the shrinkwrap (primary) is rewired when a stale sibling + /// package-lock.json lacks the package: vendor still succeeds, but LOUDLY + /// names the lock npm 12 would install from unpatched. + #[tokio::test] + async fn unrewirable_sibling_lock_is_named_not_silently_skipped() { + let fx = fixture().await; + tokio::fs::write(fx.root().join(SHRINKWRAP), &fx.lock_bytes) + .await + .unwrap(); + let stale = json!({ + "name": "fixture", "version": "1.0.0", "lockfileVersion": 3, + "packages": { "": { "name": "fixture", "version": "1.0.0" } } + }); + let stale_bytes = serde_json::to_vec_pretty(&stale).unwrap(); + tokio::fs::write(fx.lock_path(), &stale_bytes) + .await + .unwrap(); + + let (result, entry, warnings) = expect_done(fx.vendor(false).await); + assert!(result.success); + assert!(entry.unwrap().wiring.iter().all(|r| r.file == SHRINKWRAP)); + let w = warnings + .iter() + .find(|w| w.code == "vendor_npm_sibling_lock_unwired") + .unwrap_or_else(|| panic!("sibling warning missing: {warnings:?}")); + assert!( + w.detail.contains(PACKAGE_LOCK) && w.detail.contains("npm >= 12"), + "{}", + w.detail + ); + assert_eq!( + tokio::fs::read(fx.lock_path()).await.unwrap(), + stale_bytes, + "the stale sibling is left byte-untouched" + ); + } + #[tokio::test] async fn v2_lock_rewrites_the_legacy_dependencies_mirror_and_reverts() { let lock = json!({ @@ -2952,17 +3229,14 @@ mod tests { /// The flip side: when the lock provably no longer resolves through the /// artifact (re-locked away from it), the empty-wiring revert keeps its /// pre-guard behavior and removes the genuinely orphaned artifact — - /// replaying nothing. A shrinkwrap wins the probe like it wins installs: - /// an uuid mention left behind in package-lock.json does not block. + /// replaying nothing. #[tokio::test] async fn empty_wiring_revert_removes_a_genuinely_orphaned_artifact() { let (fx, entry) = reconstructed_fixture().await; - // npm installs from the shrinkwrap when both exist; the pre-vendor - // one carries no uuid reference while package-lock.json still does. - tokio::fs::write(fx.root().join(SHRINKWRAP), &fx.lock_bytes) + // Re-locked away from the artifact: the pre-vendor lock is back. + tokio::fs::write(fx.lock_path(), &fx.lock_bytes) .await .unwrap(); - let lock_vendored = tokio::fs::read(fx.lock_path()).await.unwrap(); let outcome = revert_npm(&entry, fx.root(), false).await; assert!(outcome.success, "{:?}", outcome.error); @@ -2974,11 +3248,41 @@ mod tests { ); assert_eq!( tokio::fs::read(fx.lock_path()).await.unwrap(), - lock_vendored, + fx.lock_bytes, "empty wiring replays nothing" ); } + /// REGRESSION (npm 12): a clean shrinkwrap no longer "wins" the probe. + /// npm 12 installs from package-lock.json beside a committed + /// npm-shrinkwrap.json, so a package-lock.json still resolving through + /// the artifact must block its deletion — it used to be removed, and + /// every later npm 12 install failed ENOENT on the missing tarball. + #[tokio::test] + async fn empty_wiring_revert_refuses_while_the_sibling_package_lock_is_wired() { + let (fx, entry) = reconstructed_fixture().await; + tokio::fs::write(fx.root().join(SHRINKWRAP), &fx.lock_bytes) + .await + .unwrap(); + let lock_vendored = tokio::fs::read(fx.lock_path()).await.unwrap(); + + let outcome = revert_npm(&entry, fx.root(), false).await; + assert!(!outcome.success, "must refuse: package-lock.json is wired"); + assert!( + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_wiring_unknown_revert_blocked"), + "{:?}", + outcome.warnings + ); + assert!(fx.root().join(fx.expected_rel_tgz()).exists()); + assert_eq!( + tokio::fs::read(fx.lock_path()).await.unwrap(), + lock_vendored + ); + } + /// Undeterminable lock (present but unreadable — not UTF-8): fail /// closed — it may still resolve through the artifact. A lock that is /// absent altogether cannot reference anything, so removal proceeds. diff --git a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs index 6995e06a..53f6ef71 100644 --- a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs @@ -345,7 +345,7 @@ pub async fn vendor_yarn_berry( // tamper guard on the tarball itself (spike B3, flips on any byte edit). let hash6 = &tgz_sha512[..6]; let checksum = match berry_cache_checksum_10c0(&tgz_bytes, name) { - Ok(c) => c, + Ok(c) => checksum_in_lock_spelling(&lock_text, &c), Err(e) => { return done_failure_unstage( purl, @@ -997,8 +997,42 @@ fn carried_sections(lines: &[String]) -> Vec { out } +/// Whether `lock_text` spells its entries' `checksum:` values as BARE hex. +/// +/// yarn 4.0.x writes the bare sha512 hex even at cacheKey `10c0`; yarn 4.1+ +/// prefixes the cache key (`10c0/`). Both are the digest of the same +/// cache zip, but a `--immutable` install treats a respelled checksum as a +/// lockfile modification (YN0028: "The lockfile would have been modified by +/// this install") — so an entry Socket writes (the vendored `file:` entry, +/// the hosted `__archiveUrl` rewrite) must follow the lock's own spelling or +/// every CI install of a yarn 4.0.x project fails. A lock with no checksum +/// at all keeps the prefixed form (every yarn since 4.1). +pub(crate) fn lock_spells_bare_checksums(lock_text: &str) -> bool { + let mut saw_bare = false; + for line in lock_text.lines() { + let Some(value) = line.strip_prefix(" checksum:") else { + continue; + }; + let value = value.trim().trim_matches('"'); + if value.contains('/') { + return false; + } + saw_bare |= !value.is_empty(); + } + saw_bare +} + +/// `checksum` (the recipe's `10c0/`) spelled the way `lock_text` +/// spells its checksums (see [`lock_spells_bare_checksums`]). +pub(crate) fn checksum_in_lock_spelling(lock_text: &str, checksum: &str) -> String { + match checksum.split_once('/') { + Some((_, hex)) if lock_spells_bare_checksums(lock_text) => hex.to_string(), + _ => checksum.to_string(), + } +} + /// Read a berry scalar field (`: `, value possibly quoted). -pub(super) fn berry_field<'a>(lines: &'a [String], field: &str) -> Option<&'a str> { +pub(crate) fn berry_field<'a>(lines: &'a [String], field: &str) -> Option<&'a str> { for line in lines.iter().skip(1) { let Some(rest) = body_field_line(line) else { continue; @@ -3156,4 +3190,63 @@ __metadata: ); } } + + /// REGRESSION (yarn 4.0.x): a lock whose checksums are spelled bare + /// (yarn 4.0.0–4.0.2 at cacheKey `10c0`) gets the vendored entry's + /// checksum spelled bare too — byte-exact against the spike after-lock + /// with every checksum de-prefixed. The prefixed spelling made the + /// fresh-checkout `yarn install --immutable` fail with YN0028. + #[tokio::test] + async fn yarn40_bare_checksum_lock_gets_a_bare_vendored_checksum() { + let bare_before = B3_BEFORE_LOCK.replace("checksum: 10c0/", "checksum: "); + let fx = fixture_with(B3_BEFORE_PKG, &bare_before).await; + let (result, _entry, _warnings) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + let (hash6, checksum) = fx.packed_berry_facts().await; + let written = tokio::fs::read_to_string(fx.lock_path()).await.unwrap(); + assert_eq!( + written, + spike_after_lock(&hash6, &checksum).replace("checksum: 10c0/", "checksum: ") + ); + // Idempotent: the re-run sees its own (bare) entry as in sync. + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + assert!(entry.is_none(), "in-sync re-run writes nothing"); + assert_eq!( + tokio::fs::read_to_string(fx.lock_path()).await.unwrap(), + written + ); + } + + /// yarn 4.0.x spells `10c0` checksums bare, 4.1+ prefixed: a written + /// entry follows the lock (an `--immutable` install rejects a respelled + /// checksum with YN0028). A lock with no checksum keeps the prefix. + #[test] + fn checksum_spelling_follows_the_lock() { + let prefixed = "10c0/abcdef"; + let entry = |c: &str| format!("\"x@npm:1.0.0\":\n version: 1.0.0\n checksum: {c}\n"); + let bare_lock = format!( + "__metadata:\n version: 8\n cacheKey: 10c0\n\n{}", + entry("0123") + ); + let prefixed_lock = bare_lock.replace("checksum: 0123", "checksum: 10c0/0123"); + assert!(lock_spells_bare_checksums(&bare_lock)); + assert!(lock_spells_bare_checksums(&bare_lock.replace('\n', "\r\n"))); + assert_eq!(checksum_in_lock_spelling(&bare_lock, prefixed), "abcdef"); + assert!(!lock_spells_bare_checksums(&prefixed_lock)); + assert_eq!( + checksum_in_lock_spelling(&prefixed_lock, prefixed), + prefixed + ); + // Any prefixed entry means a 4.1+ lock. + let mixed = format!("{bare_lock}\n{}", entry("10c0/9999")); + assert!(!lock_spells_bare_checksums(&mixed)); + // No checksum at all: the modern prefixed form. + let none = "__metadata:\n version: 8\n cacheKey: 10c0\n"; + assert_eq!(checksum_in_lock_spelling(none, prefixed), prefixed); + // Deeper-indented `checksum:` text (a dependency named `checksum`) + // is not an entry field. + let nested = format!("{none}\n\"x@npm:1.0.0\":\n dependencies:\n checksum: 1.0.0\n"); + assert!(!lock_spells_bare_checksums(&nested)); + } } diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-and-dist/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-and-dist/expected-edits.json new file mode 100644 index 00000000..8165c850 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-and-dist/expected-edits.json @@ -0,0 +1,10 @@ +[ + { + "path": "composer.lock", + "kind": "redirect_composer_dist", + "action": "rewritten", + "key": "monolog/monolog", + "original": "\"source\": {\n \"type\": \"git\",\n \"url\": \"https://github.com/Seldaek/monolog.git\",\n \"reference\": \"abc123def456\"\n },\n \"dist\": {\n \"type\": \"zip\",\n \"url\": \"https://api.github.com/repos/Seldaek/monolog/zipball/abc123\",\n \"reference\": \"abc123def456\",\n \"shasum\": \"\"\n }", + "new": "\"dist\": {\n \"type\": \"zip\",\n \"url\": \"https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip\",\n \"reference\": \"abc123def456\",\n \"shasum\": \"abcdef0123456789abcdef0123456789abcdef01\"\n }" + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-and-dist/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-and-dist/expected-warnings.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-and-dist/expected-warnings.json @@ -0,0 +1 @@ +[] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-and-dist/expected/composer.lock b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-and-dist/expected/composer.lock new file mode 100644 index 00000000..67324378 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-and-dist/expected/composer.lock @@ -0,0 +1,34 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state" + ], + "content-hash": "abc123def456abc123def456abc1", + "packages": [ + { + "name": "monolog/monolog", + "version": "2.0.0", + "dist": { + "type": "zip", + "url": "https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip", + "reference": "abc123def456", + "shasum": "abcdef0123456789abcdef0123456789abcdef01" + } + }, + { + "name": "psr/log", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" + } + } + ], + "packages-dev": [] +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-and-dist/input/composer.lock b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-and-dist/input/composer.lock new file mode 100644 index 00000000..6f1b07d3 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-and-dist/input/composer.lock @@ -0,0 +1,39 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state" + ], + "content-hash": "abc123def456abc123def456abc1", + "packages": [ + { + "name": "monolog/monolog", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "abc123def456" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/abc123", + "reference": "abc123def456", + "shasum": "" + } + }, + { + "name": "psr/log", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" + } + } + ], + "packages-dev": [] +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-and-dist/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-and-dist/overrides.json new file mode 100644 index 00000000..a83719b2 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-and-dist/overrides.json @@ -0,0 +1,14 @@ +[ + { + "ecosystem": "composer", + "name": "monolog", + "namespace": "monolog", + "version": "2.0.0", + "token": "11111111-1111-1111-1111-111111111111", + "patchUuid": "44444444-4444-4444-4444-444444444444", + "artifactUrl": "https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip", + "integrity": { + "sha1": "abcdef0123456789abcdef0123456789abcdef01" + } + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/gem/bundler/basic/expected/Gemfile.lock b/crates/socket-patch-core/tests/fixtures/redirect/gem/bundler/basic/expected/Gemfile.lock index cbc30ebd..ec104e5b 100644 --- a/crates/socket-patch-core/tests/fixtures/redirect/gem/bundler/basic/expected/Gemfile.lock +++ b/crates/socket-patch-core/tests/fixtures/redirect/gem/bundler/basic/expected/Gemfile.lock @@ -1,12 +1,12 @@ GEM - remote: https://rubygems.org/ + remote: https://patch.socket.dev/patch-registry/gem/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/ specs: - puma (6.0.0) + rails (7.0.0) GEM - remote: https://patch.socket.dev/patch-registry/gem/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/ + remote: https://rubygems.org/ specs: - rails (7.0.0) + puma (6.0.0) PLATFORMS ruby From 60ddaf821fb8bbf6197a5231ff6b15f86fc9f4e9 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 23 Sep 2026 14:36:16 -0400 Subject: [PATCH 2/9] feat(hosted): auto-configure npm 12 allow-remote in the project .npmrc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm >= 12 defaults to `allow-remote=none` and refuses (EALLOWREMOTE) every lockfile entry whose tarball is not served by the configured registry — exactly what a hosted redirect writes. When `scan --mode hosted` / `get --mode hosted` leaves a root package-lock.json / npm-shrinkwrap.json carrying a granted hosted artifact URL, the run now ensures `allow-remote=all` in the project `.npmrc` (creating the file, or appending one line with the BOM, CRLF and every other byte preserved) and records it in the redirect ledger as `redirect_npmrc_allow_remote` (`created` / `added`). - core `patch::redirect::npmrc`: npm's `.npmrc` grammar as measured against npm 12.1.0 (exact `allow-remote` key, last top-level assignment wins, `[section]` bodies are not top-level, bare-CR line splits, case- sensitive value), the plan (create / append / already-all / respected user / env / outer-layer value / unsupported), and the unwinds. - Every reversal removes exactly what was added once no package-lock entry needs it: the whole-ledger replay (a new `NpmrcAllowRemote` inverse, grouped with the npm lock kinds), the per-purl npm revert behind scoped rollback / remove / the vendored takeover ("last one out", same transaction, flushed after the lock through #247's shared `staged::flush_staged`), and the vendored-supersedes-hosted reconcile. A modified created file keeps the user's lines (`redirect_npmrc_allow_remote_modified`, surfaced by rollback, remove, vendor and the reconcile). A symlinked `.npmrc` refuses an unwind at plan time, before anything is written. - Hosted run: the `.npmrc` edit rides `rewrite.files` / `rewrite.edits`, so it is written under #247's apply-lock window, after its whole-run SYMLINK GUARD, and only after the ledger persisted — never on `--dry-run` (which previews the write, also for a vendored → hosted takeover; the root locks are now read for such a preview so the pnpm `trustLockfile` preview sees the lock the wet run splices). An explicit user value (project `.npmrc`, user/global/builtin config, or an `npm_config_allow_remote` env var) is respected and named; a symlinked, unreadable or bare-CR `.npmrc` is left alone; `--no-npm-allow-remote- config` / `SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG` opts out. Every variant warns `redirect_npm_allow_remote` with the whole-tree tradeoff. - `atomic_write_bytes_preserving_mode` creates its stage with the destination's permission bits (kept inside #247's `commit_stage` structure), so a 0600 token-bearing `.npmrc` is never staged world- readable. - remove: the hosted leg's advisories are printed inside #247's `unwind_hosted` (so a run that then fails still reports them) and carried into the success envelope's `warnings[]`. Tests: npmrc/replay/takeover/scan unit tests, the flag's parse coverage, `redirect_npm_allow_remote` (plus #247's invariants: the lock never outlives the run, dry runs create no `.socket/`, a full rollback leaves no `.socket/` and never the user's `.npmrc`), and the dry-run takeover previews in `coverage_fix_scan_hosted_dryrun_vendored`. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/src/args.rs | 19 + .../socket-patch-cli/src/commands/remove.rs | 38 +- .../socket-patch-cli/src/commands/rollback.rs | 9 +- .../src/commands/scan/hosted.rs | 495 ++++- .../socket-patch-cli/src/commands/scan/mod.rs | 148 +- .../socket-patch-cli/src/commands/vendor.rs | 18 +- .../socket-patch-cli/tests/cli_global_args.rs | 13 +- .../socket-patch-cli/tests/cli_parse_get.rs | 1 + .../tests/cli_parse_repair.rs | 1 + .../socket-patch-cli/tests/cli_parse_scan.rs | 1 + .../tests/cli_parse_vendor.rs | 1 + .../socket-patch-cli/tests/cli_parse_vex.rs | 4 + ...overage_fix_scan_hosted_dryrun_vendored.rs | 142 ++ .../tests/redirect_npm_allow_remote.rs | 793 ++++++++ .../tests/remove_rollback_api_overrides.rs | 1 + .../src/patch/redirect/mod.rs | 1 + .../src/patch/redirect/npmrc.rs | 1660 +++++++++++++++++ .../src/patch/redirect/replay.rs | 195 ++ .../src/patch/redirect/state.rs | 6 +- .../src/patch/redirect/takeover.rs | 244 +++ crates/socket-patch-core/src/utils/fs.rs | 65 +- 21 files changed, 3809 insertions(+), 46 deletions(-) create mode 100644 crates/socket-patch-cli/tests/redirect_npm_allow_remote.rs create mode 100644 crates/socket-patch-core/src/patch/redirect/npmrc.rs diff --git a/crates/socket-patch-cli/src/args.rs b/crates/socket-patch-cli/src/args.rs index df137f10..09a3a501 100644 --- a/crates/socket-patch-cli/src/args.rs +++ b/crates/socket-patch-cli/src/args.rs @@ -342,6 +342,23 @@ pub struct GlobalArgs { value_parser = parse_bool_flag, )] pub no_trust_lockfile_config: bool, + + /// Hosted mode (`scan`/`get --mode hosted`): do NOT auto-configure + /// `allow-remote=all` in the project .npmrc after a root + /// package-lock.json / npm-shrinkwrap.json is repointed at the hosted + /// patch server. npm >= 12 defaults to `allow-remote=none` and refuses + /// the repointed lock (EALLOWREMOTE), so opting out means every install + /// needs `npm ci --allow-remote=all` instead (the run's warning spells + /// it out). Only hosted-mode `scan` and `get` read this; other + /// subcommands accept it silently. + #[arg( + help_heading = GLOBAL_OPTIONS, + long = "no-npm-allow-remote-config", + env = "SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG", + default_value_t = false, + value_parser = parse_bool_flag, + )] + pub no_npm_allow_remote_config: bool, } impl GlobalArgs { @@ -529,6 +546,7 @@ pub const GLOBAL_ARG_ENV_VARS: &[&str] = &[ "SOCKET_DEBUG", "SOCKET_TELEMETRY_DISABLED", "SOCKET_NO_TRUST_LOCKFILE_CONFIG", + "SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG", ]; /// Every env var a **subcommand-local** flag binds (one per `env = "..."` @@ -620,6 +638,7 @@ impl Default for GlobalArgs { debug: false, no_telemetry: false, no_trust_lockfile_config: false, + no_npm_allow_remote_config: false, } } } diff --git a/crates/socket-patch-cli/src/commands/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs index 2e6559f8..82e5f018 100644 --- a/crates/socket-patch-cli/src/commands/remove.rs +++ b/crates/socket-patch-cli/src/commands/remove.rs @@ -97,6 +97,19 @@ async fn emit_not_found( } } +/// Print the hosted leg's run-level advisories (`Warning (): …`) on +/// stderr — never under `--silent` / `--json` (JSON carries them in the +/// envelope's `warnings[]`). Printed as soon as the leg returns, so a +/// human run that then fails still says what it did to the files. +fn print_hosted_leg_warnings(common: &GlobalArgs, warnings: &[(String, String)]) { + if common.silent || common.json { + return; + } + for (code, detail) in warnings { + eprintln!("Warning ({code}): {detail}"); + } +} + /// Emit a `remove` error envelope and return. Used by the many error /// paths in `run` so they all share the same JSON shape. `dry_run` rides /// the envelope so preview failures report `dryRun: true`. @@ -741,6 +754,10 @@ pub async fn run(args: RemoveArgs) -> i32 { // wiring above; `--preserve-state` still unwinds — hosted has no // preservable local state. let mut hosted_reverted_events: Vec = Vec::new(); + // The hosted leg's run-level advisories (e.g. + // `redirect_npmrc_allow_remote_modified`): printed as they arrive, + // carried into the success envelope's `warnings[]`. + let mut hosted_leg_warnings: Vec<(String, String)> = Vec::new(); if !args.skip_rollback { match load_redirect_state(cwd).await { Err(e) => { @@ -759,7 +776,10 @@ pub async fn run(args: RemoveArgs) -> i32 { match unwind_hosted(&args.common, &hosted_matches, &mut redirect_state) .await { - Ok(leg) => leg, + Ok(leg) => { + hosted_leg_warnings.extend(leg.warnings.iter().cloned()); + leg + } Err(err) => { let (code, msg) = hosted_unwind_error(err, true); emit_error_envelope( @@ -1045,6 +1065,13 @@ pub async fn run(args: RemoveArgs) -> i32 { for ev in vendor_leg.skipped { env.record(ev); } + env.warnings + .extend(hosted_leg_warnings.iter().map(|(code, detail)| { + crate::json_envelope::RunWarning { + code: code.clone(), + detail: detail.clone(), + } + })); // One Removed event per purl whose manifest entry was deleted // (Verified on --dry-run). for purl in &removed { @@ -1298,6 +1325,9 @@ async fn unwind_hosted( let replay_eligible = state.records.keys().all(|p| hosted_matches.contains(p)); let before = (state.edits.len(), state.records.len()); let leg = run_hosted_leg(common, hosted_matches, state, replay_eligible).await; + // Printed as soon as the leg returns, so a human run that then fails + // still says what it did to the files. + print_hosted_leg_warnings(common, &leg.warnings); if !common.dry_run && (state.edits.len(), state.records.len()) != before { if let Err(e) = persist_redirect_state(&common.cwd, state).await { return Err(HostedUnwindError::Persist(e.to_string())); @@ -1441,6 +1471,12 @@ async fn remove_hosted_only( } else { PatchAction::Removed }; + for (code, detail) in &leg.warnings { + env.warnings.push(crate::json_envelope::RunWarning { + code: code.clone(), + detail: detail.clone(), + }); + } // Human per-purl lines already printed inside `run_hosted_leg`. for purl in &leg.reverted { env.record(PatchEvent::new(action, purl.clone()).with_reason( diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index e339981c..4a3aefe7 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -1058,6 +1058,9 @@ pub(crate) async fn run_hosted_leg( if !defer_bun && redirect_revert_supported(purl) { match revert_redirect_purl(&common.cwd, state, purl, common.dry_run).await { Ok(revert) => { + for (code, detail) in &revert.warnings { + out.warnings.push((code.clone(), detail.clone())); + } if !common.json && !common.silent { if common.dry_run { println!("Would unwind hosted redirect for {purl}"); @@ -1093,7 +1096,11 @@ pub(crate) async fn run_hosted_leg( // (however it was spelled), and also as the "last one out turns off // the lights" pass — per-purl reverts never claim the non-package // shared settings edits (such as pnpm trustLockfile), so an emptied - // record map with leftover edits replays them here too. + // record map with leftover edits replays them here too. (The npm + // `.npmrc` `allow-remote=all` edit is the one exception: the per-purl + // npm revert of the LAST package-lock entry unwinds it itself, so a + // scoped rollback leaves no loosened policy behind while other + // ecosystems' records remain.) if replay_eligible || (state.records.is_empty() && !state.edits.is_empty()) { let replay = revert_remaining_redirect_edits(&common.cwd, state, common.dry_run).await; for refusal in &replay.refusals { diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 5584c409..e41e83f4 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -370,6 +370,154 @@ fn plan_workspace_trust(existing: Option<&str>) -> TrustPlan { TrustPlan::Append(lines.join("\n")) } +/// The root npm locks the hosted rewriter edits (`rewrite_npm_lock` rewrites +/// every one present — npm 12 installs from package-lock.json beside a +/// committed shrinkwrap). +const NPM_LOCKS: [&str; 2] = ["npm-shrinkwrap.json", "package-lock.json"]; + +/// The honest-tradeoff + opt-out tail shared by every `allow-remote` +/// warning variant. The tradeoff sentence is a security disclosure, not +/// prose garnish: `allow-remote=all` lifts npm 12's remote-tarball refusal +/// for the WHOLE dependency tree, so it must be stated wherever the setting +/// is written or recommended (the pnpm `trustLockfile` precedent). +const NPM_ALLOW_REMOTE_TRADEOFF: &str = + "Note: allow-remote=all lets npm install ANY url-resolved (remote tarball) \ + dependency, not just the patched ones Socket serves — the per-entry sha512 \ + integrity pins are still enforced. `allow-remote=root` only admits direct \ + dependencies. npm <=11 installs work unchanged (npm 11 already defaults to \ + `all`; npm <=10 has no such setting)"; + +/// The policy preamble shared by every `allow-remote` warning variant: what +/// was repointed, and how npm >= 12 fails without the setting. +fn npm_allow_remote_preamble(hosts: &[&str]) -> String { + format!( + "the npm lockfile now resolves patched dependencies from the hosted patch server ({}); \ + npm >=12 refuses tarballs from any host other than the configured registry by \ + default (`allow-remote=none`, error EALLOWREMOTE)", + hosts.join(", ") + ) +} + +/// The auto-config variant: `allow-remote=all` was (or, on `--dry-run`, +/// would be) written to the project `.npmrc`, so installs need no flags. +fn npm_allow_remote_configured_detail(hosts: &[&str], created: bool, dry_run: bool) -> String { + let how = match (created, dry_run) { + (true, false) => "`allow-remote=all` was written to a new", + (false, false) => "`allow-remote=all` was appended to the existing", + (true, true) => "`allow-remote=all` would be written to a new", + (false, true) => "`allow-remote=all` would be appended to the existing", + }; + format!( + "{}, so {how} project .npmrc — commit it alongside the lock; `npm ci` needs no \ + extra flags. {NPM_ALLOW_REMOTE_TRADEOFF}. To keep npm's default instead, re-run \ + with --no-npm-allow-remote-config (SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG) and install \ + with `npm ci --allow-remote=all`", + npm_allow_remote_preamble(hosts), + ) +} + +/// The project `.npmrc` already resolves to `allow-remote=all`. +fn npm_allow_remote_already_detail(hosts: &[&str]) -> String { + format!( + "{}, and the project .npmrc already sets `allow-remote=all` — keep it committed \ + alongside the lock; `npm ci` needs no extra flags. {NPM_ALLOW_REMOTE_TRADEOFF}", + npm_allow_remote_preamble(hosts), + ) +} + +/// The user explicitly set another value: respected, never flipped (the +/// pnpm `trustLockfile: false` precedent) — the warning names the manual +/// recoveries instead. +fn npm_allow_remote_user_set_detail(hosts: &[&str], value: &str) -> String { + format!( + "{}. The project .npmrc explicitly sets `allow-remote={value}`, which was respected \ + and left untouched — set `allow-remote=all` there yourself (or install with \ + `npm ci --allow-remote=all`) so npm >=12 installs the patched artifacts. \ + {NPM_ALLOW_REMOTE_TRADEOFF}", + npm_allow_remote_preamble(hosts), + ) +} + +/// An `npm_config_allow_remote` environment variable sets another value. +/// npm's env layer beats every `.npmrc`, so a project write could not take +/// effect in this environment — and an explicit setting is respected. +fn npm_allow_remote_env_set_detail(hosts: &[&str], var: &str, value: &str) -> String { + format!( + "{}. The environment variable {var}={value} explicitly sets `allow-remote`, which \ + was respected: npm's environment layer overrides every .npmrc, so a project \ + `allow-remote=all` would not take effect here and the project .npmrc was left \ + untouched — unset {var} (or install with `npm ci --allow-remote=all`) so npm >=12 \ + installs the patched artifacts. {NPM_ALLOW_REMOTE_TRADEOFF}", + npm_allow_remote_preamble(hosts), + ) +} + +/// A lower npm config layer (user / global / builtin file) explicitly sets +/// another value. A committed project `allow-remote=all` would silently +/// override that machine / org policy on every checkout, so it is +/// respected like a project value and the override is left to the user. +fn npm_allow_remote_outer_set_detail( + hosts: &[&str], + layer: &str, + path: &std::path::Path, + value: &str, +) -> String { + format!( + "{}. The {layer} npm config ({}) explicitly sets `allow-remote={value}`, which was \ + respected: socket-patch does not commit a project .npmrc that overrides it, and \ + the project .npmrc was left untouched — to accept the patched artifacts in this \ + project anyway, set `allow-remote=all` in the project .npmrc yourself (it outranks \ + the {layer} config) or install with `npm ci --allow-remote=all`. \ + {NPM_ALLOW_REMOTE_TRADEOFF}", + npm_allow_remote_preamble(hosts), + path.display(), + ) +} + +/// The opt-out (`--no-npm-allow-remote-config`) variant: nothing written, +/// both manual recoveries spelled out. +fn npm_allow_remote_manual_detail(hosts: &[&str]) -> String { + format!( + "{}. Commit `allow-remote=all` in the project .npmrc (or install with \ + `npm ci --allow-remote=all`) so npm >=12 installs the patched artifacts. \ + {NPM_ALLOW_REMOTE_TRADEOFF}", + npm_allow_remote_preamble(hosts), + ) +} + +/// The unreadable/unsafe `.npmrc` fallback: the file exists but could not +/// be read, or is a symlink / non-regular file the atomic writer would +/// replace. Planning a Create here would OVERWRITE the user's registry / +/// auth config, so the auto-config stands down and names the problem. +fn npm_allow_remote_unreadable_detail(hosts: &[&str], why: &str) -> String { + format!( + "{}. The project .npmrc exists but {why}; it was left untouched. Add \ + `allow-remote=all` to it yourself (or install with `npm ci --allow-remote=all`) \ + so npm >=12 installs the patched artifacts. {NPM_ALLOW_REMOTE_TRADEOFF}", + npm_allow_remote_preamble(hosts), + ) +} + +/// The project `.npmrc` read, classified for the allow-remote auto-config: +/// `Ok(Some(text))` — a regular file read fine; `Ok(None)` — ABSENT (the +/// only state where planning a Create is safe); `Err(why)` — present but +/// unreadable, a symlink (the atomic stage+rename writer would replace the +/// link with a detached copy — and the whole-run symlink guard would refuse +/// the redirect), or not a regular file (FIFO-safe: never opened blocking). +fn read_npmrc_for_allow_remote(path: &std::path::Path) -> Result, String> { + match std::fs::symlink_metadata(path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(format!("could not be inspected ({e})")), + Ok(meta) if meta.file_type().is_symlink() => { + return Err("is a symbolic link (socket-patch never writes through one)".into()) + } + Ok(_) => {} + } + socket_patch_core::utils::fs::read_regular_to_string_sync(path) + .map(Some) + .map_err(|e| format!("could not be read ({e})")) +} + /// The hosted-mode JSON error envelope, for bail-outs that return before the /// success envelope at the bottom of [`run_redirect`] is built. When the /// classic scan object (`scan_result`, threaded in from `run`) is present it @@ -910,7 +1058,8 @@ pub(super) async fn run_redirect( /// The hosted-redirect engine over an ALREADY-SELECTED `(purl, uuid)` set: /// reference grants → DepOverride build → apply lock (wet runs with a grant) /// → ledger load → vendored→hosted takeover pre-revert (symlink-checked -/// first) → candidate-file read → rewrite → pnpm trust config → +/// first) → candidate-file read → rewrite → pnpm trust config → npm `.npmrc` +/// allow-remote config → /// confirmation probe → ledger merge-then-persist → file writes → gem stale /// probe → warnings → optional VEX. Shared VERBATIM by `scan --mode hosted` /// — its `--json` arm through the `run_redirect` wrapper (which selects via @@ -1207,6 +1356,16 @@ pub(crate) async fn run_redirect_selected( // hosted rewriter does not also rewrite (a Gemfile line, a uv source). let mut takeover_migrated: Vec = Vec::new(); let mut takeover_files: std::collections::BTreeSet = std::collections::BTreeSet::new(); + // Which root locks each dry-run takeover purl is vendored into (from + // its vendor ledger wiring): the wet run reverts that wiring and then + // splices the hosted URL there, so the install-policy auto-configs + // (npm `.npmrc` allow-remote, pnpm `trustLockfile`) must be PREVIEWED + // for those locks even though the rewriters never see these purls. + let mut dry_run_takeover_locks: std::collections::HashMap> = + std::collections::HashMap::new(); + // `(artifact_url, wired root locks)` of the withheld dry-run takeover + // candidates — filled when they leave the rewrite set below. + let mut dry_run_takeover_urls: Vec<(String, Vec)> = Vec::new(); if !candidates.iter().any(|c| takeover_capable(&c.purl)) { // No takeover-capable candidates — nothing to reconcile. } else { @@ -1348,6 +1507,10 @@ pub(crate) async fn run_redirect_selected( dry_run_takeover.push((purl.clone(), uuid.clone())); takeover_migrated.push(purl.clone()); takeover_files.extend(entry.wiring.iter().map(|w| w.file.clone())); + dry_run_takeover_locks.insert( + purl.clone(), + entry.wiring.iter().map(|w| w.file.clone()).collect(), + ); continue; } let outcome = @@ -1451,6 +1614,17 @@ pub(crate) async fn run_redirect_selected( .chain(dry_run_takeover.iter().map(|(p, _)| p.as_str())) .collect(); if !withheld.is_empty() { + // Keep the dry-run takeover candidates' URLs (and the root locks + // their purl is vendored into) for the install-policy previews. + for (purl, _) in &dry_run_takeover { + let locks = dry_run_takeover_locks + .get(purl) + .cloned() + .unwrap_or_default(); + for c in candidates.iter().filter(|c| &c.purl == purl) { + dry_run_takeover_urls.push((c.dep.artifact_url.clone(), locks.clone())); + } + } candidates.retain(|c| !withheld.contains(c.purl.as_str())); } } @@ -1465,12 +1639,14 @@ pub(crate) async fn run_redirect_selected( // writer. A non-regular file now reads as "unreadable" and is skipped // exactly like a missing one. // - // Skipped entirely when no candidate survived (every reference skipped, - // refused or withheld as a dry-run takeover preview): the rewriters + // Skipped entirely when no candidate survived (every reference skipped + // or refused) and no dry-run takeover preview is pending: the rewriters // place nothing and warn about nothing without a dep, so the ~45 reads // would only feed an empty rewrite. Everything after the rewrite still - // runs — the previews are counted, the skips and warnings reported, a - // requested VEX still attempted. + // runs — the skips and warnings reported, a requested VEX still + // attempted. A dry-run takeover preview still needs the root locks: the + // install-policy previews below (pnpm `trustLockfile`, npm `.npmrc`) + // judge the lock the wet run splices after its revert. use socket_patch_core::utils::fs::read_regular_to_string; let mut files: std::collections::BTreeMap = std::collections::BTreeMap::new(); // Rush monorepos have no root package.json/lock pair: the single pnpm @@ -1481,7 +1657,7 @@ pub(crate) async fn run_redirect_selected( // rewritten in place, and the write-back below is already path-generic. let mut rush_warnings: Vec = Vec::new(); let mut rush_lock_keys: Vec = Vec::new(); - if !candidates.is_empty() { + if !candidates.is_empty() || !dry_run_takeover_urls.is_empty() { for name in REDIRECT_CANDIDATE_FILES { if *name == "bun.lockb" { continue; @@ -1777,6 +1953,26 @@ pub(crate) async fn run_redirect_selected( if let Some(text) = heal_root { pnpm_lock_texts.push(text); } + // A dry-run vendored→hosted takeover of a purl vendored into the + // root pnpm lock: the wet run reverts that wiring and splices the + // hosted URL into it, so the trust config is previewed against the + // root lock (the vendored text carries the same lockfileVersion). + let takeover_pnpm_urls: Vec<&str> = dry_run_takeover_urls + .iter() + .filter(|(_, locks)| locks.iter().any(|l| l == "pnpm-lock.yaml")) + .map(|(url, _)| url.as_str()) + .collect(); + let takeover_root: Option<&String> = if takeover_pnpm_urls.is_empty() + || heal_root.is_some() + || rewrite.files.contains_key("pnpm-lock.yaml") + { + None + } else { + files.get("pnpm-lock.yaml") + }; + if let Some(text) = takeover_root { + pnpm_lock_texts.push(text); + } if !pnpm_lock_texts.is_empty() { // Name only the hosts whose artifact URL actually landed in a // touched pnpm lock's final text (spliced this run, or the @@ -1801,6 +1997,8 @@ pub(crate) async fn run_redirect_selected( }) }) .filter_map(|o| url_host(&o.artifact_url)) + // Dry-run takeover purls land in the root lock on the wet run. + .chain(takeover_pnpm_urls.iter().filter_map(|url| url_host(url))) .collect(); hosts.sort_unstable(); hosts.dedup(); @@ -1812,7 +2010,10 @@ pub(crate) async fn run_redirect_selected( // Root-lock gate (see the block comment above): only the plain // project lock at lockfileVersion >= 9 gets the auto-config — // spliced this run, or detected already-redirected (heal path). - let root_lock_v9 = heal_root.is_some() + let root_lock_v9 = heal_root + .or(takeover_root) + .and_then(|text| pnpm_lock_version_major(text)) + .is_some_and(|major| major >= 9) || rewrite .files .get("pnpm-lock.yaml") @@ -1915,12 +2116,143 @@ pub(crate) async fn run_redirect_selected( })); } } + // npm >= 12 ships `allow-remote=none`: it refuses (EALLOWREMOTE) every + // tarball whose `resolved` origin is not the configured registry — which + // is exactly what a hosted redirect writes. Verified against real + // installs (npm 12.0.0 / 12.1.0): a fresh `npm ci` of the redirected lock + // fails before fetching anything, while npm <= 11 (11.x ships + // `allow-remote=all`; <= 10 has no such setting) installs it unchanged, + // and `allow-remote=all` in the project `.npmrc` makes npm 12 install + // the patched bytes with the sha512 pins still enforced. `root` is not + // enough in general: it only admits DIRECT dependencies of the project. + // + // ZERO-TOUCH DEFAULT (the npm twin of the pnpm trustLockfile auto-config + // above): whenever a root npm lock ends this run carrying a granted + // hosted artifact URL (spliced now, or already redirected by an earlier + // run — so a missed config heals on re-run), the run ensures + // `allow-remote=all` in the project `.npmrc` — created when absent + // (`action: "created"`), one line appended otherwise (`"added"`), every + // other byte preserved — and records it in the ledger + // (`redirect_npmrc_allow_remote`) so rollback / remove / the vendored + // takeover remove exactly that once no package-lock entry needs it. An + // explicit user `allow-remote=` is RESPECTED (never flipped), an + // unreadable / symlinked `.npmrc` is left alone, and + // `--no-npm-allow-remote-config` opts out entirely; every variant still + // WARNS (`redirect_npm_allow_remote`) with the whole-tree tradeoff. + // Vendored mode is unaffected: its `file:.socket/vendor/…` specs are npm + // `file` specs, gated by `allow-file` (default `all`), not + // `allow-remote`. + let mut npm_warnings: Vec = Vec::new(); + let mut npmrc_config_write: Option<(String, socket_patch_core::patch::redirect::FileEdit)> = + None; + { + let npm_hosts: Vec<&str> = { + let mut hosts: Vec<&str> = overrides + .iter() + .filter(|o| o.ecosystem == "npm") + .filter(|o| { + NPM_LOCKS.iter().any(|lock| { + rewrite + .files + .get(*lock) + .or_else(|| files.get(*lock)) + .is_some_and(|text| { + socket_patch_core::patch::redirect::artifact_url_present( + text, + &o.artifact_url, + ) + }) + }) + }) + .filter_map(|o| url_host(&o.artifact_url)) + // A dry-run vendored→hosted takeover: the wet run reverts + // the vendored wiring in a root npm lock and splices the + // hosted URL there, so preview the `.npmrc` write too. + .chain( + dry_run_takeover_urls + .iter() + .filter(|(_, locks)| locks.iter().any(|l| NPM_LOCKS.contains(&l.as_str()))) + .filter_map(|(url, _)| url_host(url)), + ) + .collect(); + hosts.sort_unstable(); + hosts.dedup(); + hosts + }; + if !npm_hosts.is_empty() { + use socket_patch_core::patch::redirect::npmrc::{ + plan_npmrc_allow_remote_with, resolve_outer_allow_remote, NpmConfigEnv, NpmrcPlan, + NPMRC_ALLOW_REMOTE_EDIT_KIND, NPMRC_REL, + }; + let edit = |action: &str| socket_patch_core::patch::redirect::FileEdit { + path: NPMRC_REL.into(), + kind: NPMRC_ALLOW_REMOTE_EDIT_KIND.into(), + action: action.into(), + key: Some("allow-remote".into()), + original: None, + new: Some(serde_json::json!("all")), + }; + let npmrc = read_npmrc_for_allow_remote(&common.cwd.join(NPMRC_REL)); + // The npm config layers OUTSIDE the project file, located the + // way npm does: an env `npm_config_allow_remote` beats the + // project file, and an explicit user / global / builtin value is + // a machine / org policy a committed project line would silently + // override — both are respected like a project value. + let outer = resolve_outer_allow_remote(&NpmConfigEnv::from_process(), |path| { + socket_patch_core::utils::fs::read_regular_to_string_sync(path).ok() + }); + let detail = match npmrc { + // Opt-out still reports an explicit / already-set value + // truthfully; only the WRITE is suppressed. + Ok(existing) => match plan_npmrc_allow_remote_with(existing.as_deref(), &outer) { + NpmrcPlan::AlreadyAll => npm_allow_remote_already_detail(&npm_hosts), + NpmrcPlan::UserSet(value) => { + npm_allow_remote_user_set_detail(&npm_hosts, &value) + } + NpmrcPlan::EnvSet { var, value } => { + npm_allow_remote_env_set_detail(&npm_hosts, &var, &value) + } + NpmrcPlan::OuterSet { layer, path, value } => { + npm_allow_remote_outer_set_detail(&npm_hosts, layer, &path, &value) + } + NpmrcPlan::Unsupported(why) => { + npm_allow_remote_unreadable_detail(&npm_hosts, &why) + } + _ if common.no_npm_allow_remote_config => { + npm_allow_remote_manual_detail(&npm_hosts) + } + NpmrcPlan::Create(text) => { + npmrc_config_write = Some((text, edit("created"))); + npm_allow_remote_configured_detail(&npm_hosts, true, common.dry_run) + } + NpmrcPlan::Append(text) => { + npmrc_config_write = Some((text, edit("added"))); + npm_allow_remote_configured_detail(&npm_hosts, false, common.dry_run) + } + }, + Err(why) => npm_allow_remote_unreadable_detail(&npm_hosts, &why), + }; + npm_warnings.push(serde_json::json!({ + "code": "redirect_npm_allow_remote", + "detail": detail, + })); + } + } if let Some((text, edit)) = trust_config_write { rewrite.files.insert(PNPM_WORKSPACE_REL.to_string(), text); // Appended last: `--revert` walks edits in reverse, so the trust key // is unwound before the lock originals are restored. rewrite.edits.push(edit); } + if let Some((text, edit)) = npmrc_config_write { + rewrite.files.insert( + socket_patch_core::patch::redirect::npmrc::NPMRC_REL.to_string(), + text, + ); + // Appended after the lock edits for the same reason: a whole-ledger + // replay unwinds the setting before the lock originals it served. + rewrite.edits.push(edit); + } let rewritten: Vec = rewrite .files .keys() @@ -2408,6 +2740,7 @@ pub(crate) async fn run_redirect_selected( warnings.extend(record_warnings.iter().cloned()); warnings.extend(rush_warnings.iter().cloned()); warnings.extend(pnpm_warnings.iter().cloned()); + warnings.extend(npm_warnings.iter().cloned()); warnings.extend(gem_stale.warnings.iter().cloned()); warnings.extend(python_stale.warnings.iter().cloned()); warnings.extend(takeover_pre_warnings.iter().cloned()); @@ -2917,10 +3250,14 @@ pub(crate) fn boxed_run_redirect_selected<'a>( mod tests { use super::{ build_redirect_json_envelope, gem_stale_cache_warning, gem_stale_install_warning, - gem_stale_install_warnings, installed_stale_positive_evidence, parse_purl_simple, - plan_workspace_trust, pnpm_heal_root, pnpm_lock_carries_hosted_redirect, - pnpm_lock_version_major, pnpm_trust_configured_detail, pnpm_trust_legacy_detail, - pnpm_trust_manual_guidance, pnpm_trust_workspace_unreadable_detail, prune_ignored_warning, + gem_stale_install_warnings, installed_stale_positive_evidence, + npm_allow_remote_already_detail, npm_allow_remote_configured_detail, + 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, parse_purl_simple, plan_workspace_trust, pnpm_heal_root, + pnpm_lock_carries_hosted_redirect, pnpm_lock_version_major, pnpm_trust_configured_detail, + pnpm_trust_legacy_detail, pnpm_trust_manual_guidance, + pnpm_trust_workspace_unreadable_detail, prune_ignored_warning, read_npmrc_for_allow_remote, read_workspace_for_trust, redirect_json_block, TrustPlan, REDIRECT_CANDIDATE_FILES, }; use super::{ @@ -4456,4 +4793,140 @@ mod tests { pnpm-lock.yaml to keep the redirect." ); } + + /// REGRESSION (npm 12): every hosted npm redirect variant tells the user + /// that npm >= 12 refuses the redirected lock (EALLOWREMOTE) without + /// `allow-remote=all`, and carries the whole-tree tradeoff disclosure — + /// the auto-configured, already-set, explicit-other, opted-out and + /// unreadable variants alike. + #[test] + fn npm_allow_remote_warning_variants_carry_the_load_bearing_sentences() { + let hosts = ["patch.socket.dev"]; + let variants = [ + npm_allow_remote_configured_detail(&hosts, true, false), + npm_allow_remote_configured_detail(&hosts, false, false), + npm_allow_remote_configured_detail(&hosts, true, true), + npm_allow_remote_configured_detail(&hosts, false, true), + npm_allow_remote_already_detail(&hosts), + npm_allow_remote_user_set_detail(&hosts, "root"), + npm_allow_remote_manual_detail(&hosts), + npm_allow_remote_unreadable_detail(&hosts, "could not be read (denied)"), + npm_allow_remote_env_set_detail(&hosts, "npm_config_allow_remote", "none"), + npm_allow_remote_outer_set_detail( + &hosts, + "user", + std::path::Path::new("/home/u/.npmrc"), + "none", + ), + ]; + for d in &variants { + for needle in [ + "patch.socket.dev", + "npm >=12", + "EALLOWREMOTE", + "lets npm install ANY url-resolved", + "sha512 integrity pins are still enforced", + "npm <=11 installs work unchanged", + ] { + assert!(d.contains(needle), "{needle:?} missing: {d}"); + } + } + let [created, appended, dry_created, dry_appended, already, user_set, manual, unreadable, env_set, outer_set] = + &variants; + assert!( + env_set.contains("npm_config_allow_remote=none") + && env_set.contains("overrides every .npmrc") + && env_set.contains("would not take effect") + && env_set.contains("left untouched"), + "{env_set}" + ); + assert!( + outer_set.contains("The user npm config (/home/u/.npmrc)") + && outer_set.contains("explicitly sets `allow-remote=none`") + && outer_set.contains("does not commit a project .npmrc that overrides it"), + "{outer_set}" + ); + assert!( + created.contains("was written to a new project .npmrc"), + "{created}" + ); + assert!( + appended.contains("was appended to the existing project .npmrc"), + "{appended}" + ); + // The summary line already says it is a dry run (the pnpm + // trustLockfile twin's rule): no marker inside the noun phrase. + assert!( + dry_created.contains("would be written to a new project .npmrc") + && !dry_created.contains("(--dry-run)"), + "{dry_created}" + ); + assert!( + dry_appended.contains("would be appended to the existing project .npmrc") + && !dry_appended.contains("(--dry-run)"), + "{dry_appended}" + ); + for d in [created, appended, dry_created, dry_appended] { + assert!( + d.contains("--no-npm-allow-remote-config"), + "opt-out named: {d}" + ); + assert!(d.contains("SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG"), "{d}"); + } + assert!( + already.contains("already sets `allow-remote=all`"), + "{already}" + ); + assert!( + user_set.contains("explicitly sets `allow-remote=root`"), + "{user_set}" + ); + assert!( + user_set.contains("respected and left untouched"), + "{user_set}" + ); + assert!( + user_set.contains("only admits direct dependencies"), + "{user_set}" + ); + for d in [user_set, manual, unreadable, env_set, outer_set] { + assert!( + d.contains("npm ci --allow-remote=all"), + "manual remedy: {d}" + ); + } + assert!( + unreadable.contains("could not be read (denied)"), + "{unreadable}" + ); + } + + /// The `.npmrc` read classifier: absent → plan a Create; readable → + /// plan against the text; a symlink or unreadable file → hands off + /// (never planned — a Create would clobber the user's config, and the + /// atomic writer would replace a link). + #[test] + fn read_npmrc_for_allow_remote_classifies_absent_readable_and_unsafe() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join(".npmrc"); + assert_eq!(read_npmrc_for_allow_remote(&path), Ok(None)); + std::fs::write(&path, "fund=false\n").unwrap(); + assert_eq!( + read_npmrc_for_allow_remote(&path), + Ok(Some("fund=false\n".into())) + ); + std::fs::write(&path, [0xff_u8, 0xfe]).unwrap(); + assert!(read_npmrc_for_allow_remote(&path) + .unwrap_err() + .contains("could not be read")); + #[cfg(unix)] + { + std::fs::remove_file(&path).unwrap(); + std::fs::write(tmp.path().join("real"), "fund=false\n").unwrap(); + std::os::unix::fs::symlink(tmp.path().join("real"), &path).unwrap(); + assert!(read_npmrc_for_allow_remote(&path) + .unwrap_err() + .contains("symbolic link")); + } + } } diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 19e0f034..570fb9be 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -1151,8 +1151,25 @@ pub(super) fn mode_takeover_detail(superseded: &[String], current_is_hosted: boo /// (envelope contract: codes are additive and stable; only the free-text /// detail differs), and it fires exactly once — the reconciled ledger no /// longer overlaps, so re-runs stay silent. -pub(super) fn mode_takeover_reconciled_detail(reconciled: &[String]) -> String { +pub(super) fn mode_takeover_reconciled_detail( + reconciled: &[String], + npmrc_unwound: bool, +) -> String { let list = reconciled.join(", "); + // The `.npmrc` sentence is conditional: only a run that actually + // unwound the hosted npm allow-remote auto-config says so, and then the + // "restores the hosted wiring" claim gains its npm >= 12 caveat. + let npmrc = if npmrc_unwound { + " The hosted redirect's `.npmrc` `allow-remote=all` auto-config was \ + unwound too (a redirect-created file deleted, an appended line \ + removed): the vendored `file:` specs do not need it. If you later \ + restore the hosted lock wiring with `vendor --revert`, npm >=12 \ + refuses it (EALLOWREMOTE) until `allow-remote=all` is back — re-run \ + `scan --mode hosted` afterwards to re-establish it and its ledger \ + record." + } else { + "" + }; format!( "vendored artifacts superseded the hosted redirect ledger for: {list}; \ reconciled automatically. Both halves of each superseded entry — the \ @@ -1161,9 +1178,9 @@ pub(super) fn mode_takeover_reconciled_detail(reconciled: &[String]) -> String { deleted). The lockfile points at the committed `.socket/vendor/` \ files, and the pre-vendor lock values (including the hosted-spliced \ fragment) are preserved as the vendor ledger's wiring originals, so \ - `vendor --revert` still restores the hosted wiring losslessly. \ - Ledger data for other, still-redirected package(s) was left \ - untouched. No action needed." + `vendor --revert` still restores the hosted lock wiring \ + byte-for-byte.{npmrc} Ledger data for other, still-redirected \ + package(s) was left untouched. No action needed." ) } @@ -1174,16 +1191,21 @@ pub(super) fn mode_takeover_reconciled_detail(reconciled: &[String]) -> String { /// against the LIVE lockfile: the gate that makes the warning truthful is /// the one that makes the drop lossless (the vendor ledger's wiring /// `original` embeds the hosted-spliced fragment, so `vendor --revert` needs -/// nothing from these records). `Ok(false)` when nothing matched (degenerate -/// — the caller falls back to the manual advisory rather than claiming a -/// reconciliation that did not happen); `Err` when the ledger could not be -/// read back or persisted (fail closed: the atomic writer leaves the on-disk -/// ledger either untouched or fully pre-drop, and the caller surfaces the -/// failure inside the warning). -async fn reconcile_superseded_redirect(cwd: &Path, purls: &[String]) -> Result { +/// nothing from these records). `Ok(Some(npmrc))` — reconciled, with the +/// outcome of the `.npmrc` allow-remote unwind (whether the file changed, +/// and its advisories for the caller to surface); `Ok(None)` when nothing +/// matched (degenerate — the caller falls back to the manual advisory +/// rather than claiming a reconciliation that did not happen); `Err` when +/// the ledger could not be read back or persisted (fail closed: the atomic +/// writer leaves the on-disk ledger either untouched or fully pre-drop, and +/// the caller surfaces the failure inside the warning). +async fn reconcile_superseded_redirect( + cwd: &Path, + purls: &[String], +) -> Result, String> { let mut state = match socket_patch_core::patch::redirect::load_redirect_state(cwd).await { Ok(Some(state)) => state, - Ok(None) => return Ok(false), + Ok(None) => return Ok(None), Err(corrupt) => return Err(corrupt.to_string()), }; let mut dropped = false; @@ -1191,12 +1213,23 @@ async fn reconcile_superseded_redirect(cwd: &Path, purls: &[String]) -> Result push_run_warning( - env, - common, - VENDOR_SUPERSEDES_REDIRECT, - mode_takeover_reconciled_detail(&reconcilable), - ), + Ok(Some(npmrc)) => { + push_run_warning( + env, + common, + VENDOR_SUPERSEDES_REDIRECT, + mode_takeover_reconciled_detail(&reconcilable, npmrc.file_changed), + ); + // The `.npmrc` unwind's own advisories (a redirect-created file + // the user has since added to: kept, only our line removed) — + // surfaced like rollback / vendor surface them. + for (code, detail) in npmrc.warnings { + push_run_warning(env, common, &code, detail); + } + } // Nothing matched to drop — do not claim a reconciliation that did // not happen; hand out the manual remediation instead. - Ok(false) => push_run_warning( + Ok(None) => push_run_warning( env, common, VENDOR_SUPERSEDES_REDIRECT, @@ -4538,6 +4579,69 @@ mod tests { ); } + /// Finding: the reconcile unwound the hosted `.npmrc` auto-config but + /// threw away the unwind's warnings (a user-edited redirect-created + /// `.npmrc` was rewritten with no `redirect_npmrc_allow_remote_modified`) + /// and its detail never mentioned `.npmrc` while still promising + /// `vendor --revert` restores the hosted wiring (npm 12 then refuses it + /// without the line). Both are now surfaced. + #[tokio::test] + async fn vendored_takeover_reconcile_surfaces_the_npmrc_unwind() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_redirect_ledger_with_edits( + root, + &[NPM_TAKEOVER_PURL], + vec![ + redirect_edit("package-lock.json", "minimist@1.2.2"), + socket_patch_core::patch::redirect::FileEdit { + path: ".npmrc".into(), + kind: "redirect_npmrc_allow_remote".into(), + action: "created".into(), + key: Some("allow-remote".into()), + original: None, + new: Some(serde_json::json!("all")), + }, + ], + ) + .await; + write_vendor_ledger_wired(root, &[NPM_TAKEOVER_PURL]).await; + write_lock_pointing_at_vendored(root, "minimist", "1.2.2").await; + // The user added their own setting to the redirect-created file. + tokio::fs::write(root.join(".npmrc"), "allow-remote=all\nfund=false\n") + .await + .unwrap(); + + let mut env = vendor_env(); + note_vendor_supersedes_redirect(&mut env, root, &takeover_common()).await; + + let codes: Vec<&str> = env.warnings.iter().map(|w| w.code.as_str()).collect(); + assert_eq!( + codes, + [ + VENDOR_SUPERSEDES_REDIRECT, + "redirect_npmrc_allow_remote_modified" + ], + "{:?}", + env.warnings + ); + let detail = &env.warnings[0].detail; + assert!(detail.contains("reconciled automatically"), "{detail}"); + assert!(detail.contains("`.npmrc` `allow-remote=all`"), "{detail}"); + assert!(detail.contains("EALLOWREMOTE"), "{detail}"); + assert_eq!( + tokio::fs::read_to_string(root.join(".npmrc")) + .await + .unwrap(), + "fund=false\n", + "only our line removed" + ); + assert!(load_ledger(root).await.is_none(), "emptied ledger deleted"); + + // Without a recorded `.npmrc` edit the detail stays silent on it. + assert!(!mode_takeover_reconciled_detail(&["p".into()], false).contains(".npmrc")); + } + #[tokio::test] async fn vendored_takeover_dry_run_warns_manual_and_leaves_the_ledger() { let tmp = tempfile::tempdir().unwrap(); @@ -4602,7 +4706,7 @@ mod tests { assert_eq!( env.warnings[0].detail, mode_takeover_detail(&[NPM_TAKEOVER_PURL.to_string()], false), - "an Ok(false) reconcile must fall back to the manual detail verbatim" + "an Ok(None) reconcile must fall back to the manual detail verbatim" ); let after = tokio::fs::read(&ledger_path).await.unwrap(); assert_eq!( diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 5de59bde..af34323c 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -1592,7 +1592,23 @@ pub(crate) async fn vendor_records( ) .await { - Ok(_) => { + Ok(revert) => { + // Advisories from the same transaction (a + // redirect-created `.npmrc` modified since — + // kept, only the `allow-remote=all` line removed). + for (code, detail) in &revert.warnings { + if code == "redirect_npmrc_allow_remote_modified" { + record_warning( + env, + candidate, + &VendorWarning::new( + "redirect_npmrc_allow_remote_modified", + detail.clone(), + ), + common, + ); + } + } if let Err(e) = socket_patch_core::patch::redirect::persist_redirect_state( &common.cwd, diff --git a/crates/socket-patch-cli/tests/cli_global_args.rs b/crates/socket-patch-cli/tests/cli_global_args.rs index 27acb136..b0ca583c 100644 --- a/crates/socket-patch-cli/tests/cli_global_args.rs +++ b/crates/socket-patch-cli/tests/cli_global_args.rs @@ -111,6 +111,9 @@ fn global_flag_cases() -> Vec<(&'static str, Option<&'static str>, fn(&GlobalArg ("--no-trust-lockfile-config", None, |c| { assert!(c.no_trust_lockfile_config) }), + ("--no-npm-allow-remote-config", None, |c| { + assert!(c.no_npm_allow_remote_config) + }), ("--lock-timeout", Some("30"), |c| { assert_eq!(c.lock_timeout, Some(30)) }), @@ -224,17 +227,18 @@ fn global_flag_cases_cover_every_global_field() { debug: _, no_telemetry: _, no_trust_lockfile_config: _, + no_npm_allow_remote_config: _, strict: _, vendor_source: _, vendor_url: _, patch_server_url: _, } = common; - // 24 fields ↔ 24 long-flag cases. Bump both this count and add a case when + // 25 fields ↔ 25 long-flag cases. Bump both this count and add a case when // the destructure above forces you to add a field. assert_eq!( global_flag_cases().len(), - 24, + 25, "every GlobalArgs field needs a long-flag case in global_flag_cases()", ); @@ -655,7 +659,7 @@ fn bool_env_vars_reject_zero_and_falsey() { #[serial_test::serial] fn empty_bool_env_var_resolves_to_false_not_crash() { // (env var, accessor) for every boolean global. - let bool_vars: [(&str, fn(&GlobalArgs) -> bool); 11] = [ + let bool_vars: [(&str, fn(&GlobalArgs) -> bool); 12] = [ ("SOCKET_OFFLINE", |c| c.offline), ("SOCKET_STRICT", |c| c.strict), ("SOCKET_GLOBAL", |c| c.global), @@ -669,6 +673,9 @@ fn empty_bool_env_var_resolves_to_false_not_crash() { ("SOCKET_NO_TRUST_LOCKFILE_CONFIG", |c| { c.no_trust_lockfile_config }), + ("SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG", |c| { + c.no_npm_allow_remote_config + }), ]; let saved = save_and_clear_global_env(); diff --git a/crates/socket-patch-cli/tests/cli_parse_get.rs b/crates/socket-patch-cli/tests/cli_parse_get.rs index 77adb2ac..83919aba 100644 --- a/crates/socket-patch-cli/tests/cli_parse_get.rs +++ b/crates/socket-patch-cli/tests/cli_parse_get.rs @@ -55,6 +55,7 @@ const SOCKET_ENV_VARS: &[&str] = &[ "SOCKET_DEBUG", "SOCKET_TELEMETRY_DISABLED", "SOCKET_NO_TRUST_LOCKFILE_CONFIG", + "SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG", // GetArgs-specific "SOCKET_SAVE_ONLY", "SOCKET_ONE_OFF", diff --git a/crates/socket-patch-cli/tests/cli_parse_repair.rs b/crates/socket-patch-cli/tests/cli_parse_repair.rs index cac26176..80a943fa 100644 --- a/crates/socket-patch-cli/tests/cli_parse_repair.rs +++ b/crates/socket-patch-cli/tests/cli_parse_repair.rs @@ -62,6 +62,7 @@ const SOCKET_ENV_VARS: &[&str] = &[ "SOCKET_DEBUG", "SOCKET_TELEMETRY_DISABLED", "SOCKET_NO_TRUST_LOCKFILE_CONFIG", + "SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG", // RepairArgs-specific "SOCKET_DOWNLOAD_ONLY", ]; diff --git a/crates/socket-patch-cli/tests/cli_parse_scan.rs b/crates/socket-patch-cli/tests/cli_parse_scan.rs index 5468ddc1..4c846c38 100644 --- a/crates/socket-patch-cli/tests/cli_parse_scan.rs +++ b/crates/socket-patch-cli/tests/cli_parse_scan.rs @@ -43,6 +43,7 @@ const SCAN_ENV_VARS: &[&str] = &[ "SOCKET_LOCK_TIMEOUT", "SOCKET_MANIFEST_PATH", "SOCKET_NO_TRUST_LOCKFILE_CONFIG", + "SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG", "SOCKET_OFFLINE", "SOCKET_ORG_SLUG", "SOCKET_PATCH_SERVER_URL", diff --git a/crates/socket-patch-cli/tests/cli_parse_vendor.rs b/crates/socket-patch-cli/tests/cli_parse_vendor.rs index e5b92edc..baf7c6e0 100644 --- a/crates/socket-patch-cli/tests/cli_parse_vendor.rs +++ b/crates/socket-patch-cli/tests/cli_parse_vendor.rs @@ -59,6 +59,7 @@ const SOCKET_ENV_VARS: &[&str] = &[ "SOCKET_DEBUG", "SOCKET_TELEMETRY_DISABLED", "SOCKET_NO_TRUST_LOCKFILE_CONFIG", + "SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG", // VendorArgs-specific "SOCKET_FORCE", "SOCKET_VENDOR_REVERT", diff --git a/crates/socket-patch-cli/tests/cli_parse_vex.rs b/crates/socket-patch-cli/tests/cli_parse_vex.rs index 65543c6b..03276e8e 100644 --- a/crates/socket-patch-cli/tests/cli_parse_vex.rs +++ b/crates/socket-patch-cli/tests/cli_parse_vex.rs @@ -58,6 +58,7 @@ const SOCKET_ENV_VARS: &[&str] = &[ "SOCKET_DEBUG", "SOCKET_TELEMETRY_DISABLED", "SOCKET_NO_TRUST_LOCKFILE_CONFIG", + "SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG", // VexArgs / VexEmbedArgs "SOCKET_VEX", "SOCKET_VEX_OUTPUT", @@ -215,6 +216,7 @@ struct Snap { debug: bool, no_telemetry: bool, no_trust_lockfile_config: bool, + no_npm_allow_remote_config: bool, output: Option, product: Option, no_verify: bool, @@ -248,6 +250,7 @@ fn snapshot(a: &VexArgs) -> Snap { debug: a.common.debug, no_telemetry: a.common.no_telemetry, no_trust_lockfile_config: a.common.no_trust_lockfile_config, + no_npm_allow_remote_config: a.common.no_npm_allow_remote_config, output: a.output.clone(), product: a.product.clone(), no_verify: a.no_verify, @@ -288,6 +291,7 @@ fn expected_defaults() -> Snap { debug: false, no_telemetry: false, no_trust_lockfile_config: false, + no_npm_allow_remote_config: false, output: None, product: None, no_verify: false, diff --git a/crates/socket-patch-cli/tests/coverage_fix_scan_hosted_dryrun_vendored.rs b/crates/socket-patch-cli/tests/coverage_fix_scan_hosted_dryrun_vendored.rs index 9158424e..1315877d 100644 --- a/crates/socket-patch-cli/tests/coverage_fix_scan_hosted_dryrun_vendored.rs +++ b/crates/socket-patch-cli/tests/coverage_fix_scan_hosted_dryrun_vendored.rs @@ -193,6 +193,20 @@ fn run_cli(cwd: &Path, args: &[&str]) -> (i32, String, String) { } } cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); + // Isolate the npm config layers the hosted `.npmrc` auto-config + // consults (an ambient explicit `allow-remote` would change the plan). + let absent = cwd.join(".absent-npm-config"); + for var in [ + "NPM_CONFIG_USERCONFIG", + "npm_config_userconfig", + "NPM_CONFIG_GLOBALCONFIG", + "npm_config_globalconfig", + "PREFIX", + ] { + cmd.env(var, &absent); + } + cmd.env("NPM_CONFIG_ALLOW_REMOTE", "") + .env("npm_config_allow_remote", ""); let out = cmd.output().expect("spawn socket-patch binary"); ( out.status.code().unwrap_or(-1), @@ -264,6 +278,21 @@ fn vendored_project(root: &Path) { ); } +fn warning_detail<'a>(doc: &'a Value, code: &str) -> Option<&'a str> { + doc["redirect"]["warnings"] + .as_array()? + .iter() + .find(|w| w["code"] == code) + .and_then(|w| w["detail"].as_str()) +} + +fn rewritten_files(doc: &Value) -> Vec<&str> { + doc["redirect"]["rewrittenFiles"] + .as_array() + .map(|f| f.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default() +} + fn warning_codes(doc: &Value) -> Vec<&str> { doc["redirect"]["warnings"] .as_array() @@ -316,6 +345,29 @@ async fn dry_run_over_vendored_project_previews_the_wet_takeover() { Some(0), "a revertable vendored purl is not skipped: {doc:#}" ); + // Review finding: the pnpm trustLockfile auto-config the wet run + // writes (the takeover splices the root v9 lock) must be previewed too. + let trust = warning_detail(&doc, "redirect_pnpm_trust_lockfile") + .unwrap_or_else(|| panic!("the trust config must be previewed: {doc:#}")); + // (The vendor run already created pnpm-workspace.yaml for its own + // wiring, so the wet run MERGES the key into it.) + assert!( + trust.contains("would be merged into the existing pnpm-workspace.yaml"), + "{trust}" + ); + assert!( + rewritten_files(&doc).contains(&"pnpm-workspace.yaml"), + "the preview must list the workspace file the wet run writes: {doc:#}" + ); + let workspace = |root: &Path| std::fs::read_to_string(root.join("pnpm-workspace.yaml")).ok(); + let vendored_workspace = workspace(root); + assert!( + !vendored_workspace + .as_deref() + .unwrap_or_default() + .contains("trustLockfile"), + "dry run writes nothing" + ); // Dry-run invariants: nothing on disk moved. assert_eq!( @@ -347,6 +399,10 @@ async fn dry_run_over_vendored_project_previews_the_wet_takeover() { lock.contains(&format!("tarball: {HOSTED_URL}")), "the wet run must leave the lock hosted:\n{lock}" ); + assert!( + workspace(root).is_some_and(|w| w.contains("trustLockfile: true")), + "the wet run writes what the preview promised: {wet:#}" + ); } /// Refusal parity: a vendored purl whose revert the wet run would REFUSE @@ -498,3 +554,89 @@ async fn human_takeover_prints_migration_lines_and_matching_file_counts() { "stdout=\n{wet_out}" ); } + +/// The package-lock twin of [`write_pnpm_project`]: a lockfileVersion 3 +/// root lock resolving the package from the registry. +fn write_package_lock_project(root: &Path) { + std::fs::write( + root.join("package.json"), + format!( + r#"{{ "name": "consumer", "version": "0.0.0", "dependencies": {{ "{NAME}": "{VERSION}" }} }}"# + ), + ) + .unwrap(); + let pkg = root.join("node_modules").join(NAME); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + format!(r#"{{ "name": "{NAME}", "version": "{VERSION}" }}"#), + ) + .unwrap(); + std::fs::write(pkg.join("index.js"), ORIG_INDEX).unwrap(); + let lock = json!({ + "name": "consumer", "version": "0.0.0", "lockfileVersion": 3, "requires": true, + "packages": { + "": { "name": "consumer", "version": "0.0.0", "dependencies": { NAME: VERSION } }, + format!("node_modules/{NAME}"): { + "version": VERSION, + "resolved": format!("https://registry.npmjs.org/{NAME}/-/{NAME}-{VERSION}.tgz"), + "integrity": UPSTREAM_SHA512, + } + } + }); + std::fs::write( + root.join("package-lock.json"), + serde_json::to_string_pretty(&lock).unwrap(), + ) + .unwrap(); +} + +/// Review finding: a dry-run vendored→hosted takeover of a package-lock +/// purl withheld the purl from the rewriters, so the npm 12 `.npmrc` +/// `allow-remote=all` write the wet run makes was neither warned about +/// ("would be written to a new project .npmrc") nor listed in `rewrittenFiles`. +#[tokio::test] +#[serial] +async fn dry_run_package_lock_takeover_previews_the_npmrc_write() { + let server = MockServer::start().await; + mock_hosted_api(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_package_lock_project(root); + seed_manifest_and_blob(root); + let (code, env) = vendor_cli(root); + assert_eq!(code, 0, "fixture vendor run must succeed: {env:#}"); + let vendored_lock = std::fs::read_to_string(root.join("package-lock.json")).unwrap(); + assert!(vendored_lock.contains(".socket/vendor/"), "{vendored_lock}"); + + let (code, doc) = scan_hosted_json(root, &server.uri(), /*dry_run=*/ true); + assert_eq!(code, 0, "{doc:#}"); + assert!( + warning_codes(&doc).contains(&"redirect_would_revert_vendored"), + "{doc:#}" + ); + assert_eq!(doc["redirect"]["redirected"], 1, "{doc:#}"); + let detail = warning_detail(&doc, "redirect_npm_allow_remote") + .unwrap_or_else(|| panic!("the .npmrc write must be previewed: {doc:#}")); + assert!( + detail.contains("would be written to a new project .npmrc") + && detail.contains("patch.test"), + "{detail}" + ); + assert!(rewritten_files(&doc).contains(&".npmrc"), "{doc:#}"); + assert!(!root.join(".npmrc").exists(), "dry run writes nothing"); + assert_eq!( + std::fs::read_to_string(root.join("package-lock.json")).unwrap(), + vendored_lock + ); + + let (code, wet) = scan_hosted_json(root, &server.uri(), /*dry_run=*/ false); + assert_eq!(code, 0, "{wet:#}"); + assert_eq!(wet["redirect"]["redirected"], 1, "{wet:#}"); + assert_eq!( + std::fs::read_to_string(root.join(".npmrc")).unwrap(), + "allow-remote=all\n", + "the wet run writes what the preview promised" + ); +} diff --git a/crates/socket-patch-cli/tests/redirect_npm_allow_remote.rs b/crates/socket-patch-cli/tests/redirect_npm_allow_remote.rs new file mode 100644 index 00000000..b77547f4 --- /dev/null +++ b/crates/socket-patch-cli/tests/redirect_npm_allow_remote.rs @@ -0,0 +1,793 @@ +//! Regression suite for the npm 12 `allow-remote` hazard of hosted mode. +//! +//! npm 12 changed the `allow-remote` default to `none`: `npm ci` / `npm +//! install` refuse (EALLOWREMOTE) every tarball whose `resolved` origin is +//! not the configured registry — which is exactly what `scan --mode hosted` +//! writes into package-lock.json / npm-shrinkwrap.json. Verified against the +//! real npm 12.0.0 / 12.1.0 (`e2e_redirect_npm_build`'s pinned matrix): the +//! redirected lock fails to install until the project `.npmrc` carries +//! `allow-remote=all`. +//! +//! The hosted run therefore AUTO-CONFIGURES it (the npm twin of the pnpm +//! `trustLockfile` auto-config): it ensures `allow-remote=all` in the project +//! `.npmrc` (created, or one line appended with every other byte kept), +//! records the edit in the redirect ledger (`redirect_npmrc_allow_remote`) +//! so `rollback` removes exactly what it added, respects an explicit other +//! user value, honors `--no-npm-allow-remote-config` / +//! `SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG` and `--dry-run`, and ALWAYS warns +//! (`redirect_npm_allow_remote`) with the whole-tree tradeoff — while a +//! project whose npm-family redirect is not in an npm lock stays quiet. +//! +//! Hermetic: wiremock API, the built binary, no npm needed. + +use std::path::Path; + +use serde_json::{json, Value}; +use wiremock::matchers::{method, path, path_regex}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +#[path = "common/mod.rs"] +mod common; + +const ORG: &str = "test-org"; +const NAME: &str = "allow-remote-dep"; +const VERSION: &str = "1.0.0"; +const PURL: &str = "pkg:npm/allow-remote-dep@1.0.0"; +const UUID: &str = "11111111-1111-4111-8111-111111111111"; +const PATCHED_SHA512: &str = "sha512-PATCHEDpatchedPATCHEDpatched0123456789=="; +const UPSTREAM_SHA512: &str = "sha512-UPSTREAMupstream=="; +const CODE: &str = "redirect_npm_allow_remote"; + +fn hosted_url() -> String { + format!( + "http://patch.test/patch/npm/{NAME}/{VERSION}/22222222-2222-4222-8222-222222222222/{UUID}/{NAME}-{VERSION}.tgz" + ) +} + +async fn mock_api(server: &MockServer) { + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "packages": [{ + "purl": PURL, + "patches": [{ + "uuid": UUID, "purl": PURL, "tier": "free", + "cveIds": [], "ghsaIds": [], "severity": "high", + "title": "allow-remote fixture" + }] + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "patches": [{ + "uuid": UUID, "purl": PURL, + "publishedAt": "2024-01-01T00:00:00Z", + "description": "x", "license": "MIT", "tier": "free", + "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "results": { UUID: { + "status": "granted", + "url": hosted_url(), + "purl": PURL, + "artifacts": [{ + "kind": "tarball", + "url": hosted_url(), + "integrity": { "sha512": PATCHED_SHA512 } + }], + "registryOverride": null + }} + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "uuid": UUID, "purl": PURL, + "publishedAt": "2024-01-01T00:00:00Z", + "files": { "package/index.js": { + "beforeHash": "a".repeat(64), "afterHash": "b".repeat(64), + }}, + "vulnerabilities": {}, + "description": "x", "license": "MIT", "tier": "free" + }))) + .mount(server) + .await; +} + +/// package.json + an installed copy + a registry-resolved lock under +/// `lock_name` (package-lock.json or npm-shrinkwrap.json). +fn write_npm_project(root: &Path, lock_name: &str) { + std::fs::write( + root.join("package.json"), + format!(r#"{{ "name": "consumer", "version": "0.0.0", "dependencies": {{ "{NAME}": "{VERSION}" }} }}"#), + ) + .unwrap(); + let pkg = root.join("node_modules").join(NAME); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + format!(r#"{{ "name": "{NAME}", "version": "{VERSION}" }}"#), + ) + .unwrap(); + let lock = json!({ + "name": "consumer", "version": "0.0.0", "lockfileVersion": 3, "requires": true, + "packages": { + "": { "name": "consumer", "version": "0.0.0", "dependencies": { NAME: VERSION } }, + format!("node_modules/{NAME}"): { + "version": VERSION, + "resolved": format!("https://registry.npmjs.org/{NAME}/-/{NAME}-{VERSION}.tgz"), + "integrity": UPSTREAM_SHA512, + } + } + }); + std::fs::write( + root.join(lock_name), + serde_json::to_string_pretty(&lock).unwrap(), + ) + .unwrap(); +} + +/// Isolate the child from the developer's / runner's npm config layers: +/// the hosted run respects an explicit `allow-remote` in the env and in the +/// user / global / builtin npm config, so an ambient value would flip what +/// these tests exercise. Empty env values are ignored (by npm and by the +/// resolver); the file layers point at paths that do not exist. Both +/// spellings are pinned because npm matches the prefix case-insensitively. +/// `npm_config_prefix` is pinned too: it outranks `PREFIX`, and the GitHub +/// Windows runner sets it machine-wide (`C:\npm\prefix`). The builtin +/// layer (npm's own `npmrc`, beside the `node` on PATH) has no env +/// relocation in npm, so it stays the machine's; the pinned user / global +/// paths make any `userconfig` / `globalconfig` / `prefix` it sets inert. +fn npm_isolation(root: &Path) -> Vec<(String, String)> { + let absent = |name: &str| root.join(name).to_str().unwrap().to_string(); + vec![ + ("NPM_CONFIG_USERCONFIG".into(), absent(".absent-user-npmrc")), + ("npm_config_userconfig".into(), absent(".absent-user-npmrc")), + ( + "NPM_CONFIG_GLOBALCONFIG".into(), + absent(".absent-global-npmrc"), + ), + ( + "npm_config_globalconfig".into(), + absent(".absent-global-npmrc"), + ), + ("NPM_CONFIG_PREFIX".into(), absent(".absent-prefix")), + ("npm_config_prefix".into(), absent(".absent-prefix")), + ("PREFIX".into(), absent(".absent-prefix")), + ("NPM_CONFIG_ALLOW_REMOTE".into(), String::new()), + ("npm_config_allow_remote".into(), String::new()), + ] +} + +/// Run the binary with [`npm_isolation`] plus `env` (which lands last). +fn run_isolated(cwd: &Path, args: &[&str], env: &[(&str, &str)]) -> (i32, String, String) { + let mut all = npm_isolation(cwd); + all.extend(env.iter().map(|(k, v)| (k.to_string(), v.to_string()))); + let refs: Vec<(&str, &str)> = all.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); + common::run_with_env(cwd, args, &refs) +} + +fn scan_hosted(cwd: &Path, api: &str, extra: &[&str]) -> (i32, Value, String) { + scan_hosted_env(cwd, api, extra, &[]) +} + +fn scan_hosted_env( + cwd: &Path, + api: &str, + extra: &[&str], + env: &[(&str, &str)], +) -> (i32, Value, String) { + let cwd_s = cwd.to_str().unwrap().to_string(); + let mut args = vec![ + "scan", + "--mode", + "hosted", + "--yes", + "--cwd", + &cwd_s, + "--api-url", + api, + "--org", + ORG, + "--api-token", + "fake", + ]; + args.extend_from_slice(extra); + let (code, stdout, stderr) = run_isolated(cwd, &args, env); + let doc = if args.contains(&"--json") { + serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("not JSON ({e}):\n{stdout}\nstderr:\n{stderr}")) + } else { + Value::Null + }; + (code, doc, stderr) +} + +fn allow_remote_warning(doc: &Value) -> Option<&str> { + doc["redirect"]["warnings"] + .as_array() + .into_iter() + .flatten() + .find(|w| w["code"] == CODE) + .and_then(|w| w["detail"].as_str()) +} + +/// The recorded `.npmrc` ledger edits (`(action, key, new)`). +fn npmrc_edits(root: &Path) -> Vec<(String, String, String)> { + let Ok(text) = std::fs::read_to_string(root.join(".socket/vendor/redirect-state.json")) else { + return Vec::new(); + }; + let ledger: Value = serde_json::from_str(&text).unwrap(); + ledger["edits"] + .as_array() + .into_iter() + .flatten() + .filter(|e| e["kind"] == "redirect_npmrc_allow_remote") + .map(|e| { + assert_eq!(e["path"], ".npmrc", "{e}"); + ( + e["action"].as_str().unwrap().to_string(), + e["key"].as_str().unwrap().to_string(), + e["new"].as_str().unwrap().to_string(), + ) + }) + .collect() +} + +fn rollback(cwd: &Path, extra: &[&str]) -> (i32, Value) { + let cwd_s = cwd.to_str().unwrap().to_string(); + let mut args = vec!["rollback", "--json", "--cwd", &cwd_s]; + args.extend_from_slice(extra); + let (code, stdout, stderr) = run_isolated(cwd, &args, &[]); + let doc = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("not JSON ({e}):\n{stdout}\nstderr:\n{stderr}")); + (code, doc) +} + +/// A package-lock.json redirect CREATES `.npmrc` with exactly +/// `allow-remote=all`, records a `created` ledger edit, and warns (JSON + +/// human) with the npm 12 failure, the whole-tree tradeoff and the opt-out; +/// the idempotent re-run records nothing new and still warns (the +/// already-set variant); `rollback` deletes the file it created and +/// restores the lock. +#[tokio::test] +async fn package_lock_redirect_writes_npmrc_warns_and_rollback_removes_it() { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + write_npm_project(tmp.path(), "package-lock.json"); + let pristine = std::fs::read(tmp.path().join("package-lock.json")).unwrap(); + + let (code, doc, _) = scan_hosted(tmp.path(), &server.uri(), &["--json"]); + assert_eq!(code, 0, "{doc:#}"); + assert_eq!(doc["redirect"]["redirected"], 1, "{doc:#}"); + let detail = allow_remote_warning(&doc).unwrap_or_else(|| panic!("no {CODE}: {doc:#}")); + for needle in [ + "patch.test", + "npm >=12", + "EALLOWREMOTE", + "`allow-remote=all` was written to a new project .npmrc", + "lets npm install ANY url-resolved", + "sha512 integrity pins are still enforced", + "--no-npm-allow-remote-config", + ] { + assert!(detail.contains(needle), "{needle:?} missing: {detail}"); + } + assert_eq!( + std::fs::read_to_string(tmp.path().join(".npmrc")).unwrap(), + "allow-remote=all\n" + ); + assert_eq!( + npmrc_edits(tmp.path()), + vec![("created".into(), "allow-remote".into(), "all".into())] + ); + // The `.npmrc` write rides the hosted run's lock window: the lock is + // released (unlinked) when the run ends. + assert!( + !tmp.path().join(".socket/apply.lock").exists(), + "the apply lock never outlives the hosted run" + ); + + // Re-run: nothing to splice, nothing new recorded — still warns. + let (code, doc, _) = scan_hosted(tmp.path(), &server.uri(), &["--json"]); + assert_eq!(code, 0, "{doc:#}"); + let detail = allow_remote_warning(&doc).unwrap_or_else(|| panic!("re-run: {doc:#}")); + assert!( + detail.contains("already sets `allow-remote=all`"), + "{detail}" + ); + assert_eq!(npmrc_edits(tmp.path()).len(), 1, "no duplicate ledger edit"); + + // Human output: the `Warning (): …` line on stderr; --silent mutes it. + let (code, _, stderr) = scan_hosted(tmp.path(), &server.uri(), &[]); + assert_eq!(code, 0, "{stderr}"); + assert!( + stderr.contains(&format!("Warning ({CODE}): ")) && stderr.contains("EALLOWREMOTE"), + "human stderr: {stderr}" + ); + let (code, _, stderr) = scan_hosted(tmp.path(), &server.uri(), &["--silent"]); + assert_eq!(code, 0, "{stderr}"); + assert!(!stderr.contains(CODE), "--silent is errors only: {stderr}"); + + // Rollback: the created .npmrc is deleted with the lock redirect. + let (code, doc) = rollback(tmp.path(), &[]); + assert_eq!(code, 0, "{doc:#}"); + assert!(!tmp.path().join(".npmrc").exists(), "{doc:#}"); + // (The npm writer normalizes the trailing newline; compare the JSON.) + let json = |b: &[u8]| serde_json::from_slice::(b).unwrap(); + assert_eq!( + json(&std::fs::read(tmp.path().join("package-lock.json")).unwrap()), + json(&pristine) + ); + assert!(!tmp + .path() + .join(".socket/vendor/redirect-state.json") + .exists()); + assert!( + !tmp.path().join(".socket").exists(), + "a fully unwound hosted project keeps no .socket/ residue: {doc:#}" + ); +} + +/// An existing `.npmrc` (BOM + CRLF, no allow-remote) gets exactly one +/// appended line in its own line ending; a user edit made AFTER the scan +/// survives rollback, which removes only the appended line. The shrinkwrap +/// flavor is configured the same way. +#[tokio::test] +async fn existing_npmrc_gets_one_line_and_rollback_keeps_user_edits() { + let server = MockServer::start().await; + mock_api(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + write_npm_project(tmp.path(), "npm-shrinkwrap.json"); + let user = "\u{feff}registry=https://r.example/\r\n; team config\r\n"; + std::fs::write(tmp.path().join(".npmrc"), user).unwrap(); + let (code, doc, _) = scan_hosted(tmp.path(), &server.uri(), &["--json"]); + assert_eq!(code, 0, "{doc:#}"); + let detail = allow_remote_warning(&doc).unwrap_or_else(|| panic!("no {CODE}: {doc:#}")); + assert!( + detail.contains("was appended to the existing project .npmrc"), + "{detail}" + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join(".npmrc")).unwrap(), + format!("{user}allow-remote=all\r\n") + ); + assert_eq!( + npmrc_edits(tmp.path()), + vec![("added".into(), "allow-remote".into(), "all".into())] + ); + + // The user keeps editing the file after the scan. + let mut live = std::fs::read_to_string(tmp.path().join(".npmrc")).unwrap(); + live.push_str("fund=false\r\n"); + std::fs::write(tmp.path().join(".npmrc"), &live).unwrap(); + + let (code, doc) = rollback(tmp.path(), &[]); + assert_eq!(code, 0, "{doc:#}"); + assert_eq!( + std::fs::read_to_string(tmp.path().join(".npmrc")).unwrap(), + format!("{user}fund=false\r\n"), + "only the appended line is removed" + ); + // The reversal prunes the emptied `.socket/` — never the user's + // `.npmrc`, which lives outside it and keeps their settings. + assert!( + !tmp.path().join(".socket").exists(), + "a fully unwound hosted project keeps no .socket/ residue: {doc:#}" + ); +} + +/// An explicit other value (`none` / `root`) is RESPECTED — never rewritten, +/// no ledger edit — and named with the manual remedy; an `allow_remote` +/// spelling npm does not honor in `.npmrc` is left alone and the real key +/// appended; an existing `allow-remote=all` is kept (already-set warning). +#[tokio::test] +async fn explicit_values_are_respected_and_unhonored_spellings_are_not_trusted() { + let server = MockServer::start().await; + mock_api(&server).await; + + for value in ["root", "none"] { + let tmp = tempfile::tempdir().unwrap(); + write_npm_project(tmp.path(), "package-lock.json"); + let npmrc = format!("allow-remote=all\nallow-remote={value}\n"); + std::fs::write(tmp.path().join(".npmrc"), &npmrc).unwrap(); + let (code, doc, _) = scan_hosted(tmp.path(), &server.uri(), &["--json"]); + assert_eq!(code, 0, "{doc:#}"); + let detail = allow_remote_warning(&doc).unwrap_or_else(|| panic!("no {CODE}: {doc:#}")); + assert!( + detail.contains(&format!("explicitly sets `allow-remote={value}`")) + && detail.contains("npm ci --allow-remote=all"), + "{detail}" + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join(".npmrc")).unwrap(), + npmrc, + "an explicit user setting is never rewritten" + ); + assert!(npmrc_edits(tmp.path()).is_empty()); + } + + let tmp = tempfile::tempdir().unwrap(); + write_npm_project(tmp.path(), "package-lock.json"); + std::fs::write(tmp.path().join(".npmrc"), "allow_remote=all\n").unwrap(); + let (code, doc, _) = scan_hosted(tmp.path(), &server.uri(), &["--json"]); + assert_eq!(code, 0, "{doc:#}"); + assert_eq!( + std::fs::read_to_string(tmp.path().join(".npmrc")).unwrap(), + "allow_remote=all\nallow-remote=all\n", + "npm 12 ignores `allow_remote` in .npmrc: the real key is appended" + ); + + let tmp = tempfile::tempdir().unwrap(); + write_npm_project(tmp.path(), "package-lock.json"); + std::fs::write(tmp.path().join(".npmrc"), "allow-remote = \"all\"\n").unwrap(); + let (code, doc, _) = scan_hosted(tmp.path(), &server.uri(), &["--json"]); + assert_eq!(code, 0, "{doc:#}"); + assert!( + allow_remote_warning(&doc).is_some_and(|d| d.contains("already sets `allow-remote=all`")) + ); + assert!(npmrc_edits(tmp.path()).is_empty()); + // A user-owned setting survives rollback untouched. + let (code, doc) = rollback(tmp.path(), &[]); + assert_eq!(code, 0, "{doc:#}"); + assert_eq!( + std::fs::read_to_string(tmp.path().join(".npmrc")).unwrap(), + "allow-remote = \"all\"\n" + ); +} + +/// `--no-npm-allow-remote-config` (and its env var) writes nothing and +/// warns with both manual recoveries; `--dry-run` writes nothing but says +/// what it WOULD write; a project with no npm lock stays quiet. +#[tokio::test] +async fn opt_out_dry_run_and_unredirected_projects() { + let server = MockServer::start().await; + mock_api(&server).await; + + for (flags, env) in [ + (vec!["--json", "--no-npm-allow-remote-config"], vec![]), + ( + vec!["--json"], + vec![("SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG", "1")], + ), + ] { + let tmp = tempfile::tempdir().unwrap(); + write_npm_project(tmp.path(), "package-lock.json"); + let cwd_s = tmp.path().to_str().unwrap().to_string(); + let uri = server.uri(); + let mut args = vec![ + "scan", + "--mode", + "hosted", + "--yes", + "--cwd", + &cwd_s, + "--api-url", + &uri, + "--org", + ORG, + "--api-token", + "fake", + ]; + args.extend(flags.iter().copied()); + let (code, stdout, stderr) = run_isolated(tmp.path(), &args, &env); + assert_eq!(code, 0, "{stdout}\n{stderr}"); + let doc: Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(doc["redirect"]["redirected"], 1, "{doc:#}"); + let detail = allow_remote_warning(&doc).unwrap_or_else(|| panic!("no {CODE}: {doc:#}")); + assert!( + detail.contains("Commit `allow-remote=all` in the project .npmrc") + && detail.contains("npm ci --allow-remote=all"), + "{detail}" + ); + assert!( + !tmp.path().join(".npmrc").exists(), + "opt-out writes nothing" + ); + assert!(npmrc_edits(tmp.path()).is_empty()); + } + + let tmp = tempfile::tempdir().unwrap(); + write_npm_project(tmp.path(), "package-lock.json"); + let before = std::fs::read(tmp.path().join("package-lock.json")).unwrap(); + let (code, doc, _) = scan_hosted(tmp.path(), &server.uri(), &["--json", "--dry-run"]); + assert_eq!(code, 0, "{doc:#}"); + let detail = allow_remote_warning(&doc).unwrap_or_else(|| panic!("dry run: {doc:#}")); + assert!( + detail.contains("would be written to a new project .npmrc") + && !detail.contains("(--dry-run)"), + "{detail}" + ); + assert_eq!( + std::fs::read(tmp.path().join("package-lock.json")).unwrap(), + before, + "dry run leaves the lock untouched" + ); + assert!( + !tmp.path().join(".npmrc").exists(), + "dry run writes no .npmrc" + ); + assert!( + !tmp.path().join(".socket").exists(), + "a dry run never locks, so it creates no .socket/ either" + ); + + let nolock = tempfile::tempdir().unwrap(); + write_npm_project(nolock.path(), "package-lock.json"); + std::fs::remove_file(nolock.path().join("package-lock.json")).unwrap(); + let (_, doc, _) = scan_hosted(nolock.path(), &server.uri(), &["--json"]); + assert_eq!(allow_remote_warning(&doc), None, "{doc:#}"); + assert!(!nolock.path().join(".npmrc").exists()); +} + +/// A symlinked `.npmrc` is never written through (nor does it trip the +/// whole-run symlink guard): the redirect lands, the link is untouched, and +/// the warning names the manual remedy. +#[cfg(unix)] +#[tokio::test] +async fn symlinked_npmrc_is_left_alone() { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + write_npm_project(tmp.path(), "package-lock.json"); + std::fs::write(tmp.path().join("shared.npmrc"), "fund=false\n").unwrap(); + std::os::unix::fs::symlink("shared.npmrc", tmp.path().join(".npmrc")).unwrap(); + let (code, doc, _) = scan_hosted(tmp.path(), &server.uri(), &["--json"]); + assert_eq!(code, 0, "{doc:#}"); + assert_eq!(doc["redirect"]["redirected"], 1, "{doc:#}"); + let detail = allow_remote_warning(&doc).unwrap_or_else(|| panic!("no {CODE}: {doc:#}")); + assert!(detail.contains("symbolic link") && detail.contains("npm ci --allow-remote=all")); + assert_eq!( + std::fs::read_to_string(tmp.path().join("shared.npmrc")).unwrap(), + "fund=false\n" + ); + assert!(npmrc_edits(tmp.path()).is_empty()); +} + +/// Review findings, end to end: +/// - a CR-only `.npmrc` with an explicit `allow-remote=none` (npm splits on +/// a bare `\r`) is respected, never flipped by an appended line; +/// - an indented `[sec]` is NOT a section header to npm, so the `none` +/// below it is respected (writing above it would not have taken effect); +/// - a CR-only file without the key is never spliced (manual remedy); +/// - a section-scoped `allow-remote=all` (inert to npm) gets our top-level +/// line, and rollback removes exactly ours instead of refusing the two +/// copies as ambiguous. +#[tokio::test] +async fn npm_ini_line_and_section_rules_are_honored() { + let server = MockServer::start().await; + mock_api(&server).await; + + for npmrc in [ + "registry=https://r.example/\rallow-remote=none\r", + " [sec]\nallow-remote=none\n", + ] { + let tmp = tempfile::tempdir().unwrap(); + write_npm_project(tmp.path(), "package-lock.json"); + std::fs::write(tmp.path().join(".npmrc"), npmrc).unwrap(); + let (code, doc, _) = scan_hosted(tmp.path(), &server.uri(), &["--json"]); + assert_eq!(code, 0, "{doc:#}"); + let detail = allow_remote_warning(&doc).unwrap_or_else(|| panic!("no {CODE}: {doc:#}")); + assert!( + detail.contains("explicitly sets `allow-remote=none`"), + "{npmrc:?}: {detail}" + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join(".npmrc")).unwrap(), + npmrc, + "an explicit value is never flipped" + ); + assert!(npmrc_edits(tmp.path()).is_empty()); + } + + let tmp = tempfile::tempdir().unwrap(); + write_npm_project(tmp.path(), "package-lock.json"); + let cr_only = "registry=https://r.example/\rfund=false\r"; + std::fs::write(tmp.path().join(".npmrc"), cr_only).unwrap(); + let (code, doc, _) = scan_hosted(tmp.path(), &server.uri(), &["--json"]); + assert_eq!(code, 0, "{doc:#}"); + let detail = allow_remote_warning(&doc).unwrap_or_else(|| panic!("no {CODE}: {doc:#}")); + assert!( + detail.contains("bare carriage-return") && detail.contains("npm ci --allow-remote=all"), + "{detail}" + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join(".npmrc")).unwrap(), + cr_only + ); + assert!(npmrc_edits(tmp.path()).is_empty()); + + let tmp = tempfile::tempdir().unwrap(); + write_npm_project(tmp.path(), "package-lock.json"); + let sectioned = "[sec]\nallow-remote=all\n"; + std::fs::write(tmp.path().join(".npmrc"), sectioned).unwrap(); + let (code, doc, _) = scan_hosted(tmp.path(), &server.uri(), &["--json"]); + assert_eq!(code, 0, "{doc:#}"); + assert_eq!( + std::fs::read_to_string(tmp.path().join(".npmrc")).unwrap(), + format!("allow-remote=all\n{sectioned}") + ); + let (code, doc) = rollback(tmp.path(), &[]); + assert_eq!( + code, 0, + "the section copy must not make the unwind ambiguous: {doc:#}" + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join(".npmrc")).unwrap(), + sectioned + ); +} + +/// Review finding: only the project `.npmrc` was consulted. An explicit +/// `allow-remote` in the env (which beats the project file) or in the +/// user / global npm config (a machine / org policy a committed project +/// line would silently override) is now respected — nothing written, no +/// ledger edit — and the warning names the source and the remedy. +#[tokio::test] +async fn outer_npm_config_layers_are_respected() { + let server = MockServer::start().await; + mock_api(&server).await; + + // user config (relocated the way npm allows: NPM_CONFIG_USERCONFIG). + let tmp = tempfile::tempdir().unwrap(); + let cfg = tempfile::tempdir().unwrap(); + write_npm_project(tmp.path(), "package-lock.json"); + let user = cfg.path().join("user.npmrc"); + std::fs::write(&user, "allow-remote=none\n").unwrap(); + let user_s = user.to_str().unwrap(); + let (code, doc, _) = scan_hosted_env( + tmp.path(), + &server.uri(), + &["--json"], + &[ + ("NPM_CONFIG_USERCONFIG", user_s), + ("npm_config_userconfig", user_s), + ], + ); + assert_eq!(code, 0, "{doc:#}"); + assert_eq!(doc["redirect"]["redirected"], 1, "{doc:#}"); + let detail = allow_remote_warning(&doc).unwrap_or_else(|| panic!("no {CODE}: {doc:#}")); + assert!( + detail.contains("The user npm config") + && detail.contains(user_s) + && detail.contains("explicitly sets `allow-remote=none`") + && detail.contains("npm ci --allow-remote=all"), + "{detail}" + ); + assert!( + !tmp.path().join(".npmrc").exists(), + "no project override written" + ); + assert!(npmrc_edits(tmp.path()).is_empty()); + + // global config under /etc/npmrc, the prefix relocated the + // way npm allows from the env (`npm_config_prefix` — it outranks the + // builtin config's `prefix`, e.g. the Windows installer's + // `${APPDATA}\npm`, and `PREFIX`, the default-only fallback the core + // unit tests pin). + let tmp = tempfile::tempdir().unwrap(); + write_npm_project(tmp.path(), "package-lock.json"); + let prefix = cfg.path().join("prefix"); + std::fs::create_dir_all(prefix.join("etc")).unwrap(); + std::fs::write(prefix.join("etc").join("npmrc"), "allow-remote=root\n").unwrap(); + let prefix_s = prefix.to_str().unwrap(); + let (code, doc, _) = scan_hosted_env( + tmp.path(), + &server.uri(), + &["--json"], + &[ + ("NPM_CONFIG_PREFIX", prefix_s), + ("npm_config_prefix", prefix_s), + ("NPM_CONFIG_GLOBALCONFIG", ""), + ("npm_config_globalconfig", ""), + ], + ); + assert_eq!(code, 0, "{doc:#}"); + let detail = allow_remote_warning(&doc).unwrap_or_else(|| panic!("no {CODE}: {doc:#}")); + assert!( + detail.contains("The global npm config") && detail.contains("allow-remote=root"), + "{detail}" + ); + assert!(!tmp.path().join(".npmrc").exists()); + + // env beats every file — even an already-configured project. + let tmp = tempfile::tempdir().unwrap(); + write_npm_project(tmp.path(), "package-lock.json"); + let (code, _, stderr) = scan_hosted_env( + tmp.path(), + &server.uri(), + &[], + &[("npm_config_allow_remote", "none")], + ); + assert_eq!(code, 0, "{stderr}"); + assert!( + stderr.contains(&format!("Warning ({CODE}): ")) + && stderr.contains("npm_config_allow_remote=none") + && stderr.contains("would not take effect"), + "{stderr}" + ); + assert!(!tmp.path().join(".npmrc").exists()); + + // An outer `all` never blocks the write. + let tmp = tempfile::tempdir().unwrap(); + write_npm_project(tmp.path(), "package-lock.json"); + std::fs::write(&user, "allow-remote=all\n").unwrap(); + let (code, doc, _) = scan_hosted_env( + tmp.path(), + &server.uri(), + &["--json"], + &[ + ("NPM_CONFIG_USERCONFIG", user_s), + ("npm_config_userconfig", user_s), + ], + ); + assert_eq!(code, 0, "{doc:#}"); + assert_eq!( + std::fs::read_to_string(tmp.path().join(".npmrc")).unwrap(), + "allow-remote=all\n" + ); +} + +/// Review finding: `remove` dropped the hosted leg's warnings, so a +/// redirect-created `.npmrc` the user had since added to was rewritten +/// with no `redirect_npmrc_allow_remote_modified` (CLI_CONTRACT promises +/// it in rollback/remove `warnings[]`). Human stderr and JSON both carry it. +#[tokio::test] +async fn remove_surfaces_the_npmrc_modified_warning() { + let server = MockServer::start().await; + mock_api(&server).await; + for json in [true, false] { + let tmp = tempfile::tempdir().unwrap(); + write_npm_project(tmp.path(), "package-lock.json"); + let (code, doc, _) = scan_hosted(tmp.path(), &server.uri(), &["--json"]); + assert_eq!(code, 0, "{doc:#}"); + std::fs::write(tmp.path().join(".npmrc"), "allow-remote=all\nfund=false\n").unwrap(); + + let cwd_s = tmp.path().to_str().unwrap().to_string(); + let mut args = vec!["remove", PURL, "--yes", "--cwd", &cwd_s]; + if json { + args.push("--json"); + } + let (code, stdout, stderr) = run_isolated(tmp.path(), &args, &[]); + assert_eq!(code, 0, "{stdout}\n{stderr}"); + if json { + let doc: Value = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("not JSON ({e}):\n{stdout}\n{stderr}")); + assert!( + doc["warnings"] + .as_array() + .into_iter() + .flatten() + .any(|w| w["code"] == "redirect_npmrc_allow_remote_modified"), + "{doc:#}" + ); + assert!( + !stderr.contains("Warning ("), + "--json keeps stderr quiet: {stderr}" + ); + } else { + assert!( + stderr.contains("Warning (redirect_npmrc_allow_remote_modified): "), + "{stderr}" + ); + } + assert_eq!( + std::fs::read_to_string(tmp.path().join(".npmrc")).unwrap(), + "fund=false\n" + ); + } +} diff --git a/crates/socket-patch-cli/tests/remove_rollback_api_overrides.rs b/crates/socket-patch-cli/tests/remove_rollback_api_overrides.rs index 7433b474..5db30e08 100644 --- a/crates/socket-patch-cli/tests/remove_rollback_api_overrides.rs +++ b/crates/socket-patch-cli/tests/remove_rollback_api_overrides.rs @@ -58,6 +58,7 @@ const SOCKET_ENV_VARS: &[&str] = &[ "SOCKET_ONE_OFF", "SOCKET_SKIP_ROLLBACK", "SOCKET_NO_TRUST_LOCKFILE_CONFIG", + "SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG", ]; /// Drift guard: the scrub must cover every env var `GlobalArgs` binds — the diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 685d11d1..f236dca8 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -28,6 +28,7 @@ use crate::vendor::yarn_berry_lock::yarnrc_compression_level; mod bun_binary; pub use bun_binary::{preflight_bun_binary, rewrite_bun_binary}; pub mod golang_local; +pub mod npmrc; mod pdm; mod pipenv; mod pnpm; diff --git a/crates/socket-patch-core/src/patch/redirect/npmrc.rs b/crates/socket-patch-core/src/patch/redirect/npmrc.rs new file mode 100644 index 00000000..8c9e8034 --- /dev/null +++ b/crates/socket-patch-core/src/patch/redirect/npmrc.rs @@ -0,0 +1,1660 @@ +//! The hosted npm flow's project `.npmrc` `allow-remote=all` auto-config — +//! the npm twin of the pnpm `trustLockfile` auto-config. +//! +//! npm >= 12 defaults `allow-remote=none` and refuses (EALLOWREMOTE) every +//! lockfile entry whose `resolved` tarball URL is not served by the +//! configured registry — exactly what a hosted redirect writes. The hosted +//! flow therefore ensures `allow-remote=all` in the project `.npmrc` +//! (creating the file, or appending one line) and records the edit in the +//! redirect ledger under [`NPMRC_ALLOW_REMOTE_EDIT_KIND`], so every unwind +//! path (rollback / remove replay, the per-purl npm revert behind scoped +//! rollback and the vendored takeover, the vendored-supersedes-hosted +//! reconcile) removes exactly what was added once no redirected npm lock +//! entry needs it any more. +//! +//! The `.npmrc` grammar here is npm's as MEASURED against npm 12.1.0 +//! (`npm config get allow-remote` plus a real EALLOWREMOTE/ENOTFOUND install +//! probe), not a guess: the key must be spelled exactly `allow-remote` — +//! `allow_remote` and `ALLOW-REMOTE` are NOT honored in a `.npmrc` file (npm +//! only normalizes `npm_config_*` environment variables); leading/trailing +//! whitespace and a UTF-8 BOM around the key are ignored; `;` / `#` start a +//! comment line; values may be quoted and carry an inline `;`/`#` comment; +//! the LAST top-level assignment wins; assignments under an ini `[section]` +//! header are not top-level config; CRLF line endings are accepted. The +//! value is compared case-SENSITIVELY: npm's gate (`pacote` `canUse`) +//! admits every remote tarball only for the exact string `all` (`All` +//! behaves like `root`). +//! +//! Tokenization follows npm's bundled `ini` parser exactly (the unit tests +//! pin a differential corpus against it): lines split on any run of `\r` / +//! `\n` (a bare `\r` ends a line), keys and values go through ini's +//! `unsafe()` decode, and a section header is `^\[[^\]]*\]\s*$` on the +//! UNTRIMMED line (an indented or BOM-prefixed `[sec]` is a top-level key). +//! +//! An explicit non-`all` value is respected wherever npm would read it: the +//! project file, an `npm_config_allow_remote` env var (beats every file), +//! and — when the project file is silent — the user / global / builtin +//! config files ([`resolve_outer_allow_remote`]). + +use std::collections::HashSet; + +use super::FileEdit; + +/// Repo-relative path of the project `.npmrc` the auto-config edits. +pub const NPMRC_REL: &str = ".npmrc"; + +/// `FileEdit.kind` recorded when the hosted flow ensures `allow-remote=all` +/// in the project `.npmrc`. `action: "created"` — the file itself was +/// created (an unwind deletes it while it still holds exactly +/// [`NPMRC_CREATED`]); `action: "added"` — the single +/// [`NPMRC_ALLOW_REMOTE_LINE`] line was spliced into an existing file (an +/// unwind removes exactly that line). `key` is `"allow-remote"`, `new` the +/// VALUE `"all"`. Additive ledger vocabulary: older ledgers load unchanged. +pub const NPMRC_ALLOW_REMOTE_EDIT_KIND: &str = "redirect_npmrc_allow_remote"; + +/// The line the auto-config writes (and the only line an unwind removes). +pub const NPMRC_ALLOW_REMOTE_LINE: &str = "allow-remote=all"; + +/// The exact `.npmrc` the auto-config CREATES when none existed. +pub const NPMRC_CREATED: &str = "allow-remote=all\n"; + +/// The ledger kinds that record a package-lock.json / npm-shrinkwrap.json +/// hosted splice — the entries that NEED `allow-remote=all` on npm >= 12. +/// While any of them remains in the ledger the `.npmrc` edit stays. +pub const NPM_LOCK_EDIT_KINDS: [&str; 2] = ["redirect_npm_lock_entry", "redirect_npm_lock_dep"]; + +const BOM: char = '\u{feff}'; + +/// ECMAScript whitespace — what npm's `ini` means by `\s` and by +/// `String.prototype.trim` (WhiteSpace + LineTerminator). Rust's +/// `char::is_whitespace` differs only by U+0085 (NEL: not JS whitespace) +/// and U+FEFF (the BOM: JS whitespace). +fn is_js_ws(c: char) -> bool { + c == BOM || (c.is_whitespace() && c != '\u{85}') +} + +fn js_trim(s: &str) -> &str { + s.trim_matches(is_js_ws) +} + +/// npm `ini`'s line tokenization: `str.split(/[\r\n]+/)` — a bare `\r` +/// ends a line just like `\n` (empty pieces are skipped by the parser). +fn ini_lines(text: &str) -> impl Iterator { + text.split(['\r', '\n']).filter(|l| !l.is_empty()) +} + +/// A `\r` that is not the first half of a `\r\n` pair: npm ends a line +/// there, but the line-splice writer below works on `\n`-terminated lines +/// (with an optional CRLF `\r`), so such a file is never rewritten. +fn has_lone_cr(text: &str) -> bool { + let bytes = text.as_bytes(); + bytes + .iter() + .enumerate() + .any(|(i, &b)| b == b'\r' && bytes.get(i + 1) != Some(&b'\n')) +} + +/// npm `ini`'s section header — `^\[([^\]]*)\]\s*$` matched against the +/// UNTRIMMED line: an indented ` [sec]` or a BOM-prefixed `\u{feff}[sec]` +/// is NOT a header to npm (it parses as a top-level key), so it must not +/// end the top-level scope here either. +fn is_section_header(line: &str) -> bool { + let Some(rest) = line.strip_prefix('[') else { + return false; + }; + rest.find(']') + .is_some_and(|i| rest[i + 1..].chars().all(is_js_ws)) +} + +/// Does this `\n`-split line (maybe carrying a CRLF `\r` — or, in a file +/// with lone `\r`s, several npm lines) hold a real section header? `line0` +/// is true for the file's first line, which carries the BOM `bom` the +/// callers strip off before splitting (npm does NOT strip it, so a +/// `\u{feff}[sec]` first line is no header). +fn holds_section_header(bom: &str, line: &str, line0: bool) -> bool { + let owned; + let line = if line0 && !bom.is_empty() { + owned = format!("{bom}{line}"); + owned.as_str() + } else { + line + }; + line.split('\r').any(is_section_header) +} + +/// Index of the first `\n`-split line that opens an ini section (every +/// line before it is npm top-level config), or `lines.len()`. +fn top_level_end(bom: &str, lines: &[&str]) -> usize { + lines + .iter() + .enumerate() + .position(|(i, l)| holds_section_header(bom, l, i == 0)) + .unwrap_or(lines.len()) +} + +/// npm `ini`'s `unsafe()` value/key decode: JS-trimmed; a `"…"` value is +/// `JSON.parse`d (kept verbatim when that fails); a `'…'` value loses its +/// quotes and is then `JSON.parse`d the same way; an unquoted value is cut +/// at its first unescaped `;` / `#` (`\;` / `\#` are literal) and trimmed. +fn ini_unsafe(raw: &str) -> String { + let v = js_trim(raw); + let quoted = + (v.starts_with('"') && v.ends_with('"')) || (v.starts_with('\'') && v.ends_with('\'')); + if quoted { + let candidate = if v.starts_with('\'') { + if v.len() >= 2 { + &v[1..v.len() - 1] + } else { + "" + } + } else { + v + }; + return match serde_json::from_str::(candidate) { + Ok(serde_json::Value::String(s)) => s, + Ok(other) => other.to_string(), + Err(_) => candidate.to_string(), + }; + } + let mut out = String::new(); + let mut esc = false; + for c in v.chars() { + if esc { + if !matches!(c, '\\' | ';' | '#') { + out.push('\\'); + } + out.push(c); + esc = false; + } else if c == ';' || c == '#' { + break; + } else if c == '\\' { + esc = true; + } else { + out.push(c); + } + } + if esc { + out.push('\\'); + } + js_trim(&out).to_string() +} + +/// The value an npm config FILE sets for `key` at top level, parsed the +/// way npm's `ini` does (see the module doc): `None` when it sets none. +/// The LAST assignment wins; a `key[]` array assignment makes the value an +/// array (reported as `[a, b]` — never the plain string a caller compares +/// against, so it reads as an explicit non-default value). +pub fn npmrc_top_level_value(npmrc: &str, key: &str) -> Option { + enum Val { + Scalar(String), + Array(Vec), + } + let array_key = format!("{key}[]"); + let mut value: Option = None; + for line in ini_lines(npmrc) { + let lead = line.trim_start_matches(is_js_ws); + if lead.is_empty() || lead.starts_with(';') || lead.starts_with('#') { + continue; + } + if is_section_header(line) { + // Every later key belongs to a section, never to top level. + break; + } + // `^([^=]+)(=(.*))?$`: at least one key character before the first + // `=`; `.` never matches U+2028/U+2029, so such a value fails the + // whole match and npm skips the line. + let (raw_key, raw_value) = match line.split_once('=') { + Some(("", _)) => continue, + Some((k, v)) => (k, Some(v)), + None => (line, None), + }; + if raw_value.is_some_and(|v| v.contains(['\u{2028}', '\u{2029}'])) { + continue; + } + let k = ini_unsafe(raw_key); + let is_array = k == array_key; + if k != key && !is_array { + continue; + } + let v = raw_value.map_or_else(|| "true".to_string(), ini_unsafe); + value = Some(match (value.take(), is_array) { + (Some(Val::Array(mut items)), _) => { + items.push(v); + Val::Array(items) + } + (Some(Val::Scalar(prev)), true) => Val::Array(vec![prev, v]), + (None, true) => Val::Array(vec![v]), + (_, false) => Val::Scalar(v), + }); + } + value.map(|v| match v { + Val::Scalar(s) => s, + Val::Array(items) => format!("[{}]", items.join(", ")), + }) +} + +/// The `allow-remote` value a project `.npmrc` sets at top level (the LAST +/// assignment wins, like npm's ini parser), or `None` when it sets none. +/// See the module doc for the measured grammar. +pub fn npmrc_allow_remote(npmrc: &str) -> Option { + npmrc_top_level_value(npmrc, "allow-remote") +} + +/// The planned project `.npmrc` edit. +#[derive(Debug, PartialEq)] +pub enum NpmrcPlan { + /// No `.npmrc`: create it holding exactly [`NPMRC_CREATED`]. + Create(String), + /// `.npmrc` exists without a top-level `allow-remote` assignment: the + /// full new text, with exactly one [`NPMRC_ALLOW_REMOTE_LINE`] line + /// spliced in (after the last non-empty top-level line — before any + /// `[section]` header — in the file's own line ending); every other byte + /// (BOM, CRLF, trailing-newline shape) preserved. + Append(String), + /// Already resolves to `allow-remote=all` — nothing to write. + AlreadyAll, + /// The user explicitly set `allow-remote=` (not `all`). Their + /// call is respected — flipping an explicit security setting behind the + /// user's back is worse than a failing install with a clear warning + /// (the pnpm `trustLockfile: false` precedent). + UserSet(String), + /// An `npm_config_allow_remote` environment variable (`var`, any + /// spelling npm normalizes to the key) sets a non-`all` value. The + /// environment layer beats every `.npmrc`, so a project write could not + /// take effect here — and an explicit setting is respected anyway. + EnvSet { var: String, value: String }, + /// No project assignment, but a lower npm config layer — user + /// (`~/.npmrc`), global (`$PREFIX/etc/npmrc`) or builtin — explicitly + /// sets a non-`all` value. A committed project `allow-remote=all` would + /// silently override that machine/org policy for every checkout, so it + /// is respected like a project value. + OuterSet { + layer: &'static str, + path: std::path::PathBuf, + value: String, + }, + /// The existing file cannot be spliced safely (e.g. bare-`\r` line + /// endings npm splits on but the line writer does not): left alone, + /// the reason is surfaced with the manual remedy. + Unsupported(String), +} + +/// The npm config layers OUTSIDE the project `.npmrc` that can set +/// `allow-remote` (see [`resolve_outer_allow_remote`]). +#[derive(Debug, Default, Clone, PartialEq)] +pub struct OuterAllowRemote { + /// `(variable, value)` of an `npm_config_allow_remote` env var (beats + /// every `.npmrc`). + pub env: Option<(String, String)>, + /// The highest-precedence explicit value among the user, global and + /// builtin config files (all below the project `.npmrc`). + pub file: Option, +} + +/// One explicit `allow-remote` assignment in a non-project npm config file. +#[derive(Debug, Clone, PartialEq)] +pub struct OuterFileValue { + /// `"user"`, `"global"` or `"builtin"`. + pub layer: &'static str, + pub path: std::path::PathBuf, + pub value: String, +} + +/// The process facts npm itself uses to locate its config layers +/// (`@npmcli/config`): environment, home directory, and the node binary +/// (npm derives the default global prefix from `process.execPath`, and its +/// own install root — the builtin config's home — sits beside it). +#[derive(Debug, Default, Clone)] +pub struct NpmConfigEnv { + pub vars: Vec<(String, String)>, + pub home: Option, + /// The resolved (symlink-free) node executable, when found on PATH. + pub node_exe: Option, + /// Resolve like npm on Windows: env names case-insensitive, the default + /// prefix is `node.exe`'s own directory, `~\` expands. + pub windows: bool, +} + +impl NpmConfigEnv { + /// Snapshot this process's environment. + pub fn from_process() -> Self { + let vars: Vec<(String, String)> = std::env::vars_os() + .filter_map(|(k, v)| Some((k.into_string().ok()?, v.into_string().ok()?))) + .collect(); + let windows = cfg!(windows); + let node_exe = crate::utils::process::resolve_tool("node") + .map(|p| std::fs::canonicalize(&p).unwrap_or(p)) + .map(|p| strip_verbatim(p, windows)); + Self::from_parts(vars, node_exe, windows) + } + + /// Build from injected facts, deriving `home` the way npm does: + /// `env.HOME || os.homedir()` — `os.homedir()` being `USERPROFILE` on + /// Windows (on Unix it re-reads `HOME`, so the fallback is inert there). + pub fn from_parts( + vars: Vec<(String, String)>, + node_exe: Option, + windows: bool, + ) -> Self { + let mut env = Self { + vars, + home: None, + node_exe, + windows, + }; + env.home = env + .var("HOME") + .or_else(|| env.var("USERPROFILE")) + .map(std::path::PathBuf::from); + env + } + + /// A variable as npm's `process.env[name]` reads it: exact case on + /// Unix, case-insensitive on Windows. `Some("")` when set but empty. + fn var_raw(&self, name: &str) -> Option<&str> { + self.vars + .iter() + .find(|(k, _)| { + if self.windows { + k.eq_ignore_ascii_case(name) + } else { + k == name + } + }) + .map(|(_, v)| v.as_str()) + } + + /// [`Self::var_raw`] with empty read as unset (npm tests `PREFIX` / + /// `DESTDIR` / `HOME` for truthiness). + fn var(&self, name: &str) -> Option<&str> { + self.var_raw(name).filter(|v| !v.is_empty()) + } + + /// `@npmcli/config`'s `env-replace`, applied to every config value: + /// `${NAME}` becomes the variable (left verbatim when unset), `${NAME?}` + /// becomes it or empty; an odd run of backslashes before `$` escapes + /// the expression (half the run, rounded down, is kept), an even run + /// is halved. E.g. the Windows installer's builtin `prefix=${APPDATA}\npm`. + fn env_replace(&self, value: &str) -> String { + let bytes = value.as_bytes(); + let mut out = String::with_capacity(value.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] != b'\\' && bytes[i] != b'$' { + let next = value[i..].find(['\\', '$']).map_or(value.len(), |n| i + n); + out.push_str(&value[i..next]); + i = next; + continue; + } + // A backslash run (possibly empty) then `${name}` / `${name?}`, + // `name` free of `$ { } ?`. A failed match emits the whole run: + // no position inside it may start a match (npm's lookbehind). + let run_end = i + value[i..].len() - value[i..].trim_start_matches('\\').len(); + let expr = value[run_end..].strip_prefix("${").and_then(|rest| { + let close = rest.find('}')?; + let inner = &rest[..close]; + let (name, optional) = match inner.strip_suffix('?') { + Some(name) => (name, true), + None => (inner, false), + }; + (!name.is_empty() && !name.contains(['$', '{', '}', '?'])).then_some(( + name, + optional, + run_end + 2 + close + 1, + )) + }); + let Some((name, optional, end)) = expr else { + let stop = if run_end > i { run_end } else { i + 1 }; + out.push_str(&value[i..stop]); + i = stop; + continue; + }; + let esc = run_end - i; + if esc % 2 == 1 { + out.push_str(&value[i + esc.div_ceil(2)..end]); + } else { + out.push_str(&value[i..i + esc / 2]); + match self.var_raw(name) { + Some(v) => out.push_str(v), + None if optional => {} + None => out.push_str(&value[run_end..end]), + } + } + i = end; + } + out + } + + /// A `path`-typed config value as npm's `parseField` reads it: trimmed, + /// env-replaced, then a leading `~/` (also `~\` on Windows) expands + /// against [`Self::home`]. + fn config_path(&self, value: &str) -> std::path::PathBuf { + let value = self.env_replace(value.trim_matches(is_js_ws)); + let rest = value + .strip_prefix("~/") + .or_else(|| value.strip_prefix("~\\").filter(|_| self.windows)); + match (rest, &self.home) { + (Some(rest), Some(home)) => home.join(rest), + _ => std::path::PathBuf::from(value), + } + } + + /// An `npm_config_*` variable, matched the way npm's `loadEnv` does: + /// prefix case-insensitive, then non-leading `_` → `-` and lowercased. + /// Empty values are ignored (npm skips them). When several spellings + /// are set a non-`all` one wins (process env order is unspecified, so + /// the conservative reading is reported). + fn npm_config(&self, key: &str) -> Option<(String, String)> { + let mut found: Option<(String, String)> = None; + for (k, v) in &self.vars { + let Some(rest) = k + .get(..11) + .filter(|p| p.eq_ignore_ascii_case("npm_config_")) + .map(|_| &k[11..]) + else { + continue; + }; + if v.is_empty() { + continue; + } + let mut norm = String::with_capacity(rest.len()); + for (i, c) in rest.chars().enumerate() { + norm.push(if c == '_' && i > 0 { + '-' + } else { + c.to_ascii_lowercase() + }); + } + if norm == key && found.as_ref().is_none_or(|(_, prev)| prev == "all") { + found = Some((k.clone(), v.clone())); + } + } + found + } +} + +/// Drop Windows' verbatim `\\?\` prefix `canonicalize` adds to a drive +/// path, so config paths read (and are reported) as npm prints them. +fn strip_verbatim(path: std::path::PathBuf, windows: bool) -> std::path::PathBuf { + if !windows { + return path; + } + match path.to_str().and_then(|s| s.strip_prefix(r"\\?\")) { + Some(rest) if rest.as_bytes().get(1) == Some(&b':') => rest.into(), + _ => path, + } +} + +/// Resolve the `allow-remote` assignments npm would see OUTSIDE the project +/// `.npmrc`, following `@npmcli/config`'s layer order (default < builtin < +/// global < user < project < env < cli) and file-location rules: +/// builtin = `npmrc` in npm's own install root, beside the node binary +/// (`/lib/node_modules/npm` on Unix, `/node_modules/npm` on +/// Windows — `PREFIX` / `DESTDIR` never move it); user = +/// `npm_config_userconfig` (else builtin `userconfig`, else `~/.npmrc`); +/// global = `npm_config_globalconfig` (else user/builtin `globalconfig`, +/// else `/etc/npmrc`, the prefix from `npm_config_prefix`, +/// user/builtin `prefix`, `PREFIX`, or the node binary's install root — +/// `dirname(dirname(node))`, `dirname(node)` on Windows, `DESTDIR`-rooted +/// on Unix). Path values are env-replaced and `~`-expanded like npm's +/// `parseField`; on Windows env names match case-insensitively. Best-effort: +/// `read` returning `None` (absent / unreadable) reads as "sets nothing", +/// like npm. +pub fn resolve_outer_allow_remote( + env: &NpmConfigEnv, + read: impl Fn(&std::path::Path) -> Option, +) -> OuterAllowRemote { + use std::path::{Path, PathBuf}; + let home = env.home.as_deref(); + // The directory holding node (Windows) / its `bin` parent (Unix). + let node_root: Option = env.node_exe.as_deref().and_then(|node| { + let bin = node.parent()?; + Some(if env.windows { bin } else { bin.parent()? }.to_path_buf()) + }); + let default_prefix: Option = env.var("PREFIX").map(PathBuf::from).or_else(|| { + let root = node_root.clone()?; + Some(match env.var("DESTDIR").filter(|_| !env.windows) { + Some(dest) => Path::new(dest).join(root.strip_prefix("/").unwrap_or(&root)), + None => root, + }) + }); + let builtin_path = node_root.map(|root| { + if env.windows { + root.join("node_modules").join("npm").join("npmrc") + } else { + root.join("lib") + .join("node_modules") + .join("npm") + .join("npmrc") + } + }); + let builtin_text = builtin_path.as_deref().and_then(&read); + let env_path = |key: &str| env.npm_config(key).map(|(_, v)| env.config_path(&v)); + let file_value = |text: &Option, key: &str| { + text.as_deref() + .and_then(|t| npmrc_top_level_value(t, key)) + .map(|v| env.config_path(&v)) + }; + let user_path = env_path("userconfig") + .or_else(|| file_value(&builtin_text, "userconfig")) + .or_else(|| home.map(|h| h.join(".npmrc"))); + let user_text = user_path.as_deref().and_then(&read); + let global_path = env_path("globalconfig") + .or_else(|| file_value(&user_text, "globalconfig")) + .or_else(|| file_value(&builtin_text, "globalconfig")) + .or_else(|| { + env_path("prefix") + .or_else(|| file_value(&user_text, "prefix")) + .or_else(|| file_value(&builtin_text, "prefix")) + .or_else(|| default_prefix.clone()) + .map(|prefix| prefix.join("etc").join("npmrc")) + }); + let global_text = global_path.as_deref().and_then(&read); + let file = [ + ("user", user_path, user_text), + ("global", global_path, global_text), + ("builtin", builtin_path, builtin_text), + ] + .into_iter() + .find_map(|(layer, path, text)| { + let value = npmrc_allow_remote(text.as_deref()?)?; + Some(OuterFileValue { + layer, + path: path?, + value, + }) + }); + OuterAllowRemote { + env: env.npm_config("allow-remote"), + file, + } +} + +/// Decide how to ensure `allow-remote=all` in the project `.npmrc`, +/// considering only the project file (no outer npm config layers). Line +/// splices only: untouched lines stay byte-identical, so an unwind can +/// remove exactly what was added. +pub fn plan_npmrc_allow_remote(existing: Option<&str>) -> NpmrcPlan { + plan_npmrc_allow_remote_with(existing, &OuterAllowRemote::default()) +} + +/// [`plan_npmrc_allow_remote`] with the npm config layers outside the +/// project file: an env `npm_config_allow_remote` that is not `all` +/// ([`NpmrcPlan::EnvSet`]) beats everything; then the project value; then +/// an explicit non-`all` user / global / builtin value +/// ([`NpmrcPlan::OuterSet`]). Every explicit value is respected, never +/// overridden by a write. +pub fn plan_npmrc_allow_remote_with(existing: Option<&str>, outer: &OuterAllowRemote) -> NpmrcPlan { + if let Some((var, value)) = &outer.env { + if value != "all" { + return NpmrcPlan::EnvSet { + var: var.clone(), + value: value.clone(), + }; + } + } + match existing.and_then(npmrc_allow_remote) { + Some(v) if v == "all" => return NpmrcPlan::AlreadyAll, + Some(v) => return NpmrcPlan::UserSet(v), + None => {} + } + if let Some(file) = &outer.file { + if file.value != "all" { + return NpmrcPlan::OuterSet { + layer: file.layer, + path: file.path.clone(), + value: file.value.clone(), + }; + } + } + let Some(text) = existing else { + return NpmrcPlan::Create(NPMRC_CREATED.to_string()); + }; + if has_lone_cr(text) { + return NpmrcPlan::Unsupported( + "uses bare carriage-return (CR-only) line endings, which socket-patch does not \ + rewrite" + .into(), + ); + } + let (bom, body) = match text.strip_prefix(BOM) { + Some(rest) => (&text[..BOM.len_utf8()], rest), + None => ("", text), + }; + let crlf = body.contains("\r\n"); + let line = if crlf { + format!("{NPMRC_ALLOW_REMOTE_LINE}\r") + } else { + NPMRC_ALLOW_REMOTE_LINE.to_string() + }; + let mut lines: Vec<&str> = body.split('\n').collect(); + let end = top_level_end(bom, &lines); + let anchor = lines[..end] + .iter() + .rposition(|l| !l.trim().is_empty()) + .map_or(0, |i| i + 1); + lines.insert(anchor, &line); + NpmrcPlan::Append(format!("{bom}{}", lines.join("\n"))) +} + +/// What unwinding one recorded `.npmrc` edit does to the live file. +#[derive(Debug, PartialEq)] +pub enum NpmrcUnwind { + /// The file is gone, or no longer carries the line — already clean. + Unchanged, + /// Delete the file (a `created` edit whose file still holds exactly + /// [`NPMRC_CREATED`]). + Delete, + /// Write this content (the one line removed). `modified_created` is + /// set when a `created` file had grown other content: it is kept and + /// only the line goes, which callers surface as + /// `redirect_npmrc_allow_remote_modified`. + Write { + content: String, + modified_created: bool, + }, +} + +/// Is `line` (one `\n`-split element, maybe carrying a CRLF `\r` or the +/// file's BOM) exactly the line the auto-config writes? +fn is_our_line(line: &str) -> bool { + line.trim_start_matches(BOM).trim_end_matches('\r') == NPMRC_ALLOW_REMOTE_LINE +} + +/// Unwind one recorded `.npmrc` edit (`action` `created` / `added`) against +/// the live `content` (`None` = file absent). Exact inverse of +/// [`plan_npmrc_allow_remote`]'s splice: the one matching line is removed +/// with its line terminator, every other byte kept. Only TOP-LEVEL lines +/// (before the first real ini `[section]` header — the scope the plan +/// writes into and npm reads the key from) count: a copy under a section +/// is inert user text, never ours. `Err` when the line appears more than +/// once at top level (ambiguous — refuse rather than guess which copy the +/// redirect owns). +pub fn unwind_npmrc_allow_remote( + action: &str, + content: Option<&str>, +) -> Result { + let Some(content) = content else { + return Ok(NpmrcUnwind::Unchanged); + }; + if action == "created" && content == NPMRC_CREATED { + return Ok(NpmrcUnwind::Delete); + } + let (bom, body) = match content.strip_prefix(BOM) { + Some(rest) => (&content[..BOM.len_utf8()], rest), + None => ("", content), + }; + let mut lines: Vec<&str> = body.split('\n').collect(); + let end = top_level_end(bom, &lines); + let hits: Vec = lines[..end] + .iter() + .enumerate() + .filter(|(_, l)| is_our_line(l)) + .map(|(i, _)| i) + .collect(); + match hits.as_slice() { + [] => Ok(NpmrcUnwind::Unchanged), + [i] => { + lines.remove(*i); + let rest = lines.join("\n"); + // A created file reduced to nothing but its BOM/whitespace is the + // redirect's own file: delete it rather than leave a husk. + if action == "created" && rest.trim().is_empty() { + return Ok(NpmrcUnwind::Delete); + } + Ok(NpmrcUnwind::Write { + content: format!("{bom}{rest}"), + modified_created: action == "created", + }) + } + _ => Err(format!( + "{NPMRC_REL}: the `{NPMRC_ALLOW_REMOTE_LINE}` line appears more than once — \ + ambiguous, refusing to guess which copy the hosted redirect added; remove the \ + duplicate, then re-run" + )), + } +} + +/// The advisory for a redirect-created `.npmrc` that was modified since. +pub fn npmrc_modified_warning() -> (String, String) { + ( + "redirect_npmrc_allow_remote_modified".to_string(), + format!( + "{NPMRC_REL} was created by the hosted redirect but has been modified since — kept \ + the file and removed only the `{NPMRC_ALLOW_REMOTE_LINE}` line" + ), + ) +} + +/// The unwind of every recorded `.npmrc` edit once no redirected npm lock +/// entry needs `allow-remote=all` any more. +#[derive(Debug, Default)] +pub struct NpmrcUnwindPlan { + /// Ledger indices of the `.npmrc` edits to drop. + pub indices: Vec, + /// `None` — the file needs no change; `Some(None)` — delete it; + /// `Some(Some(text))` — write `text`. + pub staged: Option>, + /// Advisory (code, detail) pairs. + pub warnings: Vec<(String, String)>, +} + +/// Is an unwind of the recorded `.npmrc` edit(s) due once `dropping` (the +/// ledger indices the caller is about to remove) is gone? True iff a +/// [`NPMRC_ALLOW_REMOTE_EDIT_KIND`] edit survives while no +/// [`NPM_LOCK_EDIT_KINDS`] edit does. Callers check this BEFORE reading the +/// live `.npmrc`, so a file that merely has an odd shape never refuses an +/// unrelated revert while the setting is still needed. +pub fn npmrc_unwind_due(edits: &[FileEdit], dropping: &HashSet) -> bool { + let live = |kinds: &[&str]| { + edits + .iter() + .enumerate() + .any(|(i, e)| !dropping.contains(&i) && kinds.contains(&e.kind.as_str())) + }; + live(&[NPMRC_ALLOW_REMOTE_EDIT_KIND]) && !live(&NPM_LOCK_EDIT_KINDS) +} + +/// "Last one out turns off the lights" for the `.npmrc` auto-config: when +/// no [`NPM_LOCK_EDIT_KINDS`] edit survives outside `dropping` (the indices +/// the caller is about to remove), plan the unwind of every recorded +/// [`NPMRC_ALLOW_REMOTE_EDIT_KIND`] edit, newest first, against `current` +/// (the live `.npmrc`). `Ok(None)` when an npm lock edit still needs the +/// setting, or there is no `.npmrc` edit to unwind. `Err` on an ambiguous +/// file or a tampered ledger path (callers fail closed). +pub fn plan_unneeded_npmrc_unwind( + edits: &[FileEdit], + dropping: &HashSet, + current: Option, +) -> Result, String> { + let still_needed = edits + .iter() + .enumerate() + .any(|(i, e)| !dropping.contains(&i) && NPM_LOCK_EDIT_KINDS.contains(&e.kind.as_str())); + if still_needed { + return Ok(None); + } + let indices: Vec = edits + .iter() + .enumerate() + .filter(|(i, e)| !dropping.contains(i) && e.kind == NPMRC_ALLOW_REMOTE_EDIT_KIND) + .map(|(i, _)| i) + .collect(); + if indices.is_empty() { + return Ok(None); + } + let mut plan = NpmrcUnwindPlan { + indices: indices.clone(), + ..NpmrcUnwindPlan::default() + }; + let mut content = current; + for &i in indices.iter().rev() { + let edit = &edits[i]; + if edit.path != NPMRC_REL { + return Err(format!( + "the redirect ledger records a {} edit for `{}` (expected `{NPMRC_REL}`); \ + refusing to touch it", + edit.kind, edit.path + )); + } + match unwind_npmrc_allow_remote(&edit.action, content.as_deref())? { + NpmrcUnwind::Unchanged => {} + NpmrcUnwind::Delete => { + content = None; + plan.staged = Some(None); + } + NpmrcUnwind::Write { + content: next, + modified_created, + } => { + if modified_created { + plan.warnings.push(npmrc_modified_warning()); + } + content = Some(next.clone()); + plan.staged = Some(Some(next)); + } + } + } + Ok(Some(plan)) +} + +/// Read the live project `.npmrc` for an unwind: `Ok(None)` when absent. +/// Refuses (`Err`) a symlink or any non-regular file HERE, at plan time — +/// [`flush_npmrc`] would refuse it too, but only after the caller's other +/// staged files (the reverted lock) had already been written, leaving the +/// lock unwound while the ledger still records the redirect. FIFO-safe +/// (non-blocking open + fstat), so a planted FIFO refuses fast instead of +/// wedging the run. +pub fn read_project_npmrc(project_root: &std::path::Path) -> Result, String> { + let path = project_root.join(NPMRC_REL); + match std::fs::symlink_metadata(&path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(format!("inspect {NPMRC_REL}: {e}")), + Ok(meta) if !meta.is_file() => { + return Err(format!( + "{NPMRC_REL} is not a regular file (a symlink, directory or special file); \ + socket-patch never writes through one — replace it with a regular file, \ + then re-run" + )) + } + Ok(_) => {} + } + match crate::utils::fs::read_regular_to_string_sync(&path) { + Ok(text) => Ok(Some(text)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(format!("read {NPMRC_REL}: {e}")), + } +} + +/// Write (or delete) a planned `.npmrc` unwind through the reverts' shared +/// staged flush: it refuses a non-regular file (a symlink or FIFO planted at +/// the path) instead of writing through it, and writes atomically keeping +/// the file's mode. +pub async fn flush_npmrc( + project_root: &std::path::Path, + staged: &Option, +) -> Result<(), String> { + let one = super::staged::Staged::from([(NPMRC_REL.to_string(), staged.clone())]); + super::staged::flush_staged(project_root, &one, &super::staged::StagedBytes::new()).await +} + +/// What [`unwind_unneeded_npmrc`] did. +#[derive(Debug, Default, PartialEq)] +pub struct NpmrcStandaloneUnwind { + /// The `.npmrc` itself was (or, on a dry run, would be) rewritten or + /// deleted. + pub file_changed: bool, + /// At least one recorded `.npmrc` edit was dropped from the ledger. + pub edits_dropped: bool, + /// Advisory (code, detail) pairs (e.g. + /// `redirect_npmrc_allow_remote_modified`). + pub warnings: Vec<(String, String)>, +} + +/// Standalone "last one out" pass over a ledger the caller has just pruned +/// WITHOUT an on-disk revert (the vendored-supersedes-hosted reconcile): +/// when no npm lock edit remains, unwind the recorded `.npmrc` edits on +/// disk (unless `dry_run`) and drop them from `state`. Returns what changed +/// plus the advisory warnings (the caller surfaces them); the caller +/// persists `state`. The ledger is only consulted — and the file only +/// read — when it records a `.npmrc` edit at all. +pub async fn unwind_unneeded_npmrc( + project_root: &std::path::Path, + state: &mut super::RedirectState, + dry_run: bool, +) -> Result { + if !npmrc_unwind_due(&state.edits, &HashSet::new()) { + // Nothing recorded, or still needed: no read, no refusal over the + // file's shape. + return Ok(NpmrcStandaloneUnwind::default()); + } + let current = read_project_npmrc(project_root)?; + let Some(plan) = plan_unneeded_npmrc_unwind(&state.edits, &HashSet::new(), current)? else { + return Ok(NpmrcStandaloneUnwind::default()); + }; + if !dry_run { + if let Some(staged) = &plan.staged { + flush_npmrc(project_root, staged).await?; + } + } + let drop: HashSet = plan.indices.iter().copied().collect(); + let mut idx = 0usize; + state.edits.retain(|_| { + let keep = !drop.contains(&idx); + idx += 1; + keep + }); + Ok(NpmrcStandaloneUnwind { + file_changed: plan.staged.is_some(), + edits_dropped: !plan.indices.is_empty(), + warnings: plan.warnings, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn edit(kind: &str, action: &str) -> FileEdit { + FileEdit { + path: if kind == NPMRC_ALLOW_REMOTE_EDIT_KIND { + NPMRC_REL.into() + } else { + "package-lock.json".into() + }, + kind: kind.into(), + action: action.into(), + key: Some(if kind == NPMRC_ALLOW_REMOTE_EDIT_KIND { + "allow-remote".into() + } else { + "node_modules/left-pad".into() + }), + original: None, + new: Some(serde_json::json!("all")), + } + } + + /// The measured npm 12.1.0 grammar (see the module doc): exact key + /// spelling only, BOM / whitespace / CRLF / quotes / inline comments + /// tolerated, comment lines and `[section]` bodies ignored, LAST + /// top-level assignment wins, value case-sensitive. + #[test] + fn npmrc_allow_remote_reads_the_effective_assignment() { + assert_eq!(npmrc_allow_remote(""), None); + assert_eq!(npmrc_allow_remote("registry=https://r.example/\n"), None); + assert_eq!( + npmrc_allow_remote("allow-remote=all\n").as_deref(), + Some("all") + ); + assert_eq!( + npmrc_allow_remote(" allow-remote = \"all\"\r\n").as_deref(), + Some("all") + ); + assert_eq!( + npmrc_allow_remote("allow-remote='root'\n").as_deref(), + Some("root") + ); + assert_eq!( + npmrc_allow_remote("allow-remote=all ; why\n").as_deref(), + Some("all") + ); + assert_eq!( + npmrc_allow_remote("\u{feff}allow-remote=all\n").as_deref(), + Some("all") + ); + // npm does NOT normalize `.npmrc` keys: these are ignored by npm 12. + assert_eq!(npmrc_allow_remote("allow_remote=all\n"), None); + assert_eq!(npmrc_allow_remote("ALLOW-REMOTE=all\n"), None); + // Comment lines never count. + assert_eq!( + npmrc_allow_remote("; allow-remote=all\n# allow-remote=all\n"), + None + ); + // LAST top-level assignment wins, either direction. + assert_eq!( + npmrc_allow_remote("allow-remote=none\nallow-remote=all\n").as_deref(), + Some("all") + ); + assert_eq!( + npmrc_allow_remote("allow-remote=all\nallow-remote=none\n").as_deref(), + Some("none") + ); + // Section bodies are not top-level config. + assert_eq!(npmrc_allow_remote("[sec]\nallow-remote=all\n"), None); + assert_eq!( + npmrc_allow_remote("allow-remote=root\n[sec]\nallow-remote=all\n").as_deref(), + Some("root") + ); + // Case-sensitive value: `All` is not `all` to npm's gate. + assert_eq!( + npmrc_allow_remote("allow-remote=All\n").as_deref(), + Some("All") + ); + } + + /// Differential corpus: every expectation is what npm's bundled `ini` + /// (`ini.parse(text)['allow-remote']`) returned for the same text — + /// identically under npm 11.19 (ini 6.0.0) and npm 12.1.0 (ini 7.0.0). Pins the review findings: a bare + /// `\r` ends a line (`/[\r\n]+/`), and a section header is matched on + /// the UNTRIMMED line (an indented or BOM-prefixed `[sec]` is a plain + /// top-level key, so the scope does not end there). + #[test] + fn npmrc_allow_remote_matches_npm_ini_differentially() { + let cases: &[(&str, Option<&str>)] = &[ + ("registry=x\rallow-remote=none\r", Some("none")), + (" [sec]\nallow-remote=none\n", Some("none")), + ("\u{feff}[sec]\nallow-remote=none\n", Some("none")), + ("[sec]\nallow-remote=all\n", None), + ("[a]b=c\nallow-remote=none\n", Some("none")), + ("[sec] \t\nallow-remote=none\n", None), + ("\"allow-remote\"=none\n", Some("none")), + ("allow-remote=\"none\"\n", Some("none")), + ("allow-remote='\"root\"'\n", Some("root")), + ("allow-remote=al;l\n", Some("al")), + ("allow-remote=al\\;l\n", Some("al;l")), + ("allow-remote\n", Some("true")), + ("=allow-remote=none\n", None), + ( + "allow-remote[]=none\nallow-remote=all\n", + Some("[none, all]"), + ), + ("allow-remote=none\u{2028}\n", None), + ("allow-remote = all # c\n", Some("all")), + ("\u{a0}allow-remote=none\n", Some("none")), + ("\u{85}allow-remote=none\n", None), + ( + "allow-remote=all\r\n[x]\r\nallow-remote=none\r\n", + Some("all"), + ), + ("allow-remote=\"\n", Some("\"")), + ("allow-remote='\n", Some("")), + ]; + for (text, want) in cases { + assert_eq!(npmrc_allow_remote(text).as_deref(), *want, "{text:?}"); + } + } + + /// Finding: a CR-only `.npmrc` with an explicit `allow-remote=none` was + /// read as one `registry` line, planned an Append, and the appended + /// `allow-remote=all` silently flipped the user's `none`. Now it is + /// respected; a CR-only file WITHOUT the key is never spliced. + #[test] + fn plan_never_flips_or_splices_a_cr_only_npmrc() { + assert_eq!( + plan_npmrc_allow_remote(Some("registry=x\rallow-remote=none\r")), + NpmrcPlan::UserSet("none".into()) + ); + assert!(matches!( + plan_npmrc_allow_remote(Some("registry=x\r[sec]\rfund=false\r")), + NpmrcPlan::Unsupported(_) + )); + // Mixed: one lone CR anywhere is enough to stand down. + assert!(matches!( + plan_npmrc_allow_remote(Some("a=1\r\nb=2\rc=3\r\n")), + NpmrcPlan::Unsupported(_) + )); + } + + /// Finding: an indented / BOM-prefixed `[sec]` was treated as a header, + /// so the plan wrote `allow-remote=all` above it while npm kept reading + /// the user's `none` below it (EALLOWREMOTE, yet the warning claimed + /// the write fixed it). npm reads those lines as top-level keys. + #[test] + fn plan_respects_values_under_non_headers() { + for text in [ + " [sec]\nallow-remote=none\n", + "\u{feff}[sec]\nallow-remote=none\n", + "[a]b=c\nallow-remote=none\n", + ] { + assert_eq!( + plan_npmrc_allow_remote(Some(text)), + NpmrcPlan::UserSet("none".into()), + "{text:?}" + ); + } + // A real header after a BOM-less first line still bounds the scope, + // and a BOM-prefixed first-line "header" does not. + let NpmrcPlan::Append(text) = plan_npmrc_allow_remote(Some("\u{feff}[x]\n[sec]\ny=1\n")) + else { + panic!("append expected"); + }; + assert_eq!(text, "\u{feff}[x]\nallow-remote=all\n[sec]\ny=1\n"); + } + + /// Finding: `[sec]\nallow-remote=all\n` (inert under a section) planned + /// a top-level append, and the unwind then counted BOTH copies and + /// refused as ambiguous — blocking rollback, remove, the vendored + /// takeover and the reconcile over a state our own writer created. The + /// unwind now only counts top-level lines, like the plan. + #[test] + fn unwind_ignores_section_scoped_copies() { + let before = "[sec]\nallow-remote=all\n"; + let NpmrcPlan::Append(text) = plan_npmrc_allow_remote(Some(before)) else { + panic!("append expected"); + }; + assert_eq!(text, "allow-remote=all\n[sec]\nallow-remote=all\n"); + assert_eq!( + unwind_npmrc_allow_remote("added", Some(&text)).unwrap(), + NpmrcUnwind::Write { + content: before.into(), + modified_created: false + } + ); + // Only a section copy left: nothing of ours to remove. + assert_eq!( + unwind_npmrc_allow_remote("added", Some(before)).unwrap(), + NpmrcUnwind::Unchanged + ); + // The ledger-level plan agrees (the per-purl revert / reconcile path). + let edits = vec![edit(NPMRC_ALLOW_REMOTE_EDIT_KIND, "added")]; + let plan = plan_unneeded_npmrc_unwind(&edits, &HashSet::new(), Some(text)) + .unwrap() + .expect("unwind planned"); + assert_eq!(plan.staged, Some(Some(before.into()))); + // Two TOP-LEVEL copies are still genuinely ambiguous. + assert!(unwind_npmrc_allow_remote( + "added", + Some("allow-remote=all\nallow-remote=all\n[sec]\nallow-remote=all\n") + ) + .is_err()); + } + + fn cfg_env(vars: &[(&str, &str)]) -> NpmConfigEnv { + NpmConfigEnv { + vars: vars + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + home: Some("/home/u".into()), + node_exe: Some("/opt/node/bin/node".into()), + windows: false, + } + } + + /// Finding: only the project `.npmrc` was consulted, so a machine / org + /// `allow-remote=none` in `~/.npmrc` or `$PREFIX/etc/npmrc` was + /// silently overridden by a committed project `allow-remote=all`, and + /// an env `npm_config_allow_remote=none` (which beats the project file) + /// was ignored while the warning promised `npm ci` needs no flags. + #[test] + fn outer_layers_are_located_like_npm_and_respected() { + use std::collections::HashMap; + use std::path::{Path, PathBuf}; + let files: HashMap = HashMap::from([ + (PathBuf::from("/home/u/.npmrc"), "allow-remote=none\n"), + (PathBuf::from("/opt/node/etc/npmrc"), "allow-remote=root\n"), + ( + PathBuf::from("/opt/node/lib/node_modules/npm/npmrc"), + "allow-remote=all\n", + ), + ( + PathBuf::from("/alt/user.npmrc"), + "fund=false\nprefix=/pfx\n", + ), + (PathBuf::from("/pfx/etc/npmrc"), "allow-remote=none\n"), + ]); + let read = |p: &Path| files.get(p).map(|s| s.to_string()); + + // user beats global beats builtin. + let outer = resolve_outer_allow_remote(&cfg_env(&[]), read); + assert_eq!(outer.env, None); + let file = outer.file.clone().expect("user value"); + assert_eq!((file.layer, file.value.as_str()), ("user", "none")); + assert_eq!(file.path, PathBuf::from("/home/u/.npmrc")); + assert_eq!( + plan_npmrc_allow_remote_with(None, &outer), + NpmrcPlan::OuterSet { + layer: "user", + path: "/home/u/.npmrc".into(), + value: "none".into() + } + ); + // A project value outranks every file layer. + assert_eq!( + plan_npmrc_allow_remote_with(Some("allow-remote=all\n"), &outer), + NpmrcPlan::AlreadyAll + ); + + // npm_config_userconfig relocates the user file; its `prefix` + // relocates the global file. + let outer = resolve_outer_allow_remote( + &cfg_env(&[("NPM_CONFIG_USERCONFIG", "/alt/user.npmrc")]), + read, + ); + let file = outer.file.expect("global value"); + assert_eq!((file.layer, file.value.as_str()), ("global", "none")); + assert_eq!(file.path, PathBuf::from("/pfx/etc/npmrc")); + + // Default global prefix from the node binary; builtin beside npm. + let outer = + resolve_outer_allow_remote(&cfg_env(&[("npm_config_userconfig", "/nowhere")]), read); + let file = outer.file.expect("global value"); + assert_eq!((file.layer, file.value.as_str()), ("global", "root")); + let outer = resolve_outer_allow_remote( + &cfg_env(&[ + ("npm_config_userconfig", "/nowhere"), + ("npm_config_globalconfig", "/nowhere"), + ]), + read, + ); + let file = outer.file.clone().expect("builtin value"); + assert_eq!((file.layer, file.value.as_str()), ("builtin", "all")); + // An outer `all` never blocks the write. + assert_eq!( + plan_npmrc_allow_remote_with(None, &outer), + NpmrcPlan::Create(NPMRC_CREATED.into()) + ); + + // env: any spelling npm normalizes to the key; beats everything. + for var in [ + "npm_config_allow_remote", + "NPM_CONFIG_ALLOW_REMOTE", + "npm_config_allow-remote", + ] { + let outer = resolve_outer_allow_remote(&cfg_env(&[(var, "none")]), |_| None); + assert_eq!(outer.env, Some((var.to_string(), "none".to_string()))); + assert_eq!( + plan_npmrc_allow_remote_with(Some("allow-remote=all\n"), &outer), + NpmrcPlan::EnvSet { + var: var.into(), + value: "none".into() + }, + "{var}" + ); + } + // Empty env values are ignored by npm; `all` never blocks. + let outer = resolve_outer_allow_remote( + &cfg_env(&[("npm_config_allow_remote", ""), ("npm_config_x", "none")]), + |_| None, + ); + assert_eq!(outer, OuterAllowRemote::default()); + let outer = + resolve_outer_allow_remote(&cfg_env(&[("npm_config_allow_remote", "all")]), |_| None); + assert_eq!( + plan_npmrc_allow_remote_with(None, &outer), + NpmrcPlan::Create(NPMRC_CREATED.into()) + ); + // PREFIX (exact case) overrides the node-derived default prefix. + let outer = resolve_outer_allow_remote( + &cfg_env(&[("npm_config_userconfig", "/nowhere"), ("PREFIX", "/pfx")]), + read, + ); + assert_eq!( + outer.file.expect("global").path, + PathBuf::from("/pfx/etc/npmrc") + ); + // ...but never the builtin config: npm's own install root is where + // npm lives (beside node), not the global prefix. + let outer = resolve_outer_allow_remote( + &cfg_env(&[ + ("npm_config_userconfig", "/nowhere"), + ("npm_config_globalconfig", "/nowhere"), + ("PREFIX", "/pfx"), + ("DESTDIR", "/dest"), + ]), + read, + ); + let file = outer.file.expect("builtin value"); + assert_eq!(file.layer, "builtin"); + assert_eq!( + file.path, + PathBuf::from("/opt/node/lib/node_modules/npm/npmrc") + ); + } + + /// `@npmcli/config`'s env-replace, differentially pinned against npm + /// 12.1.0's `lib/env-replace.js` (outputs captured from node). + #[test] + fn env_replace_matches_npm() { + let env = NpmConfigEnv::from_parts( + vec![("A".into(), "x".into()), ("E".into(), String::new())], + None, + false, + ); + for (input, want) in [ + ("${A}", "x"), + ("${B}", "${B}"), + ("${B?}", ""), + ("${A?}", "x"), + ("${E}", ""), + ("${E?}", ""), + ("\\${A}", "${A}"), + ("\\\\${A}", "\\x"), + ("\\\\\\${A}", "\\${A}"), + ("\\\\\\\\${A}", "\\\\x"), + ("a$b${", "a$b${"), + ("${A}${A}", "xx"), + ("$${A}", "$x"), + ("\\x${A}", "\\xx"), + ("${}", "${}"), + ("${a{b}", "${a{b}"), + ("${a?b}", "${a?b}"), + ("${a??}", "${a??}"), + ("pre\\\\\\${B?}post", "pre\\${B?}post"), + ("${A}\\npm", "x\\npm"), + ("\\\\${B?}", "\\"), + ("é${A}ü", "éxü"), + ] { + assert_eq!(env.env_replace(input), want, "{input:?}"); + } + } + + /// Windows resolution, simulated on any host (forward-slash drive paths + /// parse on both): env names are case-insensitive (`process.env`), home + /// falls back to `USERPROFILE` (`os.homedir()`), the default prefix is + /// `node.exe`'s own directory (no `bin`), builtin is + /// `/node_modules/npm/npmrc`, `DESTDIR` is ignored, `~\` expands, + /// and the installer's builtin `prefix=${APPDATA}\npm` is env-replaced. + /// The same inputs with `windows: false` resolve the Unix way. + #[test] + fn outer_layers_are_located_like_npm_on_windows() { + use std::collections::HashMap; + use std::path::{Path, PathBuf}; + let win = |vars: &[(&str, &str)]| { + NpmConfigEnv::from_parts( + vars.iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + Some("C:/nodejs/node.exe".into()), + true, + ) + }; + let appdata_global = PathBuf::from(r"C:/Users/u/AppData/Roaming\npm") + .join("etc") + .join("npmrc"); + let files: HashMap = HashMap::from([ + (PathBuf::from("C:/Users/u/.npmrc"), "allow-remote=none\n"), + (PathBuf::from("C:/Users/u/alt.npmrc"), "allow-remote=root\n"), + ( + PathBuf::from("C:/nodejs/node_modules/npm/npmrc"), + "prefix=${APPDATA}\\npm\n", + ), + (appdata_global.clone(), "allow-remote=none\n"), + (PathBuf::from("C:/nodejs/etc/npmrc"), "allow-remote=root\n"), + (PathBuf::from("C:/pfx/etc/npmrc"), "allow-remote=none\n"), + ]); + let read = |p: &Path| files.get(p).map(|s| s.to_string()); + + // Home from USERPROFILE (any case); HOME (any case) wins over it. + let env = win(&[("UserProfile", "C:/Users/u")]); + assert_eq!(env.home, Some(PathBuf::from("C:/Users/u"))); + assert_eq!( + win(&[("USERPROFILE", "C:/Users/u"), ("Home", "D:/h")]).home, + Some(PathBuf::from("D:/h")) + ); + let file = resolve_outer_allow_remote(&env, read).file.expect("user"); + assert_eq!(file.layer, "user"); + assert_eq!(file.path, PathBuf::from("C:/Users/u/.npmrc")); + + // `~\` expands on Windows. + let file = resolve_outer_allow_remote( + &win(&[ + ("USERPROFILE", "C:/Users/u"), + ("npm_config_userconfig", r"~\alt.npmrc"), + ]), + read, + ) + .file + .expect("relocated user"); + assert_eq!(file.path, PathBuf::from("C:/Users/u/alt.npmrc")); + + // The builtin `prefix=${APPDATA}\npm` (APPDATA matched in any case) + // moves the global file under %APPDATA%\npm. + let file = resolve_outer_allow_remote( + &win(&[ + ("npm_config_userconfig", "C:/nowhere"), + ("AppData", "C:/Users/u/AppData/Roaming"), + ]), + read, + ) + .file + .expect("global"); + assert_eq!((file.layer, file.path), ("global", appdata_global.clone())); + + // With no builtin prefix: `dirname(node.exe)`, not its parent, and + // DESTDIR is Unix-only. + let no_builtin = |p: &Path| { + (!p.ends_with("node_modules/npm/npmrc")) + .then(|| read(p)) + .flatten() + }; + let file = resolve_outer_allow_remote( + &win(&[("npm_config_userconfig", "C:/nowhere"), ("DESTDIR", "C:/d")]), + no_builtin, + ) + .file + .expect("global"); + assert_eq!(file.path, PathBuf::from("C:/nodejs/etc/npmrc")); + + // PREFIX is matched case-insensitively on Windows, exactly on Unix. + let vars = [ + ("npm_config_userconfig", "C:/nowhere"), + ("Prefix", "C:/pfx"), + ]; + let file = resolve_outer_allow_remote(&win(&vars), no_builtin) + .file + .expect("global"); + assert_eq!(file.path, PathBuf::from("C:/pfx/etc/npmrc")); + let mut unix = win(&vars); + unix.windows = false; + let outer = resolve_outer_allow_remote(&unix, |p| { + assert_ne!(p, Path::new("C:/pfx/etc/npmrc"), "`Prefix` is not PREFIX"); + None + }); + assert_eq!(outer.file, None); + + // The Unix reading of the same facts: `~\` is literal, env names + // are exact-case (`AppData` does not satisfy `${APPDATA}`), the + // builtin lives under `lib/`. + let unix = NpmConfigEnv::from_parts( + vec![ + ("HOME".into(), "/home/u".into()), + ("npm_config_userconfig".into(), r"~\alt.npmrc".into()), + ("AppData".into(), "/appdata".into()), + ], + Some("/opt/node/bin/node".into()), + false, + ); + let seen = std::cell::RefCell::new(Vec::new()); + resolve_outer_allow_remote(&unix, |p| { + seen.borrow_mut().push(p.to_path_buf()); + (p == Path::new("/opt/node/lib/node_modules/npm/npmrc")) + .then(|| "prefix=${APPDATA}/npm\n".to_string()) + }); + assert_eq!( + seen.into_inner(), + vec![ + PathBuf::from("/opt/node/lib/node_modules/npm/npmrc"), + PathBuf::from(r"~\alt.npmrc"), + PathBuf::from("${APPDATA}/npm/etc/npmrc"), + ] + ); + } + + #[test] + fn strip_verbatim_only_touches_windows_drive_paths() { + use std::path::PathBuf; + let v = PathBuf::from(r"\\?\C:\Program Files\nodejs\node.exe"); + assert_eq!( + strip_verbatim(v.clone(), true), + PathBuf::from(r"C:\Program Files\nodejs\node.exe") + ); + assert_eq!(strip_verbatim(v.clone(), false), v); + let unc = PathBuf::from(r"\\?\UNC\srv\share\node.exe"); + assert_eq!(strip_verbatim(unc.clone(), true), unc); + } + + #[test] + fn unwind_due_only_when_no_npm_lock_edit_survives() { + let edits = vec![ + edit("redirect_npm_lock_entry", "rewritten"), + edit(NPMRC_ALLOW_REMOTE_EDIT_KIND, "created"), + ]; + assert!(!npmrc_unwind_due(&edits, &HashSet::new())); + assert!(npmrc_unwind_due(&edits, &HashSet::from([0]))); + assert!(!npmrc_unwind_due(&edits, &HashSet::from([0, 1]))); + assert!(!npmrc_unwind_due(&edits[..1], &HashSet::new())); + } + + /// Finding: the per-purl revert learned a symlinked `.npmrc` was + /// unwritable only at flush time, after the reverted lock had landed. + /// The read used while PLANNING now refuses it. + #[cfg(unix)] + #[test] + fn read_project_npmrc_refuses_a_symlink_at_plan_time() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(read_project_npmrc(dir.path()), Ok(None)); + std::fs::write(dir.path().join("shared.npmrc"), "allow-remote=all\n").unwrap(); + std::os::unix::fs::symlink("shared.npmrc", dir.path().join(".npmrc")).unwrap(); + let err = read_project_npmrc(dir.path()).unwrap_err(); + assert!(err.contains("not a regular file"), "{err}"); + std::fs::remove_file(dir.path().join(".npmrc")).unwrap(); + std::fs::create_dir(dir.path().join(".npmrc")).unwrap(); + assert!(read_project_npmrc(dir.path()).is_err()); + } + + #[test] + fn plan_creates_when_absent() { + assert_eq!( + plan_npmrc_allow_remote(None), + NpmrcPlan::Create("allow-remote=all\n".into()) + ); + } + + #[test] + fn plan_appends_preserving_bytes_and_round_trips() { + let cases = [ + ( + "registry=https://r.example/\n", + "registry=https://r.example/\nallow-remote=all\n", + ), + ("a=1", "a=1\nallow-remote=all"), + ("a=1\n\n", "a=1\nallow-remote=all\n\n"), + ("a=1\r\nb=2\r\n", "a=1\r\nb=2\r\nallow-remote=all\r\n"), + ("\u{feff}a=1\n", "\u{feff}a=1\nallow-remote=all\n"), + ("", "allow-remote=all\n"), + ("\u{feff}", "\u{feff}allow-remote=all\n"), + ("\n", "allow-remote=all\n\n"), + ("; team config\n", "; team config\nallow-remote=all\n"), + // An `allow_remote` spelling npm ignores stays byte-identical. + ("allow_remote=all\n", "allow_remote=all\nallow-remote=all\n"), + // Never inside an ini section: before the first header. + ("a=1\n[sec]\nx=1\n", "a=1\nallow-remote=all\n[sec]\nx=1\n"), + ("[sec]\nx=1\n", "allow-remote=all\n[sec]\nx=1\n"), + ]; + for (before, after) in cases { + let NpmrcPlan::Append(text) = plan_npmrc_allow_remote(Some(before)) else { + panic!("{before:?} must plan an append"); + }; + assert_eq!(text, after, "append into {before:?}"); + assert_eq!( + npmrc_allow_remote(&text).as_deref(), + Some("all"), + "{text:?}" + ); + // Exact inverse: the unwind restores the user's bytes. + assert_eq!( + unwind_npmrc_allow_remote("added", Some(&text)).unwrap(), + NpmrcUnwind::Write { + content: before.to_string(), + modified_created: false + }, + "unwind of {text:?}" + ); + } + } + + #[test] + fn plan_respects_existing_values() { + for text in [ + "allow-remote=all\n", + "allow-remote = \"all\"\r\n", + "allow-remote=none\nallow-remote=all\n", + ] { + assert_eq!( + plan_npmrc_allow_remote(Some(text)), + NpmrcPlan::AlreadyAll, + "{text:?}" + ); + } + for (text, value) in [ + ("allow-remote=none\n", "none"), + ("allow-remote=root\n", "root"), + ("allow-remote=all\nallow-remote=root\n", "root"), + ("allow-remote=All\n", "All"), + ] { + assert_eq!( + plan_npmrc_allow_remote(Some(text)), + NpmrcPlan::UserSet(value.into()), + "{text:?}" + ); + } + } + + #[test] + fn unwind_created_file() { + assert_eq!( + unwind_npmrc_allow_remote("created", Some(NPMRC_CREATED)).unwrap(), + NpmrcUnwind::Delete + ); + assert_eq!( + unwind_npmrc_allow_remote("created", None).unwrap(), + NpmrcUnwind::Unchanged + ); + // Grown since: keep the file, drop only our line, say so. + assert_eq!( + unwind_npmrc_allow_remote("created", Some("allow-remote=all\nfund=false\n")).unwrap(), + NpmrcUnwind::Write { + content: "fund=false\n".into(), + modified_created: true + } + ); + // The user already removed our line: nothing to do. + assert_eq!( + unwind_npmrc_allow_remote("added", Some("fund=false\n")).unwrap(), + NpmrcUnwind::Unchanged + ); + // A commented-out copy is not our line. + assert_eq!( + unwind_npmrc_allow_remote("added", Some("; allow-remote=all\n")).unwrap(), + NpmrcUnwind::Unchanged + ); + // Ambiguous duplicates refuse. + assert!( + unwind_npmrc_allow_remote("added", Some("allow-remote=all\nallow-remote=all\n")) + .is_err() + ); + } + + #[test] + fn last_one_out_keeps_the_setting_while_npm_lock_edits_remain() { + let edits = vec![ + edit("redirect_npm_lock_entry", "rewritten"), + edit(NPMRC_ALLOW_REMOTE_EDIT_KIND, "created"), + ]; + // The lock edit survives: the setting is still needed. + assert!( + plan_unneeded_npmrc_unwind(&edits, &HashSet::new(), Some(NPMRC_CREATED.into())) + .unwrap() + .is_none() + ); + // The lock edit is being dropped: unwind (delete the created file). + let plan = + plan_unneeded_npmrc_unwind(&edits, &HashSet::from([0]), Some(NPMRC_CREATED.into())) + .unwrap() + .expect("unwind planned"); + assert_eq!(plan.indices, vec![1]); + assert_eq!(plan.staged, Some(None)); + // A pnpm-only ledger with a stray `.npmrc` edit also unwinds. + let edits = vec![ + edit("redirect_pnpm_resolution", "rewritten"), + edit(NPMRC_ALLOW_REMOTE_EDIT_KIND, "added"), + ]; + let plan = plan_unneeded_npmrc_unwind( + &edits, + &HashSet::new(), + Some("a=1\nallow-remote=all\n".into()), + ) + .unwrap() + .expect("unwind planned"); + assert_eq!(plan.staged, Some(Some("a=1\n".into()))); + // A tampered ledger path refuses. + let mut bad = edit(NPMRC_ALLOW_REMOTE_EDIT_KIND, "added"); + bad.path = "../.npmrc".into(); + assert!(plan_unneeded_npmrc_unwind(&[bad], &HashSet::new(), None).is_err()); + } + + #[tokio::test] + async fn standalone_unwind_removes_only_our_line_and_drops_the_edit() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join(".npmrc"), + "fund=false\r\nallow-remote=all\r\n", + ) + .unwrap(); + let mut state = super::super::RedirectState::new(); + state + .edits + .push(edit(NPMRC_ALLOW_REMOTE_EDIT_KIND, "added")); + // Dry run: nothing written, edit still dropped from the (throwaway) state. + let mut probe = state.clone(); + unwind_unneeded_npmrc(dir.path(), &mut probe, true) + .await + .unwrap(); + assert!(probe.edits.is_empty()); + assert_eq!( + std::fs::read_to_string(dir.path().join(".npmrc")).unwrap(), + "fund=false\r\nallow-remote=all\r\n" + ); + let out = unwind_unneeded_npmrc(dir.path(), &mut state, false) + .await + .unwrap(); + assert!(out.warnings.is_empty()); + assert!(out.file_changed && out.edits_dropped, "{out:?}"); + assert!(state.edits.is_empty()); + assert_eq!( + std::fs::read_to_string(dir.path().join(".npmrc")).unwrap(), + "fund=false\r\n" + ); + } +} diff --git a/crates/socket-patch-core/src/patch/redirect/replay.rs b/crates/socket-patch-core/src/patch/redirect/replay.rs index 02a707e9..0f2d18ab 100644 --- a/crates/socket-patch-core/src/patch/redirect/replay.rs +++ b/crates/socket-patch-core/src/patch/redirect/replay.rs @@ -72,6 +72,11 @@ enum Inverse { /// The pnpm `trustLockfile` auto-config (kind-specific: `created` /// deletes the scaffold, `added` removes exactly one line). PnpmTrust, + /// The npm `.npmrc` `allow-remote=all` auto-config (kind-specific: + /// `created` deletes the untouched file, `added` removes exactly one + /// line — see [`super::npmrc`]). Grouped with the npm lock kinds so a + /// surviving (refused) package-lock edit keeps the setting it needs. + NpmrcAllowRemote, BunBinaryPackage, /// Owned by a per-purl revert (npm JSON kinds). Present here only /// when that revert failed — refuse the group rather than guess. @@ -148,6 +153,7 @@ fn classify(kind: &str, action: &str) -> (&'static str, Inverse) { ("golang", Inverse::NoopDrop) } "redirect_npm_lock_entry" | "redirect_npm_lock_dep" => ("npm", Inverse::PerPurlOnly), + super::npmrc::NPMRC_ALLOW_REMOTE_EDIT_KIND => ("npm", Inverse::NpmrcAllowRemote), "redirect_maven_repository" | "redirect_maven_dep_management" | "redirect_maven_config" @@ -667,6 +673,55 @@ pub async fn revert_remaining_redirect_edits( } } } + Inverse::NpmrcAllowRemote => { + // Refuse a symlinked / non-regular `.npmrc` while + // planning — never at flush time, after sibling files + // of the group may already have landed. + if !staged.contains_key(&edit.path) { + if let Ok(meta) = + tokio::fs::symlink_metadata(project_root.join(&edit.path)).await + { + if !meta.is_file() { + refuse( + format!("{} is not a regular file", edit.path), + &mut outcome, + ); + refused_groups.insert(group); + continue 'group; + } + } + } + let content = match staged_read(&staged, project_root, &edit.path).await { + Ok(c) => c, + Err(e) => { + refuse(e, &mut outcome); + refused_groups.insert(group); + continue 'group; + } + }; + match super::npmrc::unwind_npmrc_allow_remote(&edit.action, content.as_deref()) + { + Ok(super::npmrc::NpmrcUnwind::Unchanged) => {} + Ok(super::npmrc::NpmrcUnwind::Delete) => { + staged.insert(edit.path.clone(), None); + } + Ok(super::npmrc::NpmrcUnwind::Write { + content, + modified_created, + }) => { + staged.insert(edit.path.clone(), Some(content)); + if modified_created { + group_warnings.push(super::npmrc::npmrc_modified_warning()); + } + } + Err(e) => { + refuse(e, &mut outcome); + refused_groups.insert(group); + continue 'group; + } + } + group_drops.insert(idx); + } } } @@ -1507,6 +1562,144 @@ mod tests { assert_eq!(state.edits.len(), 1, "only the refused npm edit remains"); } + // ---------- npm .npmrc allow-remote ---------- + + fn npmrc_edit(action: &str) -> FileEdit { + FileEdit { + path: ".npmrc".into(), + kind: "redirect_npmrc_allow_remote".into(), + action: action.into(), + key: Some("allow-remote".into()), + original: None, + new: Some(json!("all")), + } + } + + #[tokio::test] + async fn npmrc_created_file_is_deleted_when_unmodified() { + let dir = TempDir::new().unwrap(); + write(dir.path(), ".npmrc", "allow-remote=all\n").await; + let mut state = state_with(vec![npmrc_edit("created")], &[]); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert!(out.fully_reverted(), "{:?}", out.refusals); + assert!(!dir.path().join(".npmrc").exists()); + assert!(state.edits.is_empty()); + } + + #[tokio::test] + async fn npmrc_appended_line_is_removed_exactly_and_user_edits_survive() { + let dir = TempDir::new().unwrap(); + // The user added their own setting after our line (CRLF file). + write( + dir.path(), + ".npmrc", + "registry=https://r.example/\r\nallow-remote=all\r\nfund=false\r\n", + ) + .await; + let mut state = state_with(vec![npmrc_edit("added")], &[]); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert!(out.fully_reverted(), "{:?}", out.refusals); + assert_eq!( + read(dir.path(), ".npmrc").await, + "registry=https://r.example/\r\nfund=false\r\n" + ); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + } + + #[tokio::test] + async fn npmrc_modified_created_file_keeps_the_file_and_warns() { + let dir = TempDir::new().unwrap(); + write(dir.path(), ".npmrc", "allow-remote=all\nfund=false\n").await; + let mut state = state_with(vec![npmrc_edit("created")], &[]); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert!(out.fully_reverted(), "{:?}", out.refusals); + assert_eq!(read(dir.path(), ".npmrc").await, "fund=false\n"); + assert!(out + .warnings + .iter() + .any(|(code, _)| code == "redirect_npmrc_allow_remote_modified")); + } + + #[tokio::test] + async fn npmrc_edit_is_kept_while_an_npm_lock_edit_refuses() { + // A package-lock edit the per-purl revert failed to claim refuses + // the npm group — and with it the `.npmrc` setting that lock needs. + let dir = TempDir::new().unwrap(); + write(dir.path(), ".npmrc", "allow-remote=all\n").await; + let mut state = state_with( + vec![ + FileEdit { + path: "package-lock.json".into(), + kind: "redirect_npm_lock_entry".into(), + action: "rewritten".into(), + key: Some("node_modules/a".into()), + original: Some(json!({"resolved": "https://registry/a-1.tgz"})), + new: Some(json!({"resolved": "https://patch.example/a-1.tgz"})), + }, + npmrc_edit("created"), + ], + &["pkg:npm/a@1"], + ); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert_eq!(out.refusals.len(), 1, "{out:?}"); + assert_eq!(out.refusals[0].group, "npm"); + assert_eq!(read(dir.path(), ".npmrc").await, "allow-remote=all\n"); + assert_eq!(state.edits.len(), 2); + } + + #[tokio::test] + async fn npmrc_duplicate_line_refuses_and_dry_run_writes_nothing() { + let dir = TempDir::new().unwrap(); + write(dir.path(), ".npmrc", "allow-remote=all\nallow-remote=all\n").await; + let mut state = state_with(vec![npmrc_edit("added")], &[]); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert_eq!(out.refusals.len(), 1, "{out:?}"); + assert_eq!(state.edits.len(), 1); + + write(dir.path(), ".npmrc", "allow-remote=all\n").await; + let mut state = state_with(vec![npmrc_edit("created")], &[]); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, true).await; + assert!(out.fully_reverted()); + assert!(out.reverted_files.contains(".npmrc")); + assert_eq!(read(dir.path(), ".npmrc").await, "allow-remote=all\n"); + } + + /// The replay twin of the per-purl finding: a symlinked `.npmrc` + /// refuses the npm group while planning (the link and its target are + /// never written), and a section-scoped copy of the line no longer + /// makes the unwind ambiguous. + #[cfg(unix)] + #[tokio::test] + async fn npmrc_symlink_refuses_at_plan_time_and_section_copies_are_inert() { + let dir = TempDir::new().unwrap(); + write(dir.path(), "shared.npmrc", "allow-remote=all\n").await; + std::os::unix::fs::symlink("shared.npmrc", dir.path().join(".npmrc")).unwrap(); + let mut state = state_with(vec![npmrc_edit("created")], &[]); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert_eq!(out.refusals.len(), 1, "{out:?}"); + assert!( + out.refusals[0].reason.contains("not a regular file"), + "{out:?}" + ); + assert_eq!(state.edits.len(), 1); + assert_eq!(read(dir.path(), "shared.npmrc").await, "allow-remote=all\n"); + + let dir = TempDir::new().unwrap(); + write( + dir.path(), + ".npmrc", + "allow-remote=all\n[sec]\nallow-remote=all\n", + ) + .await; + let mut state = state_with(vec![npmrc_edit("added")], &[]); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert!(out.fully_reverted(), "{out:?}"); + assert_eq!( + read(dir.path(), ".npmrc").await, + "[sec]\nallow-remote=all\n" + ); + } + // ---------- pnpm trust ---------- #[tokio::test] @@ -1801,6 +1994,8 @@ mod tests { ("redirect_golang_stale_gosum_removed", "removed"), ("redirect_npm_lock_entry", "rewritten"), ("redirect_npm_lock_dep", "rewritten"), + ("redirect_npmrc_allow_remote", "created"), + ("redirect_npmrc_allow_remote", "added"), ("redirect_maven_repository", "added"), ("redirect_maven_dep_management", "added"), ("redirect_maven_dep_version", "rewritten"), diff --git a/crates/socket-patch-core/src/patch/redirect/state.rs b/crates/socket-patch-core/src/patch/redirect/state.rs index 3ad48de4..84f07873 100644 --- a/crates/socket-patch-core/src/patch/redirect/state.rs +++ b/crates/socket-patch-core/src/patch/redirect/state.rs @@ -38,8 +38,10 @@ pub struct RedirectState { /// hosted pnpm flow's `redirect_pnpm_workspace_trust`, recording the /// auto-configured pnpm-workspace.yaml `trustLockfile: true` with /// `action` `"created"` for a new file or `"added"` for a spliced-in - /// line) must round-trip through ledgers written before they existed, - /// so no field here may ever tighten into an enum. + /// line; likewise the hosted npm flow's `redirect_npmrc_allow_remote` + /// for the project `.npmrc` `allow-remote=all`) must round-trip through + /// ledgers written before they existed, so no field here may ever + /// tighten into an enum. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub edits: Vec, /// PURL -> manifest patch record. Present so VEX can attest redirected diff --git a/crates/socket-patch-core/src/patch/redirect/takeover.rs b/crates/socket-patch-core/src/patch/redirect/takeover.rs index 631897bf..6962d6f5 100644 --- a/crates/socket-patch-core/src/patch/redirect/takeover.rs +++ b/crates/socket-patch-core/src/patch/redirect/takeover.rs @@ -57,6 +57,9 @@ use super::FileEdit; pub struct RedirectRevert { /// Repo-relative files this revert actually rewrote or removed. pub reverted_files: Vec, + /// Advisory (code, detail) pairs — e.g. a redirect-created `.npmrc` + /// that was modified since (`redirect_npmrc_allow_remote_modified`). + pub warnings: Vec<(String, String)>, } /// Does [`revert_redirect_purl`] have an implementation for this purl's @@ -690,6 +693,37 @@ pub async fn revert_npm_redirect_purl( } } + // LAST ONE OUT: the `.npmrc` `allow-remote=all` auto-config exists only + // for package-lock / shrinkwrap hosted entries (npm >= 12 refuses them + // without it). When this purl's revert leaves no such entry in the + // ledger, unwind the recorded `.npmrc` edit(s) in the SAME transaction — + // scoped rollback / remove of the last npm purl and the vendored + // takeover then leave no loosened install policy behind. An ambiguous + // `.npmrc` refuses the whole revert (nothing written), like any drift. + let mut npmrc_staged: Option> = None; + { + let dropping: HashSet = mine.iter().copied().collect(); + // Checked BEFORE the read: the read refuses a symlinked / non-regular + // `.npmrc` here, at plan time — so the whole revert refuses with + // nothing written (flush_npmrc refusing it after flush_staged had + // already written the lock would strand a reverted lock behind a + // ledger that still records the redirect) — but only when the + // unwind is actually due. + if super::npmrc::npmrc_unwind_due(&state.edits, &dropping) { + let current = super::npmrc::read_project_npmrc(project_root)?; + if let Some(plan) = + super::npmrc::plan_unneeded_npmrc_unwind(&state.edits, &dropping, current)? + { + if plan.staged.is_some() { + out.reverted_files.push(super::npmrc::NPMRC_REL.to_string()); + } + npmrc_staged = plan.staged; + out.warnings.extend(plan.warnings); + mine.extend(plan.indices); + } + } + } + // Every inverse resolved — only now does any of it reach disk, so a // refusal above left the project exactly as it was found. A dry run // skips ONLY the disk flush: the in-memory ledger mutation below still @@ -699,6 +733,12 @@ pub async fn revert_npm_redirect_purl( // persists it on a dry run, so nothing durable changes. if !dry_run { flush_staged(project_root, &staged, &staged_bytes).await?; + // After the lock: an I/O fault here leaves the (reverted) lock plus + // a still-present `allow-remote=all` — never a hosted lock entry + // whose `.npmrc` setting was already taken away. + if let Some(npmrc) = &npmrc_staged { + super::npmrc::flush_npmrc(project_root, npmrc).await?; + } } drop_claimed(state, mine, &record_key); @@ -1543,6 +1583,210 @@ mod tests { assert!(state.edits.is_empty(), "edits dropped"); } + fn npmrc_edit(action: &str) -> FileEdit { + FileEdit { + path: ".npmrc".into(), + kind: super::super::npmrc::NPMRC_ALLOW_REMOTE_EDIT_KIND.into(), + action: action.into(), + key: Some("allow-remote".into()), + original: None, + new: Some(serde_json::json!("all")), + } + } + + /// Pristine lockfileVersion 3 package-lock holding two registry deps. + fn two_dep_package_lock() -> String { + let entry = |name: &str, version: &str| { + serde_json::json!({ + "version": version, + "resolved": format!("https://registry.npmjs.org/{name}/-/{name}-{version}.tgz"), + "integrity": "sha512-pristine==" + }) + }; + let lock = serde_json::json!({ + "name": "app", "version": "1.0.0", "lockfileVersion": 3, "requires": true, + "packages": { + "": { "name": "app", "version": "1.0.0" }, + "node_modules/left-pad": entry("left-pad", "1.3.0"), + "node_modules/other": entry("other", "2.0.0"), + } + }); + format!("{}\n", serde_json::to_string_pretty(&lock).unwrap()) + } + + /// The `.npmrc` `allow-remote=all` auto-config is unwound in the SAME + /// transaction as the LAST package-lock purl's revert (created file + /// deleted), and never while another package-lock entry still needs it. + #[tokio::test] + async fn npm_revert_unwinds_npmrc_only_when_the_last_lock_entry_goes() { + let (tmp, mut state) = npm_redirected_fixture_multi( + "package-lock.json", + &two_dep_package_lock(), + &[ + (NPM_PURL, npm_dep()), + ("pkg:npm/other@2.0.0", npm_dep_for("other", "2.0.0")), + ], + ) + .await; + let root = tmp.path(); + tokio::fs::write(root.join(".npmrc"), "allow-remote=all\n") + .await + .unwrap(); + state.edits.push(npmrc_edit("created")); + + // The last-but-one lock entry goes: .npmrc stays. + let out = revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) + .await + .expect("first revert"); + assert!(!out.reverted_files.iter().any(|f| f == ".npmrc"), "{out:?}"); + assert_eq!( + tokio::fs::read_to_string(root.join(".npmrc")) + .await + .unwrap(), + "allow-remote=all\n", + "still needed by the other package-lock entry" + ); + assert!(state + .edits + .iter() + .any(|e| e.kind == "redirect_npmrc_allow_remote")); + + // Dry run of the last one: previews the removal, writes nothing. + let mut probe = state.clone(); + let out = revert_npm_redirect_purl(root, &mut probe, "pkg:npm/other@2.0.0", true) + .await + .expect("dry-run revert"); + assert!(out.reverted_files.iter().any(|f| f == ".npmrc"), "{out:?}"); + assert!(root.join(".npmrc").exists(), "dry run writes nothing"); + + let out = revert_npm_redirect_purl(root, &mut state, "pkg:npm/other@2.0.0", false) + .await + .expect("last revert"); + assert!(out.reverted_files.iter().any(|f| f == ".npmrc"), "{out:?}"); + assert!( + !root.join(".npmrc").exists(), + "the created .npmrc is deleted" + ); + assert_eq!( + tokio::fs::read_to_string(root.join("package-lock.json")) + .await + .unwrap(), + two_dep_package_lock() + ); + assert!( + state.edits.is_empty() && state.records.is_empty(), + "{state:?}" + ); + } + + /// An APPENDED line is removed exactly (user bytes, BOM and CRLF kept); + /// an ambiguous duplicate refuses the whole revert byte-untouched. + #[tokio::test] + async fn npm_revert_removes_only_the_appended_npmrc_line() { + let (tmp, mut state) = + npm_redirected_fixture("package-lock.json", &package_lock_pristine()).await; + let root = tmp.path(); + let wired = tokio::fs::read_to_string(root.join("package-lock.json")) + .await + .unwrap(); + state.edits.push(npmrc_edit("added")); + + tokio::fs::write( + root.join(".npmrc"), + "allow-remote=all\r\n; mine\r\nallow-remote=all\r\n", + ) + .await + .unwrap(); + let err = revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) + .await + .expect_err("ambiguous .npmrc refuses"); + assert!(err.contains("more than once"), "{err}"); + assert_eq!( + tokio::fs::read_to_string(root.join("package-lock.json")) + .await + .unwrap(), + wired, + "a refusal leaves the lock untouched" + ); + + tokio::fs::write( + root.join(".npmrc"), + "\u{feff}registry=https://r.example/\r\nallow-remote=all\r\n", + ) + .await + .unwrap(); + revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) + .await + .expect("revert succeeds"); + assert_eq!( + tokio::fs::read_to_string(root.join(".npmrc")) + .await + .unwrap(), + "\u{feff}registry=https://r.example/\r\n" + ); + assert!(state.edits.is_empty(), "{state:?}"); + } + + /// Finding: a symlinked `.npmrc` passed planning (the read followed + /// the link), `flush_staged` wrote the reverted lock, and only then did + /// `flush_npmrc` refuse the link — leaving the lock un-hosted while the + /// ledger still recorded the redirect. The refusal now happens while + /// planning: lock byte-identical, ledger untouched. While another + /// package-lock entry still needs the setting, the odd `.npmrc` shape + /// does not block the revert at all (the file is never read). + #[cfg(unix)] + #[tokio::test] + async fn npm_revert_refuses_a_symlinked_npmrc_before_writing_anything() { + let (tmp, mut state) = npm_redirected_fixture_multi( + "package-lock.json", + &two_dep_package_lock(), + &[ + (NPM_PURL, npm_dep()), + ("pkg:npm/other@2.0.0", npm_dep_for("other", "2.0.0")), + ], + ) + .await; + let root = tmp.path(); + tokio::fs::write(root.join("shared.npmrc"), "allow-remote=all\n") + .await + .unwrap(); + std::os::unix::fs::symlink("shared.npmrc", root.join(".npmrc")).unwrap(); + state.edits.push(npmrc_edit("created")); + + // Not the last lock entry: the unwind is not due, the link is fine. + revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) + .await + .expect("first revert is not blocked by the .npmrc shape"); + + let wired = tokio::fs::read_to_string(root.join("package-lock.json")) + .await + .unwrap(); + let before = state.clone(); + let err = revert_npm_redirect_purl(root, &mut state, "pkg:npm/other@2.0.0", false) + .await + .expect_err("symlinked .npmrc refuses the last revert"); + assert!(err.contains("not a regular file"), "{err}"); + assert_eq!( + tokio::fs::read_to_string(root.join("package-lock.json")) + .await + .unwrap(), + wired, + "the lock must not be reverted behind the refusal" + ); + assert_eq!(state.edits.len(), before.edits.len(), "ledger untouched"); + assert_eq!( + state.records.len(), + before.records.len(), + "ledger untouched" + ); + assert!(root + .join(".npmrc") + .symlink_metadata() + .unwrap() + .file_type() + .is_symlink()); + } + /// Pristine pnpm v6 lock holding a PLAIN instance and a resolved-peer /// instance of the same purl: the rewriter records one edit per /// instance, keying the peered one `@(@)`. diff --git a/crates/socket-patch-core/src/utils/fs.rs b/crates/socket-patch-core/src/utils/fs.rs index e69f5ad8..b2ef02d0 100644 --- a/crates/socket-patch-core/src/utils/fs.rs +++ b/crates/socket-patch-core/src/utils/fs.rs @@ -366,6 +366,33 @@ pub async fn atomic_write_bytes_preserving_mode( atomic_write_bytes_as(path, content, perms).await } +/// Create the stage file for [`atomic_write_bytes_as`]. On Unix, when the +/// destination's permissions are being preserved, the stage is CREATED with +/// those bits (narrowed further by the umask) rather than the 0666 & ~umask +/// default: the full new content — a `.npmrc` `_authToken`, a 0600 private +/// manifest — is written and fsynced into the stage before the final +/// chmod, so a default-mode stage would expose it to other local users for +/// the whole write, and leave a world-readable copy behind if the process +/// is killed before the rename. +async fn create_stage( + stage: &Path, + perms: Option<&std::fs::Permissions>, +) -> std::io::Result { + let mut options = tokio::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + if let Some(p) = perms { + use std::os::unix::fs::PermissionsExt; + // Permission bits only (never setuid/setgid/sticky on a stage). A + // read-only mode (0400) is fine: O_CREAT still hands back a + // writable descriptor for the file it just created. + options.mode(p.mode() & 0o777); + } + #[cfg(not(unix))] + let _ = perms; + options.open(stage).await +} + async fn atomic_write_bytes_as( path: &Path, content: &[u8], @@ -380,11 +407,7 @@ async fn atomic_write_bytes_as( // `create_new` failing leaves no stage to clean up; every step after it // does, so they share one error arm. - let file = tokio::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&stage) - .await?; + let file = create_stage(&stage, perms.as_ref()).await?; if let Err(e) = commit_stage(file, content, perms, &stage, path).await { let _ = tokio::fs::remove_file(&stage).await; return Err(e); @@ -655,6 +678,38 @@ mod tests { assert_eq!(tokio::fs::read(&fresh).await.unwrap(), b"x"); } + /// The stage of a mode-preserving write is CREATED with the preserved + /// bits, never the 0666 & ~umask default: the full new content (a + /// `.npmrc` auth token) is written and fsynced into it before the final + /// chmod, and a killed process leaves it behind. Red before the fix: + /// the 0600 destination's stage came out 0644 (umask 022). + #[cfg(unix)] + #[tokio::test] + async fn preserving_stage_is_created_with_the_destination_mode() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + for mode in [0o600, 0o400, 0o640] { + let stage = tmp.path().join(format!(".socket-stage-npmrc-{mode:o}")); + let perms = std::fs::Permissions::from_mode(mode); + let mut file = create_stage(&stage, Some(&perms)).await.unwrap(); + use tokio::io::AsyncWriteExt; + // A read-only preserved mode still yields a writable stage fd. + file.write_all(b"//r/:_authToken=secret\n").await.unwrap(); + file.flush().await.unwrap(); + let got = std::fs::metadata(&stage).unwrap().permissions().mode() & 0o777; + assert_eq!(got & !mode, 0, "stage {got:o} must not exceed {mode:o}"); + assert_eq!( + got & 0o077 & !mode, + 0, + "no group/other bits beyond {mode:o}" + ); + } + // No preserved mode: the plain umask default, as before. + let plain = tmp.path().join(".socket-stage-plain"); + create_stage(&plain, None).await.unwrap(); + assert!(plain.is_file()); + } + /// The post-rename parent-directory fsync is best-effort: when the /// parent can be traversed and written but not opened for read /// (mode 0o333 — the stage create, the stage write, and the rename From 56d791fa4d2846aeb7e4716ca01d188af24c2cf1 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 23 Sep 2026 21:10:49 -0400 Subject: [PATCH 3/9] refactor(core/vendor): split lock_inventory into per-format submodules A verbatim move with no behavior change, so the next commit can give each lock format its shared entry model beside its registry view: `vendor/lock_inventory.rs` becomes a directory module with one file per format (`npm`, `npm_family`, `pnpm`, `yarn`, `bun`, `cargo`, `golang`, `composer`, `gem`, `pypi`), ledger recovery (`recover`) and the rewired-lock trust anchor (`wired`). `mod.rs` keeps the public API (`LockIntegrity`, `LockfileEntry`, `UnsupportedNpmLayout`, `lookup`, `inventory_project(_diagnosed)`, `recover_lock_entry`, `wired_vendor_integrity`); the three test modules move to `tests.rs`, `recover_tests.rs` and `python_lock_union_tests.rs`. Only imports, visibility (helpers another file calls become `pub(super)`), sibling-module paths (`super::state` -> `crate::vendor::state`), module docs and the single-file section banners changed; the test modules lost one indentation level and were re-wrapped by rustfmt. `git show --color-moved` shows everything else as moved. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/vendor/lock_inventory.rs | 5380 ----------------- .../src/vendor/lock_inventory/bun.rs | 91 + .../src/vendor/lock_inventory/cargo.rs | 80 + .../src/vendor/lock_inventory/composer.rs | 88 + .../src/vendor/lock_inventory/gem.rs | 168 + .../src/vendor/lock_inventory/golang.rs | 44 + .../src/vendor/lock_inventory/mod.rs | 254 + .../src/vendor/lock_inventory/npm.rs | 66 + .../src/vendor/lock_inventory/npm_family.rs | 210 + .../src/vendor/lock_inventory/pnpm.rs | 171 + .../src/vendor/lock_inventory/pypi.rs | 570 ++ .../lock_inventory/python_lock_union_tests.rs | 164 + .../src/vendor/lock_inventory/recover.rs | 466 ++ .../vendor/lock_inventory/recover_tests.rs | 621 ++ .../src/vendor/lock_inventory/tests.rs | 2229 +++++++ .../src/vendor/lock_inventory/wired.rs | 192 + .../src/vendor/lock_inventory/yarn.rs | 80 + 17 files changed, 5494 insertions(+), 5380 deletions(-) delete mode 100644 crates/socket-patch-core/src/vendor/lock_inventory.rs create mode 100644 crates/socket-patch-core/src/vendor/lock_inventory/bun.rs create mode 100644 crates/socket-patch-core/src/vendor/lock_inventory/cargo.rs create mode 100644 crates/socket-patch-core/src/vendor/lock_inventory/composer.rs create mode 100644 crates/socket-patch-core/src/vendor/lock_inventory/gem.rs create mode 100644 crates/socket-patch-core/src/vendor/lock_inventory/golang.rs create mode 100644 crates/socket-patch-core/src/vendor/lock_inventory/mod.rs create mode 100644 crates/socket-patch-core/src/vendor/lock_inventory/npm.rs create mode 100644 crates/socket-patch-core/src/vendor/lock_inventory/npm_family.rs create mode 100644 crates/socket-patch-core/src/vendor/lock_inventory/pnpm.rs create mode 100644 crates/socket-patch-core/src/vendor/lock_inventory/pypi.rs create mode 100644 crates/socket-patch-core/src/vendor/lock_inventory/python_lock_union_tests.rs create mode 100644 crates/socket-patch-core/src/vendor/lock_inventory/recover.rs create mode 100644 crates/socket-patch-core/src/vendor/lock_inventory/recover_tests.rs create mode 100644 crates/socket-patch-core/src/vendor/lock_inventory/tests.rs create mode 100644 crates/socket-patch-core/src/vendor/lock_inventory/wired.rs create mode 100644 crates/socket-patch-core/src/vendor/lock_inventory/yarn.rs diff --git a/crates/socket-patch-core/src/vendor/lock_inventory.rs b/crates/socket-patch-core/src/vendor/lock_inventory.rs deleted file mode 100644 index 1682dd40..00000000 --- a/crates/socket-patch-core/src/vendor/lock_inventory.rs +++ /dev/null @@ -1,5380 +0,0 @@ -//! Read-only lockfile inventories: the dependency set a project's lockfile -//! resolves, independent of what is installed on disk. -//! -//! Two consumers: -//! -//! * `scan` supplements its installed-tree crawl with lockfile-only entries -//! (discovery on fresh clones and partial installs), warning that those -//! packages are not yet installed; -//! * `vendor` fetches the pristine artifact for a lockfile-resolved package -//! with no installed copy ([`super::registry_fetch`]), verifying the bytes -//! against the integrity the lock records — FAIL-CLOSED: an entry whose -//! lock carries no content verifier is never fetched. -//! -//! Parsing is fail-soft per entry (a malformed entry is skipped, never an -//! error; a malformed text file yields `None`, while a malformed binary Bun -//! lock emits `bun_lockb_invalid`) and fail-closed per value: -//! names/versions are path-safety-guarded before an entry is emitted — the -//! lockfile is committed, tamperable input that later feeds filesystem paths -//! and download URLs. - -use std::collections::HashMap; -use std::path::Path; - -use serde_json::Value; -use toml_edit::{DocumentMut, Item, TableLike, Value as TomlValue}; - -use crate::crawlers::composer_crawler::normalize_version; -use crate::crawlers::python_crawler::canonicalize_pypi_name; -use crate::patch::path_safety; -use crate::utils::fs::{read_regular_to_bytes, read_regular_to_string}; -use crate::utils::purl::{percent_decode_purl_component, strip_purl_qualifiers}; -use crate::vendor::bun_lock_text; - -use super::npm_common::is_safe_npm_name; -use super::npm_flavor::{detect_npm_lock_flavor, NpmLockFlavor}; -use super::path::parse_vendor_path; -use super::{pnpm_lock, yarn_berry_lock, yarn_classic_lock}; - -/// The content verifier a lockfile records for an entry. The fetch layer -/// refuses entries whose verifier is [`LockIntegrity::None`]. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum LockIntegrity { - /// SRI string (`sha512-`, possibly multi-hash space-separated) — - /// npm family; verified against the raw tarball bytes. - Sri(String), - /// yarn classic `resolved "...#"` fragment (40-hex) — verified - /// against the raw tarball bytes. - Sha1Hex(String), - /// yarn berry cache-zip checksum (`/`, e.g. `10c0/…`) — - /// verified by rebuilding the deterministic cache zip from the fetched - /// tarball and comparing (the lock never hashes the tarball itself). - BerryChecksum(String), - /// Hex sha256 of the artifact (Cargo.lock `checksum`, pypi file hashes, - /// Gemfile.lock `CHECKSUMS`). - Sha256Hex(String), - /// One of several hex sha256 digests: the lock records every release - /// file's digest without saying which file is which (Pipfile.lock - /// `hashes`), so the fetcher picks the pure-Python wheel whose PyPI - /// digest is in the set and verifies the download against that digest. - Sha256AnyOf(Vec), - /// go.sum module-zip dirhash (`h1:`). - GoH1(String), - /// The lock records no content verifier. - None, -} - -/// One lockfile-resolved package. -#[derive(Debug, Clone)] -pub struct LockfileEntry { - /// Vendor-ecosystem tag (`npm`, `cargo`, `golang`, `pypi`, `gem`, - /// `composer`) — matches `VendorEntry::ecosystem`. - pub ecosystem: &'static str, - /// Literal (percent-decoded) package name, e.g. `@scope/name`. - pub name: String, - /// Exact resolved version. - pub version: String, - /// Canonical literal purl (`pkg:npm/@scope/name@1.0.0`) — the same form - /// the crawlers emit. - pub purl: String, - /// Artifact URL when the lock records one (package-lock `resolved`, - /// yarn `resolved` minus its `#sha1` fragment, pnpm `tarball:`); `None` - /// means the fetcher constructs the conventional registry URL. - pub resolved: Option, - pub integrity: LockIntegrity, -} - -impl LockfileEntry { - fn npm( - name: impl Into, - version: impl Into, - resolved: Option, - integrity: LockIntegrity, - ) -> Self { - let (name, version) = (name.into(), version.into()); - let purl = format!("pkg:npm/{name}@{version}"); - LockfileEntry { - ecosystem: "npm", - name, - version, - purl, - resolved, - integrity, - } - } -} - -/// A project layout or lockfile that cannot be inventoried safely. -/// Consumers surface these diagnoses instead of treating an unreadable -/// dependency graph as an empty project. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct UnsupportedNpmLayout { - /// Stable diagnosis code, including `bun_lockb_invalid` for malformed - /// binary Bun locks and the flavor probe's Plug'n'Play refusal codes. - pub code: &'static str, - /// Human-readable diagnosis with format or filesystem error details. - pub detail: String, -} - -/// Inventory the project's npm-family lockfile. Routes by -/// [`detect_npm_lock_flavor`]. `Ok(None)` means there is nothing to -/// inventory (missing lockfile, dep-less locks); `Err` propagates the -/// probe's Plug'n'Play diagnosis — a layout whose packages the inventory -/// can NEVER serve — and malformed binary Bun locks, which callers must -/// not conflate with the calm no-lockfile case. Two -/// pnpm-specific refusals fall back instead of -/// yielding `None`: an unsupported `lockfileVersion` reads the root -/// `pnpm-lock.yaml` directly — unless a live sibling lock the router would -/// otherwise have chosen sits beside it (a pnpm→yarn/npm migration -/// leftover), in which case the SIBLING is inventoried instead -/// ([`inventory_live_sibling_lock`]) — and `vendor_lockfile_missing` reads -/// the pnpm <=2-era `shrinkwrap.yaml` (same v5 grammar, older filename). -/// Any remaining probe failure falls back to Rush's common lock when -/// `rush.json` is present. -pub(crate) async fn inventory_npm_lock( - project_root: &Path, -) -> Result)>, UnsupportedNpmLayout> { - let (flavor, _warnings) = match detect_npm_lock_flavor(project_root).await { - Ok(found) => found, - Err((code, detail)) => { - // The PnP loaders are a refusal, not an absence: propagate the - // diagnosis instead of discarding it. Under PnP the - // installed-tree crawl is ALSO structurally empty, so - // swallowing this here made `scan` a silent success-0 no-op in - // every mode. Every other probe error keeps the fallbacks below - // and the calm `Ok(None)`. - if matches!( - code, - "vendor_yarn_berry_unsupported" | "vendor_pnpm_pnp_unsupported" - ) { - return Err(UnsupportedNpmLayout { code, detail }); - } - // The flavor probe passes only pnpm locks the WIRING backends - // support (lockfileVersion 5.4/6.0/9.0), but inventory is - // read-only discovery — an out-of-family (pnpm <= 6-era or - // future) lock still names the resolved set, so on the probe's - // pnpm version refusal a present root lock is read directly - // rather than leaving fresh clones of such projects blind. Only - // that code: on any other refusal a root pnpm-lock.yaml is - // stale debris from a migration, and inventorying it would - // present dead resolutions as the live dependency set. - // (`vendor_lockfile_version_unsupported` also covers the - // unrecognizable-yarn.lock refusal, but the probe only sniffs - // yarn.lock when no root pnpm-lock.yaml exists, so the direct - // read is a no-op there.) - if code == "vendor_lockfile_version_unsupported" { - // The version refusal fires from the probe's pnpm step, - // which runs BEFORE its yarn/npm steps — so it says nothing - // about whether a LIVE sibling lock sits beside the refused - // pnpm lock (a pnpm→yarn/npm migration leaves exactly that - // shape behind). Prefer whichever sibling the router would - // have chosen had the pnpm lock not shadowed it; only a - // sibling-less project is a genuine old-pnpm project whose - // lock the fallback may surface. - match inventory_live_sibling_lock(project_root).await { - Some((flavor, entries)) if !entries.is_empty() => { - return Ok(Some((flavor, finalize_npm(entries)))); - } - // A sibling lock FILE exists but yields no entries - // (dep-less project, or a grammar we cannot read): the - // migration still happened, so the pnpm lock stays out — - // blind beats presenting dead resolutions as live. - Some(_) => {} - None => { - let pnpm = inventory_pnpm_lock(project_root).await.unwrap_or_default(); - if !pnpm.is_empty() { - return Ok(Some((NpmLockFlavor::Pnpm, finalize_npm(pnpm)))); - } - } - } - } - // pnpm 1/2 wrote the v5-era lock grammar under the name - // `shrinkwrap.yaml` (shrinkwrapVersion 3) — pnpm 3 renamed the - // file to pnpm-lock.yaml. The flavor probe doesn't know that - // filename, so such a project refuses as - // `vendor_lockfile_missing`; the lock still names the full - // resolved set, so read it directly rather than leaving pnpm<=2 - // projects (and their fresh clones) lockfile-blind. Gated on - // that ONE code: any other refusal means a DIFFERENT lock - // family is present (bun markers, an unsupported recognized - // lock), where a shrinkwrap.yaml is stale debris from a - // long-ago migration whose dead resolutions must not pose as - // the live dependency set. - if code == "vendor_lockfile_missing" { - let legacy = inventory_pnpm_lock_at(&project_root.join("shrinkwrap.yaml")) - .await - .unwrap_or_default(); - if !legacy.is_empty() { - return Ok(Some((NpmLockFlavor::PnpmLegacy, finalize_npm(legacy)))); - } - } - // Rush monorepos have no root package.json/lock pair; their - // single pnpm source-of-truth lives under common/config/rush/. - // The flavor probe (root-relative) can't see it, so fall back - // explicitly when the root lock is absent but rush.json is - // present. - let rush = inventory_rush_pnpm_locks(project_root).await; - return Ok((!rush.is_empty()).then(|| (NpmLockFlavor::Pnpm, finalize_npm(rush)))); - } - }; - let raw = match flavor { - NpmLockFlavor::PackageLock => inventory_package_lock(project_root).await, - // The pnpm reader is grammar-agnostic (it already served legacy - // 5.4/6.0 locks through the refusal fallback below before those - // grammars had a wiring backend), so both pnpm flavors share it. - NpmLockFlavor::Pnpm | NpmLockFlavor::PnpmLegacy => inventory_pnpm_lock(project_root).await, - NpmLockFlavor::YarnClassic => inventory_yarn_classic(project_root).await, - NpmLockFlavor::YarnBerry => inventory_yarn_berry(project_root).await, - NpmLockFlavor::Bun => { - if tokio::fs::symlink_metadata(project_root.join("bun.lock")) - .await - .is_ok() - { - inventory_bun(project_root).await - } else { - Some(inventory_bun_binary(project_root).await?) - } - } - }; - Ok(raw.map(|raw| (flavor, finalize_npm(raw)))) -} - -/// The live sibling lock a version-refused root `pnpm-lock.yaml` may be -/// shadowing, or `None` when no sibling lock file exists at all. -/// -/// [`detect_npm_lock_flavor`] cannot be re-asked (it already refused on its -/// pnpm step), so this mirrors the rest of its precedence by hand — bun, -/// then yarn, then npm — on file EXISTENCE, and returns the first present -/// sibling's inventory (possibly empty: presence alone proves the pnpm lock -/// is migration debris, so the caller must not fall back to it). Raw -/// entries — the caller applies [`finalize_npm`]. -async fn inventory_live_sibling_lock(root: &Path) -> Option<(NpmLockFlavor, Vec)> { - let exists = |name: &str| { - let p = root.join(name); - async move { tokio::fs::metadata(&p).await.is_ok() } - }; - // bun.lock — router step 2. That step runs BEFORE the pnpm sniff, so - // when the version refusal fired no bun.lock can actually be present; - // probed anyway to keep this a literal transcription of the router's - // order. The binary lock shares the same routing precedence. - if exists("bun.lock").await { - return Some(( - NpmLockFlavor::Bun, - inventory_bun(root).await.unwrap_or_default(), - )); - } - if exists("bun.lockb").await { - return Some(( - NpmLockFlavor::Bun, - inventory_bun_binary(root).await.unwrap_or_default(), - )); - } - // yarn.lock — router step 4, where classic vs berry is a content - // decision. Rather than re-deriving that head sniff, try both readers: - // each yields entries only for its own grammar (classic's `version "…"` - // fields vs berry's `resolution:` lines), so a non-empty result is the - // sniff's answer. Berry PnP needs no carve-out: a PnP marker would have - // refused at the router's step 1 with a code this fallback ignores. - if exists("yarn.lock").await { - let classic = inventory_yarn_classic(root).await.unwrap_or_default(); - if !classic.is_empty() { - return Some((NpmLockFlavor::YarnClassic, classic)); - } - return Some(( - NpmLockFlavor::YarnBerry, - inventory_yarn_berry(root).await.unwrap_or_default(), - )); - } - // npm — router step 5 (`inventory_package_lock` itself prefers the - // shrinkwrap when both exist, mirroring npm). - if exists("npm-shrinkwrap.json").await || exists("package-lock.json").await { - return Some(( - NpmLockFlavor::PackageLock, - inventory_package_lock(root).await.unwrap_or_default(), - )); - } - None -} - -/// Match a manifest/API purl (possibly percent-encoded, possibly carrying -/// qualifiers) against the inventory: components decode via -/// [`crate::utils::purl::normalize_purl`], so `pkg:npm/%40scope/x@1` -/// matches the literal entry. -pub fn lookup<'a>(entries: &'a [LockfileEntry], purl: &str) -> Option<&'a LockfileEntry> { - let decoded = crate::utils::purl::normalize_purl(strip_purl_qualifiers(purl)).into_owned(); - let rest = decoded.strip_prefix("pkg:")?; - let (purl_type, rest) = rest.split_once('/')?; - // purl types double as the vendor-ecosystem tags (same set the - // dispatcher recognizes). - let eco = match purl_type { - "npm" | "cargo" | "golang" | "pypi" | "gem" | "composer" => purl_type, - _ => return None, - }; - let at = rest.rfind('@').filter(|&i| i > 0)?; - let (name, version) = (&rest[..at], &rest[at + 1..]); - // pypi names compare in PEP 503 normalized form. - let name = if eco == "pypi" { - canonicalize_pypi_name(name) - } else { - name.to_string() - }; - entries - .iter() - .find(|e| e.ecosystem == eco && e.name == name && e.version == version) -} - -/// Everything every recognized lockfile in the project resolves — the -/// union the scan supplement and the vendor auto-fetch consume. Drops the -/// npm-layout diagnosis; callers that must surface refusals (scan) use -/// [`inventory_project_diagnosed`]. -pub async fn inventory_project(project_root: &Path) -> Vec { - inventory_project_diagnosed(project_root).await.0 -} - -/// [`inventory_project`] plus the npm-family layout refusals it hit: a -/// Plug'n'Play project yields no npm entries AND a diagnosis, so consumers -/// can tell "nothing to inventory" from "packages structurally unreachable" -/// and refuse explicitly instead of silently reporting an empty project. -pub async fn inventory_project_diagnosed( - project_root: &Path, -) -> (Vec, Vec) { - let mut out: Vec = Vec::new(); - let mut unsupported: Vec = Vec::new(); - match inventory_npm_lock(project_root).await { - Ok(Some((_, entries))) => out.extend(entries), - Ok(None) => {} - Err(diag) => unsupported.push(diag), - } - if let Some(entries) = inventory_cargo_lock(project_root).await { - out.extend(entries); - } - if let Some(entries) = inventory_go_sum(project_root).await { - out.extend(entries); - } - if let Some(entries) = inventory_composer_lock(project_root).await { - out.extend(entries); - } - if let Some(entries) = inventory_gemfile_lock(project_root).await { - out.extend(entries); - } - if let Some(entries) = inventory_pypi_locks(project_root).await { - out.extend(entries); - } - (out, unsupported) -} - -/// Guard + dedup the raw npm entries: unsafe names/versions are dropped -/// fail-closed; duplicate (name, version) instances collapse to one, -/// preferring the instance that carries a verifier. -fn finalize_npm(raw: Vec) -> Vec { - dedup_prefer_integrity( - raw.into_iter() - .filter(|e| { - is_safe_npm_name(&e.name) && path_safety::is_safe_single_segment(&e.version) - }) - .collect(), - ) -} - -/// Collapse duplicate (name, version) instances, preferring one that -/// carries a verifier. -fn dedup_prefer_integrity(raw: Vec) -> Vec { - let mut seen: HashMap<(String, String), usize> = HashMap::new(); - let mut out: Vec = Vec::new(); - for entry in raw { - let key = (entry.name.clone(), entry.version.clone()); - match seen.get(&key) { - Some(&i) => { - if out[i].integrity == LockIntegrity::None && entry.integrity != LockIntegrity::None - { - out[i] = entry; - } - } - None => { - seen.insert(key, out.len()); - out.push(entry); - } - } - } - out -} - -// ──────────────────────────────── Cargo.lock ──────────────────────────────── - -/// Inventory `Cargo.lock` `[[package]]` blocks. Only crates.io-sourced -/// entries are fetchable (their `checksum` is the sha256 of the `.crate` -/// file); workspace members (no `source`) are skipped, and git/custom- -/// registry sources stay listed for discovery without a verifier. -async fn inventory_cargo_lock(project_root: &Path) -> Option> { - let text = read_regular_to_string(&project_root.join("Cargo.lock")) - .await - .ok()?; - /// One in-flight `[[package]]` block: name, version, source, checksum. - type CargoBlock = ( - Option, - Option, - Option, - Option, - ); - let mut out = Vec::new(); - let mut cur: Option = None; - let flush = |cur: &mut Option, out: &mut Vec| { - if let Some((Some(name), Some(version), source, checksum)) = cur.take() { - let Some(source) = source else { - return; // workspace member - }; - if !path_safety::is_safe_single_segment(&name) - || !path_safety::is_safe_single_segment(&version) - { - return; - } - let crates_io = source.contains("github.com/rust-lang/crates.io-index") - || source.contains("index.crates.io"); - let integrity = match checksum { - Some(c) if crates_io && is_hex_of_len(&c, 64) => LockIntegrity::Sha256Hex(c), - _ => LockIntegrity::None, - }; - let purl = format!("pkg:cargo/{name}@{version}"); - out.push(LockfileEntry { - ecosystem: "cargo", - name, - version, - purl, - resolved: None, - integrity, - }); - } - }; - for line in text.lines() { - let line = line.trim(); - if line == "[[package]]" { - flush(&mut cur, &mut out); - cur = Some((None, None, None, None)); - continue; - } - if line.starts_with('[') { - flush(&mut cur, &mut out); - continue; - } - let Some(slot) = cur.as_mut() else { continue }; - let Some((key, value)) = line.split_once('=') else { - continue; - }; - let value = value.trim().trim_matches('"').to_string(); - match key.trim() { - "name" => slot.0 = Some(value), - "version" => slot.1 = Some(value), - "source" => slot.2 = Some(value), - "checksum" => slot.3 = Some(value), - _ => {} - } - } - flush(&mut cur, &mut out); - Some(dedup_prefer_integrity(out)) -} - -// ────────────────────────────────── go.sum ────────────────────────────────── - -/// Inventory `go.sum` module-zip lines (` h1:`); the -/// `/go.mod`-suffixed lines hash only the manifest and are skipped. go.sum -/// may list more modules than the final build graph — acceptable for -/// discovery, and the manifest decides what actually gets vendored. -async fn inventory_go_sum(project_root: &Path) -> Option> { - let text = read_regular_to_string(&project_root.join("go.sum")) - .await - .ok()?; - let mut out = Vec::new(); - for line in text.lines() { - let mut parts = line.split_whitespace(); - let (Some(module), Some(version), Some(hash)) = (parts.next(), parts.next(), parts.next()) - else { - continue; - }; - if version.ends_with("/go.mod") || !hash.starts_with("h1:") { - continue; - } - // SECURITY: module path segments and the version feed paths/URLs. - if !path_safety::is_safe_multi_segment(module) - || !path_safety::is_safe_single_segment(version) - { - continue; - } - out.push(LockfileEntry { - ecosystem: "golang", - name: module.to_string(), - version: version.to_string(), - purl: format!("pkg:golang/{module}@{version}"), - resolved: None, - integrity: LockIntegrity::GoH1(hash.to_string()), - }); - } - Some(dedup_prefer_integrity(out)) -} - -/// Keep a lock-recorded URL only when it is a plain http(s) artifact URL -/// (drops `git+…`, `file:…`, `link:…` — content the registry conventions -/// cannot reproduce; such entries stay listed for discovery but the fetch -/// layer's integrity rule decides fetchability). -fn http_url(raw: &str) -> Option { - (raw.starts_with("https://") || raw.starts_with("http://")).then(|| raw.to_string()) -} - -fn is_hex_of_len(s: &str, len: usize) -> bool { - s.len() == len && s.bytes().all(|b| b.is_ascii_hexdigit()) -} - -// ──────────────────── package-lock.json / npm-shrinkwrap ──────────────────── - -async fn inventory_package_lock(root: &Path) -> Option> { - // Shrinkwrap wins, mirroring `npm_lock::select_lockfile`. - let mut bytes = None; - for lock in ["npm-shrinkwrap.json", "package-lock.json"] { - if let Ok(b) = read_regular_to_bytes(&root.join(lock)).await { - bytes = Some(b); - break; - } - } - let doc: Value = serde_json::from_slice(&bytes?).ok()?; - // v1 legacy locks have no `packages` map — no inventory (documented). - let packages = doc.get("packages")?.as_object()?; - - let mut out = Vec::new(); - for (key, node) in packages { - // "" is the root project; keys without node_modules/ are workspace - // members (mirrors npm_lock::scan_lock_matches' member rule). - let Some((_, key_name)) = key.rsplit_once("node_modules/") else { - continue; - }; - if node.get("link").and_then(Value::as_bool).unwrap_or(false) - || node - .get("inBundle") - .and_then(Value::as_bool) - .unwrap_or(false) - { - continue; - } - let name = node - .get("name") - .and_then(Value::as_str) - .unwrap_or(key_name) - .to_string(); - let Some(version) = node.get("version").and_then(Value::as_str) else { - continue; - }; - let resolved_raw = node.get("resolved").and_then(Value::as_str); - // Our own vendored spec: not a registry dependency. - if resolved_raw.is_some_and(|r| parse_vendor_path(r).is_some()) { - continue; - } - let integrity = node - .get("integrity") - .and_then(Value::as_str) - .map(|i| LockIntegrity::Sri(i.to_string())) - .unwrap_or(LockIntegrity::None); - out.push(LockfileEntry::npm( - name, - version, - resolved_raw.and_then(http_url), - integrity, - )); - } - Some(out) -} - -// ────────────────────────────── pnpm-lock.yaml ────────────────────────────── - -async fn inventory_pnpm_lock(root: &Path) -> Option> { - inventory_pnpm_lock_at(&root.join("pnpm-lock.yaml")).await -} - -/// Inventory a specific `pnpm-lock.yaml` (path given explicitly so the Rush -/// fallback can point it at `common/config/rush/…` and subspace locks). -async fn inventory_pnpm_lock_at(lock_path: &Path) -> Option> { - let text = read_regular_to_string(lock_path).await.ok()?; - let lines = pnpm_lock::split_lines(&text); - let (start, end) = pnpm_lock::section_bounds(&lines, "packages")?; - - let mut out = Vec::new(); - let mut i = start + 1; - while let Some(block) = pnpm_lock::next_block(&lines, i, end) { - i = block.end; - // Key grammar by lock generation: v9 `name@version`, v6 (pnpm 8) - // the same behind a leading `/`, v5.4 (pnpm 7) `/name/version` — - // names may be scoped (`@scope/name`) in all three. Peer suffixes: - // v6/v9 append `(peer@1.2.3)…` after the version; v5 appends - // `_peer@x`/`_` to the version itself. - let trimmed = match block.key.find('(') { - Some(p) => block.key[..p].trim_end(), - None => block.key.as_str(), - }; - let (base, legacy) = match trimmed.strip_prefix('/') { - Some(stripped) => (stripped, true), - None => (trimmed, false), - }; - let Some((name, version)) = split_pnpm_key(base, legacy) else { - continue; - }; - // Only plain registry versions: `file:`/`link:`/`https:`/git specs - // are not registry-resolvable. - if !version.chars().next().is_some_and(|c| c.is_ascii_digit()) { - continue; - } - let mut integrity = LockIntegrity::None; - let mut tarball: Option = None; - let entry_lines = &lines[block.header + 1..block.end]; - for (j, line) in entry_lines.iter().enumerate() { - let t = line.trim(); - let Some(rest) = t.strip_prefix("resolution:") else { - continue; - }; - if rest.trim().is_empty() { - // shrinkwrap.yaml (pnpm <=2, shrinkwrapVersion 3) nests the - // resolution as a BLOCK mapping — - // resolution: - // integrity: sha512-… - // — where every pnpm-lock.yaml generation writes the inline - // `resolution: {…}` flow map. Its fields are exactly the - // following deeper-indented lines (a shallower or blank - // line ends the mapping). - let indent = pnpm_lock::indent_of(line); - for child in &entry_lines[j + 1..] { - if child.trim().is_empty() || pnpm_lock::indent_of(child) <= indent { - break; - } - if let Some(v) = inline_yaml_field(child, "integrity:") { - integrity = LockIntegrity::Sri(v); - } - if let Some(v) = inline_yaml_field(child, "tarball:") { - tarball = Some(v); - } - } - } else { - if let Some(v) = inline_yaml_field(rest, "integrity:") { - integrity = LockIntegrity::Sri(v); - } - tarball = inline_yaml_field(rest, "tarball:"); - } - break; - } - // Our own vendored spec: not a registry dependency. - if tarball - .as_deref() - .is_some_and(|t| parse_vendor_path(t).is_some()) - { - continue; - } - out.push(LockfileEntry::npm( - name, - version, - tarball.as_deref().and_then(http_url), - integrity, - )); - } - Some(out) -} - -/// Split a peer-paren-stripped, slash-stripped pnpm packages key into -/// `(name, version)`; `None` is skipped by the caller, never guessed. -/// `legacy` marks a key that carried the v5/v6 leading `/` — only those may -/// use the v5 `name/version` grammar. What tells v5 `/@scope/name/1.2.3` -/// apart from v6 `/@scope/name@1.2.3` is the segment after the last `/`: -/// a v5 version (its `_peer`/`_hash` suffix dropped) starts with a digit -/// and never contains `@`, while a v6 scoped key's trailing segment is -/// `name@version`. v5 non-default-registry keys (`example.com/name/1.2.3`) -/// carry no leading `/` and fall through to the `@` split, where they are -/// dropped fail-closed downstream. -fn split_pnpm_key(base: &str, legacy: bool) -> Option<(&str, &str)> { - if legacy { - if let Some((name, rest)) = base.rsplit_once('/') { - let version = rest.split('_').next().unwrap_or(rest); - if !name.is_empty() - && version.chars().next().is_some_and(|c| c.is_ascii_digit()) - && !version.contains('@') - { - return Some((name, version)); - } - } - } - let at = base.rfind('@').filter(|&p| p > 0)?; - Some((&base[..at], &base[at + 1..])) -} - -// ─────────────────────────────── Rush monorepo ─────────────────────────────── - -/// Inventory a Rush monorepo's pnpm locks. Rush keeps a single -/// source-of-truth lock at `common/config/rush/pnpm-lock.yaml` and, when -/// subspaces are enabled, one lock per subspace under -/// `common/config/subspaces//pnpm-lock.yaml`. `rush install` copies -/// the source lock into common/temp and runs pnpm there. -/// -/// Only called (via [`inventory_npm_lock`]) when there is NO root lock but -/// `rush.json` is present, so it never shadows a plain pnpm project. The -/// subspace directory is read sorted for deterministic output. Missing -/// files/dirs are skipped fail-soft; the caller drops the whole result when -/// it comes back empty. -async fn inventory_rush_pnpm_locks(project_root: &Path) -> Vec { - if tokio::fs::metadata(project_root.join("rush.json")) - .await - .is_err() - { - return Vec::new(); - } - let mut out = Vec::new(); - - // The single source-of-truth lock. - let common_lock = project_root.join(crate::constants::npm_family::RUSH_COMMON_LOCK_REL); - if let Some(entries) = inventory_pnpm_lock_at(&common_lock).await { - out.extend(entries); - } - - // Per-subspace locks, sorted for determinism. - let subspaces_dir = project_root.join("common/config/subspaces"); - if let Ok(mut read_dir) = tokio::fs::read_dir(&subspaces_dir).await { - let mut subspace_dirs: Vec = Vec::new(); - while let Ok(Some(entry)) = read_dir.next_entry().await { - if entry.file_type().await.is_ok_and(|t| t.is_dir()) { - subspace_dirs.push(entry.path()); - } - } - subspace_dirs.sort(); - for dir in subspace_dirs { - if let Some(entries) = inventory_pnpm_lock_at(&dir.join("pnpm-lock.yaml")).await { - out.extend(entries); - } - } - } - out -} - -// ───────────────────────────── yarn.lock (classic) ───────────────────────────── - -async fn inventory_yarn_classic(root: &Path) -> Option> { - let text = read_regular_to_string(&root.join("yarn.lock")).await.ok()?; - let mut out = Vec::new(); - for block in yarn_classic_lock::scan_blocks(&text) { - // Our own vendored block: not a registry dependency. - if yarn_classic_lock::block_points_into_vendor(&block.lines) { - continue; - } - let patterns = yarn_classic_lock::split_key_patterns(&block.key); - let Some(name) = patterns - .first() - .and_then(|p| yarn_classic_lock::pattern_real_name(p)) - else { - continue; - }; - let Some(version) = yarn_classic_lock::classic_field(&block.lines, "version") else { - continue; - }; - let resolved_raw = yarn_classic_lock::classic_field(&block.lines, "resolved"); - // `resolved "url#sha1hex"` — the fragment is the legacy verifier. - let (resolved, sha1_hex) = match resolved_raw { - Some(raw) => match raw.split_once('#') { - Some((url, frag)) => ( - http_url(url), - is_hex_of_len(frag, 40).then(|| frag.to_ascii_lowercase()), - ), - None => (http_url(raw), None), - }, - None => (None, None), - }; - let integrity = yarn_classic_lock::classic_field(&block.lines, "integrity") - .map(|i| LockIntegrity::Sri(i.to_string())) - .or(sha1_hex.map(LockIntegrity::Sha1Hex)) - .unwrap_or(LockIntegrity::None); - out.push(LockfileEntry::npm(name, version, resolved, integrity)); - } - Some(out) -} - -// ───────────────────────────── yarn.lock (berry) ───────────────────────────── - -async fn inventory_yarn_berry(root: &Path) -> Option> { - let text = read_regular_to_string(&root.join("yarn.lock")).await.ok()?; - let mut out = Vec::new(); - // Berry reuses classic's block grammar (same scanner the berry backend - // imports); `__metadata` and workspace/patch/file resolutions are not - // registry packages. - for block in yarn_classic_lock::scan_blocks(&text) { - if block.key.starts_with("__metadata") { - continue; - } - let Some(resolution) = yarn_berry_lock::berry_field(&block.lines, "resolution") else { - continue; - }; - // Registry resolutions are `name@npm:` (a `::binding` - // suffix may follow). Anything else (workspace:/patch:/file:/link:) - // is skipped — including our own vendored file: resolutions. - let Some((name, reference)) = yarn_classic_lock::split_pattern(resolution) else { - continue; - }; - let Some(reference) = reference.strip_prefix("npm:") else { - continue; - }; - let version_from_res = reference.split("::").next().unwrap_or(reference); - let version = - yarn_berry_lock::berry_field(&block.lines, "version").unwrap_or(version_from_res); - let integrity = yarn_berry_lock::berry_field(&block.lines, "checksum") - .map(|c| LockIntegrity::BerryChecksum(c.to_string())) - .unwrap_or(LockIntegrity::None); - out.push(LockfileEntry::npm(name, version, None, integrity)); - } - Some(out) -} - -// ──────────────────────────────── bun.lock ──────────────────────────────── - -async fn inventory_bun_binary(root: &Path) -> Result, UnsupportedNpmLayout> { - let invalid = |detail: String| UnsupportedNpmLayout { - code: "bun_lockb_invalid", - detail: format!("cannot inventory bun.lockb: {detail}"), - }; - let bytes = read_regular_to_bytes(&root.join("bun.lockb")) - .await - .map_err(|error| invalid(error.to_string()))?; - let lock = super::bun_lockb::BunLockb::parse(&bytes).map_err(invalid)?; - let packages = lock.packages().map_err(invalid)?; - Ok(packages - .into_iter() - .filter_map(|package| { - let version = package.version?; - // Only resolved registry versions participate. Workspace, file and - // git sources have no registry version; a local vendored tarball's - // pristine metadata is recovered from its wiring ledger instead. - if !version.chars().next().is_some_and(|c| c.is_ascii_digit()) { - return None; - } - Some(LockfileEntry::npm( - package.name, - version, - http_url(&package.resolution), - package - .integrity - .map(LockIntegrity::Sri) - .unwrap_or(LockIntegrity::None), - )) - }) - .collect()) -} - -async fn inventory_bun(root: &Path) -> Option> { - let text = read_regular_to_string(&root.join("bun.lock")).await.ok()?; - bun_lock_text::check_lock_version(&text).ok()?; - let lines: Vec = text.split('\n').map(str::to_string).collect(); - let entries = bun_lock_text::parse_packages_section(&lines).ok()?; - - let mut out = Vec::new(); - for entry in entries { - // Registry entries are 4-tuples `[spec, registry, {deps}, sha512]`; - // our vendored 3-tuples and other shapes are skipped. - if entry.elems.len() != 4 || !entry.elems[2].starts_with('{') { - continue; - } - let Some(spec) = entry - .elems - .first() - .and_then(|e| bun_lock_text::decode_json_string(e)) - else { - continue; - }; - let Some((name, version)) = bun_lock_text::split_name_spec(&spec) else { - continue; - }; - if !version.chars().next().is_some_and(|c| c.is_ascii_digit()) { - continue; - } - let Some(registry) = bun_lock_text::decode_json_string(&entry.elems[1]) else { - continue; - }; - let Some(integrity) = bun_lock_text::decode_json_string(&entry.elems[3]) else { - continue; - }; - // elem[1] is `""` for the default registry; a full `.tgz` URL is - // used verbatim; any other base falls back to conventional URL - // construction (the integrity check still gates the content). - let resolved = (registry.ends_with(".tgz")) - .then(|| http_url(®istry)) - .flatten(); - out.push(LockfileEntry::npm( - name, - version, - resolved, - LockIntegrity::Sri(integrity), - )); - } - Some(out) -} - -// ────────────────────────────── composer.lock ────────────────────────────── - -/// Inventory `composer.lock` `packages`/`packages-dev`. The `dist.shasum` -/// (sha1 of the dist zip) is frequently empty — such entries stay -/// discovery-only. Names lowercase to the canonical packagist form; -/// versions drop the pretty leading `v`/`V` through the crawler's -/// [`normalize_version`], so installed and lockfile rows agree. -async fn inventory_composer_lock(project_root: &Path) -> Option> { - let bytes = read_regular_to_bytes(&project_root.join("composer.lock")) - .await - .ok()?; - let doc: Value = serde_json::from_slice(&bytes).ok()?; - let mut out = Vec::new(); - for section in ["packages", "packages-dev"] { - let Some(list) = doc.get(section).and_then(Value::as_array) else { - continue; - }; - for pkg in list { - let Some(name) = pkg.get("name").and_then(Value::as_str) else { - continue; - }; - let Some(version) = pkg.get("version").and_then(Value::as_str) else { - continue; - }; - let name = name.to_ascii_lowercase(); - // Share the crawler's normalization rather than re-deriving it: - // it strips `v` AND `V` (both are legal Composer tags), and a - // lockfile row that normalizes differently from the installed - // row double-counts the package — one installed `@1.2.3` plus a - // phantom lockfile-only `@V1.2.3`, both POSTed. - let version = normalize_version(version).to_string(); - if !path_safety::is_safe_multi_segment(&name) - || name.split('/').count() != 2 - || !path_safety::is_safe_single_segment(&version) - { - continue; - } - let dist = pkg.get("dist"); - let dist_url = dist - .and_then(|d| d.get("url")) - .and_then(Value::as_str) - .unwrap_or(""); - // Our own vendored entries use a path dist — skip. - if dist - .and_then(|d| d.get("type")) - .and_then(Value::as_str) - .is_some_and(|t| t == "path") - || parse_vendor_path(dist_url).is_some() - { - continue; - } - let is_zip = dist - .and_then(|d| d.get("type")) - .and_then(Value::as_str) - .is_some_and(|t| t == "zip"); - let shasum = dist - .and_then(|d| d.get("shasum")) - .and_then(Value::as_str) - .unwrap_or(""); - let integrity = if is_zip && is_hex_of_len(shasum, 40) { - LockIntegrity::Sha1Hex(shasum.to_ascii_lowercase()) - } else { - LockIntegrity::None - }; - let purl = format!("pkg:composer/{name}@{version}"); - out.push(LockfileEntry { - ecosystem: "composer", - name, - version, - purl, - resolved: is_zip.then(|| http_url(dist_url)).flatten(), - integrity, - }); - } - } - Some(dedup_prefer_integrity(out)) -} - -// ────────────────────────────── Gemfile.lock ────────────────────────────── - -/// Inventory `Gemfile.lock`: `GEM`-section `specs:` entries (4-space -/// indent; deeper lines are dependency ranges) plus the bundler ≥ 2.6 -/// `CHECKSUMS` section's sha256 values when present (older locks stay -/// discovery-only). Platform-suffixed specs (`nokogiri (1.16.5-arm64-…)`) -/// are skipped — platform gems are unsupported for vendoring anyway. -/// -/// Multi-source locks: bundler ≥ 2 emits ONE GEM section per source -/// (Gemfile `source … do` blocks; verified against bundler 4.0.15) and -/// hard-errors on multiple global sources, so each spec resolves against -/// its OWN section's remote — never the first remote in the file, which -/// for a private-server section would 404 at best and leak private gem -/// names to the public registry at worst. A section carrying SEVERAL -/// distinct `remote:` lines is a legacy bundler 1.x multisource lock whose -/// per-spec origin is genuinely ambiguous: its specs stay discovery-only -/// (no resolved URL — the fetch layer then refuses), fail-closed. -async fn inventory_gemfile_lock(project_root: &Path) -> Option> { - let text = read_regular_to_string(&project_root.join("Gemfile.lock")) - .await - .ok()?; - let mut section_remotes: Vec> = Vec::new(); - let mut checksums: HashMap<(String, String), String> = HashMap::new(); - let mut specs: Vec<(String, String, usize)> = Vec::new(); - - let mut section = ""; - let mut in_specs = false; - for line in text.lines() { - if !line.starts_with(' ') { - section = line.trim(); - in_specs = false; - if section == "GEM" { - section_remotes.push(Vec::new()); - } - continue; - } - let trimmed = line.trim_start(); - let indent = line.len() - trimmed.len(); - match section { - "GEM" => { - if indent == 2 { - if let Some(r) = trimmed.strip_prefix("remote:") { - let r = r.trim().trim_end_matches('/'); - if !r.is_empty() { - if let Some(remotes) = section_remotes.last_mut() { - remotes.push(r.to_string()); - } - } - } - in_specs = trimmed == "specs:"; - } else if in_specs && indent == 4 { - if let Some((name, version)) = parse_gem_spec_line(trimmed) { - specs.push((name, version, section_remotes.len() - 1)); - } - } - } - "CHECKSUMS" => { - // ` name (version) sha256=hex` - if let Some((spec_part, hash_part)) = - trimmed.rsplit_once(" sha256=").map(|(s, h)| (s, h.trim())) - { - if let Some((name, version)) = parse_gem_spec_line(spec_part) { - if is_hex_of_len(hash_part, 64) { - checksums.insert((name, version), hash_part.to_ascii_lowercase()); - } - } - } - } - _ => {} - } - } - if specs.is_empty() { - return None; - } - let mut out = Vec::new(); - for (name, version, sec) in specs { - if !path_safety::is_safe_single_segment(&name) - || !path_safety::is_safe_single_segment(&version) - { - continue; - } - let integrity = checksums - .get(&(name.clone(), version.clone())) - .map(|h| LockIntegrity::Sha256Hex(h.clone())) - .unwrap_or(LockIntegrity::None); - let resolved = match section_remotes.get(sec).map(Vec::as_slice) { - Some([base]) => http_url(&format!("{base}/downloads/{name}-{version}.gem")), - // No remote (a missing `remote:` line defaults to rubygems.org - // ONLY when the whole lock has one remote-less GEM section — - // the pre-multisource shape) or several remotes: fail closed. - Some([]) if section_remotes.len() == 1 => http_url(&format!( - "https://rubygems.org/downloads/{name}-{version}.gem" - )), - _ => None, - }; - out.push(LockfileEntry { - ecosystem: "gem", - purl: format!("pkg:gem/{name}@{version}"), - resolved, - name, - version, - integrity, - }); - } - Some(dedup_prefer_integrity(out)) -} - -/// `name (version)` → parts; platform-suffixed versions (`1.2.3-x86_64…`) -/// and dependency lines (no parens / range operators) yield `None`. -fn parse_gem_spec_line(line: &str) -> Option<(String, String)> { - let (name, rest) = line.split_once(" (")?; - let version = rest.strip_suffix(')')?; - if name.is_empty() - || version.is_empty() - || version.contains(' ') - || version.contains('-') - || !version.chars().next().is_some_and(|c| c.is_ascii_digit()) - { - return None; - } - Some((name.to_string(), version.to_string())) -} - -// ─────────────────────────────── pypi locks ─────────────────────────────── -// pypi purls and lock entries compare in PEP 503 normalized form -// (`Foo._Bar` → `foo-bar`) — see `canonicalize_pypi_name`. - -/// Inventory the pypi lock the project carries. Fetchable resolution -/// (URL + sha256 of a pure `py3-none-any` wheel) comes from `uv.lock`; -/// `poetry.lock` and `--hash`-pinned `requirements.txt` contribute -/// DISCOVERY-only entries (no recorded URL; platform-independent wheel -/// choice is not derivable offline). `pdm.lock` contributes discovery-only -/// entries. Pipfile.lock contributes entries whose integrity is its digest SET -/// (see `inventory_pipfile_lock`). -async fn inventory_pypi_locks(project_root: &Path) -> Option> { - let mut out = Vec::new(); - let mut found = false; - let mut uv_lock = false; - if let Ok(paths) = crate::utils::python_lock::python_lock_paths(project_root) { - for path in paths { - let Ok(text) = read_regular_to_string(&project_root.join(&path)).await else { - continue; - }; - if let Some(entries) = python_lock_inventory(&text) { - found = true; - uv_lock |= path == "uv.lock"; - out.extend(entries); - } - } - } - // A PARSEABLE uv.lock stays the EXCLUSIVE project inventory (its - // precedence over poetry.lock / requirements.txt predates standalone-lock - // support). Exclusivity is keyed on parse SUCCESS, not on the file's - // presence: an unparseable uv.lock contributed nothing above, so it falls - // through to poetry.lock / requirements.txt exactly like a package-less - // poetry.lock does (`depless_poetry_lock_falls_through_to_requirements`). - // Keying on presence would hide every requirements pin behind a corrupt - // lock AND diverge from hosted, which skips an unparseable uv.lock with - // `redirect_uv_lock_unsupported` and still reads the other pins. A - // PEP 723 script lock or a PEP 751 lock is scoped to its own install, - // so it SUPPLEMENTS the project's tool lock: a stray `tool.py.lock` - // must not hide every poetry.lock / requirements.txt pin from scan's - // lockfile supplement and vendor's lookup. - if !uv_lock { - if let Some(entries) = inventory_poetry_lock(project_root).await { - found = true; - out.extend(entries); - } else if let Some(entries) = inventory_pdm_lock(project_root).await { - found = true; - out.extend(entries); - } else { - // Pipfile.lock and requirements.txt are read TOGETHER: Pipenv - // projects routinely ship both (`pipenv requirements` exports the - // same pins — deduplicated below), and a stale Pipfile.lock left in - // a requirements project must not hide the pins the project - // actually installs from (the hosted rewriter judges each file on - // its own). - if let Some(entries) = inventory_pipfile_lock(project_root).await { - found = true; - out.extend(entries); - } - if let Some(entries) = inventory_requirements_txt(project_root).await { - found = true; - out.extend(entries); - } - } - } - found.then(|| dedup_prefer_integrity(out)) -} - -fn python_archive(archive: &dyn TableLike) -> Option<(String, String)> { - let url = archive.get("url")?.as_str()?; - if !url.split(['?', '#']).next()?.ends_with("-none-any.whl") { - return None; - } - let sha = archive - .get("hash") - .and_then(Item::as_str) - .and_then(|value| value.strip_prefix("sha256:")) - .or_else(|| { - archive - .get("hashes")? - .as_table_like()? - .get("sha256")? - .as_str() - })?; - if !is_hex_of_len(sha, 64) { - return None; - } - Some((http_url(url)?, sha.to_ascii_lowercase())) -} - -fn python_package_archive(package: &dyn TableLike) -> Option<(String, String)> { - if let Some(archive) = package - .get("archive") - .and_then(Item::as_table_like) - .and_then(python_archive) - { - return Some(archive); - } - if let Some(wheels) = package.get("wheels").and_then(Item::as_array) { - for wheel in wheels.iter().filter_map(TomlValue::as_inline_table) { - if let Some(archive) = python_archive(wheel) { - return Some(archive); - } - } - } - if let Some(wheels) = package.get("wheel").and_then(Item::as_array_of_tables) { - for wheel in wheels.iter() { - if let Some(archive) = python_archive(wheel) { - return Some(archive); - } - } - } - None -} - -fn python_lock_inventory(text: &str) -> Option> { - let document: DocumentMut = text.parse().ok()?; - let pep751 = document.get("lock-version").is_some(); - let collection = if pep751 { - if document.get("lock-version").and_then(Item::as_str) != Some("1.0") { - return None; - } - "packages" - } else { - if document.get("version").and_then(Item::as_integer) != Some(1) { - return None; - } - if document.contains_key("distribution") { - "distribution" - } else { - "package" - } - }; - let mut out = Vec::new(); - let packages = document.get(collection)?.as_array_of_tables()?; - for package in packages.iter() { - let Some(name) = package - .get("name") - .and_then(Item::as_str) - .map(canonicalize_pypi_name) - else { - continue; - }; - let Some(version) = package.get("version").and_then(Item::as_str) else { - continue; - }; - if !path_safety::is_safe_single_segment(&name) - || !path_safety::is_safe_single_segment(version) - { - continue; - } - let remote = if pep751 { - !package.contains_key("vcs") - && !package.contains_key("directory") - && !package - .get("archive") - .and_then(Item::as_table_like) - .is_some_and(|archive| archive.contains_key("path")) - } else { - package.get("source").is_some_and(|source| { - source.as_str().is_some_and(|value| { - value.starts_with("registry+") || value.starts_with("direct+") - }) || source.as_table_like().is_some_and(|table| { - table.contains_key("registry") || table.contains_key("url") - }) - }) - }; - if !remote { - continue; - } - let (resolved, integrity) = match python_package_archive(package) { - Some((url, sha)) => (Some(url), LockIntegrity::Sha256Hex(sha)), - None => (None, LockIntegrity::None), - }; - out.push(LockfileEntry { - ecosystem: "pypi", - purl: format!("pkg:pypi/{name}@{version}"), - name, - version: version.to_string(), - resolved, - integrity, - }); - } - Some(out) -} - -/// The sha256 of each package's pure-Python (`-none-any.whl`) wheel as the -/// lock records it — `files = [...]` inside `[[package]]` (lock 2.x) or the -/// `[metadata.files]` entry (lock 1.0/1.1). Poetry 0.12's `[metadata.hashes]` -/// lists bare digests without filenames, so no wheel can be chosen there. -/// Keyed by canonical name. An unparseable lock contributes nothing (the -/// line-based name/version walk below still runs). -fn poetry_pure_wheel_hashes(text: &str) -> HashMap { - fn pure_wheel_sha(files: &Item) -> Option { - let files = files.as_array()?; - files - .iter() - .filter_map(TomlValue::as_inline_table) - .find_map(|entry| { - let file = entry.get("file")?.as_str()?; - if !file.ends_with("-none-any.whl") { - return None; - } - let sha = entry.get("hash")?.as_str()?.strip_prefix("sha256:")?; - is_hex_of_len(sha, 64).then(|| sha.to_ascii_lowercase()) - }) - } - let mut out = HashMap::new(); - let Ok(document) = text.parse::() else { - return out; - }; - if let Some(packages) = document.get("package").and_then(Item::as_array_of_tables) { - for package in packages.iter() { - let Some(name) = package.get("name").and_then(Item::as_str) else { - continue; - }; - if let Some(sha) = package.get("files").and_then(pure_wheel_sha) { - out.entry(canonicalize_pypi_name(name)).or_insert(sha); - } - } - } - if let Some(files) = document - .get("metadata") - .and_then(|m| m.get("files")) - .and_then(Item::as_table_like) - { - for (name, entry) in files.iter() { - if let Some(sha) = pure_wheel_sha(entry) { - out.entry(canonicalize_pypi_name(name)).or_insert(sha); - } - } - } - out -} - -/// poetry.lock: `[[package]]` blocks with `name`/`version`. The lock records -/// file hashes but no URLs and no platform choice, so an entry carries the -/// pure-Python wheel's sha256 when the lock lists one (the pypi fetcher then -/// resolves the matching file through PyPI's JSON API) and stays -/// discovery-only otherwise. -async fn inventory_poetry_lock(project_root: &Path) -> Option> { - let text = read_regular_to_string(&project_root.join("poetry.lock")) - .await - .ok()?; - let hashes = poetry_pure_wheel_hashes(&text); - let mut out = Vec::new(); - let mut in_package = false; - let mut name: Option = None; - for line in text.lines() { - let t = line.trim(); - if t == "[[package]]" { - in_package = true; - name = None; - continue; - } - if t.starts_with('[') && t != "[[package]]" { - in_package = false; - continue; - } - if !in_package { - continue; - } - if let Some(v) = t.strip_prefix("name = ") { - name = Some(canonicalize_pypi_name(v.trim_matches('"'))); - } else if let Some(v) = t.strip_prefix("version = ") { - if let Some(n) = name.take() { - let v = v.trim_matches('"').to_string(); - if path_safety::is_safe_single_segment(&n) - && path_safety::is_safe_single_segment(&v) - { - let integrity = hashes - .get(&n) - .map(|sha| LockIntegrity::Sha256Hex(sha.clone())) - .unwrap_or(LockIntegrity::None); - out.push(LockfileEntry { - ecosystem: "pypi", - purl: format!("pkg:pypi/{n}@{v}"), - name: n, - version: v, - resolved: None, - integrity, - }); - } - } - } - } - if out.is_empty() { - return None; - } - Some(dedup_prefer_integrity(out)) -} - -/// `https://pypi.org/simple`, `https://pypi.python.org/simple`, -/// `https://files.pythonhosted.org/…`: the public index PyPI's JSON API -/// describes. -fn is_public_pypi_url(url: &str) -> bool { - let host = url - .split("://") - .nth(1) - .and_then(|rest| rest.split(['/', '?', '#']).next()) - .unwrap_or("") - .to_ascii_lowercase(); - let host = host.rsplit('@').next().unwrap_or(&host); - matches!( - host, - "pypi.org" | "www.pypi.org" | "pypi.python.org" | "files.pythonhosted.org" - ) -} - -/// The `(canonical name, version)` a Socket-written Pipfile.lock reference -/// stands for: a hosted URL -/// `https:///patch/pypi/////[#…]` -/// (coordinates from the path) or a vendored path -/// `[./].socket/vendor/pypi//--…whl` (coordinates from -/// the wheel filename). `None` for a user's own file/path reference. -fn socket_reference_coords(reference: &str) -> Option<(String, String)> { - let reference = reference.split('#').next().unwrap_or(reference); - if let Some(rest) = reference.strip_prefix("https://") { - let path = rest.split_once('/')?.1; - let parts: Vec<&str> = path.split('/').collect(); - if parts.len() == 7 - && parts[0] == "patch" - && parts[1] == "pypi" - && parts[6].ends_with(".whl") - { - return Some((canonicalize_pypi_name(parts[2]), parts[3].to_string())); - } - return None; - } - let rel = reference.trim_start_matches("./"); - let rest = rel.strip_prefix(".socket/vendor/pypi/")?; - let (_uuid, wheel) = rest.split_once('/')?; - let stem = wheel.strip_suffix(".whl")?; - let mut fields = stem.split('-'); - let name = fields.next()?; - let version = fields.next()?; - if name.is_empty() || version.is_empty() || !version.starts_with(|c: char| c.is_ascii_digit()) { - return None; - } - Some((canonicalize_pypi_name(name), version.to_string())) -} - -/// Pipfile.lock (pipfile-spec 6): every category other than `_meta` holds -/// `name: {"version": "==X", "hashes": ["sha256:", …], …}` entries. -/// Registry pins (`==` version) become entries whose integrity is the SET of -/// recorded digests — Pipenv lists every release file's hash without -/// filenames, so the pure-Python wheel is selected by digest at fetch time -/// ([`LockIntegrity::Sha256AnyOf`]). VCS / path / file / editable sources and -/// range pins are skipped (nothing registry-shaped to vendor over), as are -/// our own already-wired file references. An unparseable lock contributes -/// nothing, so the caller falls through to requirements.txt like an absent -/// lock would. -async fn inventory_pipfile_lock(project_root: &Path) -> Option> { - let text = read_regular_to_string(&project_root.join("Pipfile.lock")) - .await - .ok()?; - let value: serde_json::Value = - serde_json::from_str(text.trim_start_matches('\u{feff}')).ok()?; - let root = value.as_object()?; - // Digests are only fetchable through PyPI's JSON API when the lock - // resolves from PyPI: a lock whose `_meta.sources` name only private - // indexes must not leak its package names to pypi.org (and would not find - // its files there anyway) — its entries stay discovery-only. - let public_index = root - .get("_meta") - .and_then(|m| m.get("sources")) - .and_then(serde_json::Value::as_array) - .is_none_or(|sources| { - sources.is_empty() - || sources.iter().any(|source| { - source - .get("url") - .and_then(serde_json::Value::as_str) - .is_some_and(is_public_pypi_url) - }) - }); - let mut out = Vec::new(); - for (section, entries) in root { - if section == "_meta" { - continue; - } - let Some(entries) = entries.as_object() else { - continue; - }; - for (name, entry) in entries { - let Some(entry) = entry.as_object() else { - continue; - }; - // Socket's own references (a hosted `file` URL, a vendored - // `./.socket/vendor/pypi//` path) stay DISCOVERABLE - // as the package they replace, so a re-scan of an already - // redirected lock-only checkout still lists (and re-confirms / - // attests) it instead of reporting zero packages. - if let Some(reference) = entry - .get("file") - .or_else(|| entry.get("path")) - .and_then(serde_json::Value::as_str) - { - if let Some((n, v)) = socket_reference_coords(reference) { - if path_safety::is_safe_single_segment(&n) - && path_safety::is_safe_single_segment(&v) - { - out.push(LockfileEntry { - ecosystem: "pypi", - purl: format!("pkg:pypi/{n}@{v}"), - name: n, - version: v, - resolved: None, - integrity: LockIntegrity::None, - }); - } - } - continue; - } - if ["git", "hg", "svn", "bzr", "editable"] - .iter() - .any(|key| entry.contains_key(*key)) - { - continue; - } - let Some(version) = entry - .get("version") - .and_then(serde_json::Value::as_str) - .and_then(|v| v.strip_prefix("==")) - .map(str::trim) - .filter(|v| !v.is_empty()) - else { - continue; - }; - let n = canonicalize_pypi_name(name); - if !path_safety::is_safe_single_segment(&n) - || !path_safety::is_safe_single_segment(version) - { - continue; - } - let hashes: Vec = entry - .get("hashes") - .and_then(serde_json::Value::as_array) - .into_iter() - .flatten() - .filter_map(serde_json::Value::as_str) - .filter_map(|h| h.strip_prefix("sha256:")) - .filter(|h| is_hex_of_len(h, 64)) - .map(|h| h.to_ascii_lowercase()) - .collect(); - let integrity = if hashes.is_empty() || !public_index { - LockIntegrity::None - } else { - LockIntegrity::Sha256AnyOf(hashes) - }; - out.push(LockfileEntry { - ecosystem: "pypi", - purl: format!("pkg:pypi/{n}@{version}"), - name: n, - version: version.to_string(), - resolved: None, - integrity, - }); - } - } - Some(out) -} - -/// `pdm.lock`: `[[package]]` blocks with `name`/`version`, DISCOVERY-only. This -/// surfaces the project's PyPI coordinates so a hosted lock-only checkout (no -/// installed package) can be redirected — the hosted rewrite pins the API -/// grant's URL and does not need a lock-derived hash. It stays discovery-only -/// (`LockIntegrity::None`) so vendored keeps refusing a lock-only checkout -/// (`vendor_fetch_unverifiable`): vendoring rebuilds the wheel from the -/// INSTALLED package, and PDM installs into a `__pypackages__` tree the crawler -/// does not probe, so a lock-only vendored path would not survive a re-scan. -async fn inventory_pdm_lock(project_root: &Path) -> Option> { - let text = read_regular_to_string(&project_root.join("pdm.lock")) - .await - .ok()?; - let mut out = Vec::new(); - let mut in_package = false; - let mut name: Option = None; - for line in text.lines() { - let t = line.trim(); - if t == "[[package]]" { - in_package = true; - name = None; - continue; - } - if t.starts_with('[') && t != "[[package]]" { - in_package = false; - continue; - } - if !in_package { - continue; - } - if let Some(v) = t.strip_prefix("name = ") { - name = Some(canonicalize_pypi_name(v.trim_matches('"'))); - } else if let Some(v) = t.strip_prefix("version = ") { - if let Some(n) = name.take() { - let v = v.trim_matches('"').to_string(); - if path_safety::is_safe_single_segment(&n) - && path_safety::is_safe_single_segment(&v) - { - out.push(LockfileEntry { - ecosystem: "pypi", - purl: format!("pkg:pypi/{n}@{v}"), - name: n, - version: v, - resolved: None, - integrity: LockIntegrity::None, - }); - } - } - } - } - if out.is_empty() { - return None; - } - Some(dedup_prefer_integrity(out)) -} - -/// requirements.txt with exact `==` pins — discovery only. -async fn inventory_requirements_txt(project_root: &Path) -> Option> { - let text = read_regular_to_string(&project_root.join("requirements.txt")) - .await - .ok()?; - let mut out = Vec::new(); - for line in text.lines() { - let t = line.trim(); - if t.is_empty() || t.starts_with('#') || t.starts_with('-') { - continue; - } - // `name==version` (strip extras, env markers, hash continuations). - let spec = t.split(';').next().unwrap_or(t).trim(); - let spec = spec.split_whitespace().next().unwrap_or(spec); - let Some((raw_name, version)) = spec.split_once("==") else { - continue; - }; - let name = canonicalize_pypi_name(raw_name.split('[').next().unwrap_or(raw_name).trim()); - let version = version.trim().to_string(); - if name.is_empty() - || !path_safety::is_safe_single_segment(&name) - || !path_safety::is_safe_single_segment(&version) - || !version.chars().next().is_some_and(|c| c.is_ascii_digit()) - { - continue; - } - out.push(LockfileEntry { - ecosystem: "pypi", - purl: format!("pkg:pypi/{name}@{version}"), - name, - version, - resolved: None, - integrity: LockIntegrity::None, - }); - } - if out.is_empty() { - return None; - } - Some(dedup_prefer_integrity(out)) -} - -// ──────────────── registry-fragment recovery from the ledger ──────────────── - -/// Recover the PRE-VENDOR registry resolution of a vendored package from its -/// ledger entry's wiring `original` fragments (and `entry.lock` for cargo), -/// as a fetchable [`LockfileEntry`]. -/// -/// This is the rebuild path for artifacts that are referenced by the rewired -/// lockfile but missing on disk: the live lockfile no longer carries the -/// registry resolution (it points at `.socket/vendor/...`), but `--revert`'s -/// restore data does. golang is deliberately absent — go.sum is never -/// rewired, so the standard [`inventory_project`]/[`lookup`] path covers it. -/// -/// SECURITY: state.json is committed and tamper-able. Recovered URLs go -/// through the same http(s)-only gate as inventoried ones, recovered hashes -/// are shape-validated here and verified against the fetched bytes -/// fail-closed by the fetch layer — a poisoned fragment can at worst make -/// the fetch fail, never land unverified content. -pub async fn recover_lock_entry( - project_root: &Path, - entry: &super::state::VendorEntry, -) -> Result { - let (name, version) = parse_base_purl_coords(&entry.base_purl) - .ok_or_else(|| format!("unparseable base purl `{}`", entry.base_purl))?; - - match entry.ecosystem.as_str() { - "npm" => recover_npm_fragment(entry, &name, &version), - "cargo" => { - let checksum = entry - .lock - .as_ref() - .and_then(|l| l.checksum.clone()) - .filter(|c| is_hex_of_len(c, 64)) - .ok_or_else(|| { - "the ledger records no pre-vendor Cargo.lock checksum".to_string() - })?; - Ok(LockfileEntry { - ecosystem: "cargo", - purl: format!("pkg:cargo/{name}@{version}"), - name, - version, - resolved: None, - integrity: LockIntegrity::Sha256Hex(checksum.to_ascii_lowercase()), - }) - } - "composer" => { - let original = wiring_original(entry, &["composer_lock_package"]) - .ok_or_else(|| "no pre-vendor composer.lock fragment recorded".to_string())?; - let dist = original - .get("dist") - .ok_or_else(|| "the pre-vendor composer.lock fragment has no dist".to_string())?; - let url = dist - .get("url") - .and_then(serde_json::Value::as_str) - .and_then(http_url) - .ok_or_else(|| "the pre-vendor dist has no http(s) url".to_string())?; - let shasum = dist - .get("shasum") - .and_then(serde_json::Value::as_str) - .filter(|s| is_hex_of_len(s, 40)) - .ok_or_else(|| { - "the pre-vendor dist records no shasum; refusing an unverifiable fetch" - .to_string() - })?; - Ok(LockfileEntry { - ecosystem: "composer", - purl: format!("pkg:composer/{name}@{version}"), - name, - version, - resolved: Some(url), - integrity: LockIntegrity::Sha1Hex(shasum.to_ascii_lowercase()), - }) - } - "gem" => { - let line = wiring_original(entry, &["gemfile_lock_checksum"]) - .and_then(|v| v.as_str().map(str::to_string)) - .ok_or_else(|| "no pre-vendor Gemfile.lock checksum recorded".to_string())?; - let sha = line - .split("sha256=") - .nth(1) - .map(|rest| { - rest.trim_end_matches(',') - .trim() - .chars() - .take_while(|c| c.is_ascii_hexdigit()) - .collect::() - }) - .filter(|s| is_hex_of_len(s, 64)) - .ok_or_else(|| { - "the pre-vendor checksum line has no sha256; refusing an unverifiable fetch" - .to_string() - })?; - let base = match gem_remotes(project_root).await.as_slice() { - [] => "https://rubygems.org".to_string(), - [one] => http_url(one).ok_or_else(|| { - // A lone non-http remote (file:// gem repo): the registry - // conventions cannot reproduce its bytes, and defaulting - // to rubygems.org would leak the gem name off-site. - format!( - "the Gemfile.lock's GEM remote ({one}) is not an http(s) registry; \ - refusing to fetch from a guessed remote" - ) - })?, - several => { - // The vendored spec's own GEM section is gone (it moved - // into the PATH section), so with several sources its - // origin is genuinely ambiguous — a guessed remote - // would 404 at best and leak a private gem name to the - // public registry at worst. - return Err(format!( - "Gemfile.lock lists multiple GEM sources ({}); the vendored gem's \ - pre-vendor source is ambiguous — refusing to fetch from a guessed \ - remote", - several.join(", ") - )); - } - }; - Ok(LockfileEntry { - ecosystem: "gem", - purl: format!("pkg:gem/{name}@{version}"), - resolved: http_url(&format!("{base}/downloads/{name}-{version}.gem")), - name, - version, - integrity: LockIntegrity::Sha256Hex(sha.to_ascii_lowercase()), - }) - } - "pypi" => { - if entry.artifact.platform_locked == Some(true) { - return Err( - "the vendored wheel is platform-locked (compiled); it cannot be rebuilt from the registry" - .to_string(), - ); - } - // The inventory canonicalizes names (PEP 503); the purl may carry - // the project's own spelling (`PyYAML`, `typing_extensions`) — - // compare in normalized form like `lookup` does. - let canonical_name = canonicalize_pypi_name(&name); - for wiring in entry - .wiring - .iter() - .filter(|wiring| wiring.kind == "python_lock_document") - { - if let Some(text) = wiring.original.as_ref().and_then(Value::as_str) { - if let Some(entries) = python_lock_inventory(text) { - if let Some(resolution) = entries.into_iter().find(|candidate| { - candidate.name == canonical_name - && candidate.version == version - && candidate.resolved.is_some() - && candidate.integrity != LockIntegrity::None - }) { - return Ok(resolution); - } - } - } - } - if entry - .wiring - .iter() - .any(|wiring| wiring.kind == "python_lock_document") - { - return Err("the pre-vendor Python lock has no hash-pinned pure wheel for this package; reinstall it before repair".to_string()); - } - // Every pypi package manager records the pre-vendor resolution under - // its own wiring kind — uv writes `uv_lock_package`, pdm - // `pdm_lock_package`, poetry `poetry_lock_package`, pipenv - // `pipenv_lock_entry`, bare pip `requirements_line`. Accept them all - // so recovery is not blind to non-uv projects. - let fragment = wiring_original( - entry, - &[ - "uv_lock_package", - "python_lock_document", - "pdm_lock_package", - "poetry_lock_package", - "pipenv_lock_entry", - "requirements_line", - ], - ) - .ok_or_else(|| "no pre-vendor pypi lock fragment recorded".to_string())?; - // Only uv.lock and pdm's `static_urls` locks inline the wheel's - // registry URL (`url = "…", hash = "sha256:…"`), which is all a - // registry rebuild can fetch from. Default pdm/poetry (`file = …`), - // pipenv (`hashes` only) and pip (`--hash=`) record the hash but no - // fetchable URL — an honest, actionable message, not the false - // "not installed / no recoverable fragment". - const NO_URL: &str = "the pre-vendor pypi lock fragment records the wheel hash but \ - no fetchable registry URL (only uv.lock and pdm `static_urls` locks carry wheel \ - URLs); reinstall the package so repair can rebuild from the installed copy"; - // Pipenv's pre-vendor entry is a JSON object carrying every - // release file's sha256 (`"hashes": ["sha256:…", …]`): fetchable - // by digest through PyPI's JSON API like a fresh Pipfile.lock - // inventory entry, so a lock-only checkout of an already-vendored - // project re-scans green instead of `package_not_installed`. - if let Some(object) = fragment.as_object() { - let digests: Vec = object - .get("hashes") - .and_then(serde_json::Value::as_array) - .into_iter() - .flatten() - .filter_map(serde_json::Value::as_str) - .filter_map(|h| h.strip_prefix("sha256:")) - .filter(|h| is_hex_of_len(h, 64)) - .map(|h| h.to_ascii_lowercase()) - .collect(); - if digests.is_empty() { - return Err( - "the pre-vendor Pipfile.lock entry records no sha256 digests; reinstall the \ - package so repair can rebuild from the installed copy" - .to_string(), - ); - } - return Ok(LockfileEntry { - ecosystem: "pypi", - purl: format!("pkg:pypi/{name}@{version}"), - name, - version, - resolved: None, - integrity: LockIntegrity::Sha256AnyOf(digests), - }); - } - let unit = fragment.as_str().ok_or_else(|| NO_URL.to_string())?; - let (url, sha) = pure_wheel_from_uv_unit(unit).ok_or_else(|| NO_URL.to_string())?; - Ok(LockfileEntry { - ecosystem: "pypi", - purl: format!("pkg:pypi/{name}@{version}"), - name, - version, - resolved: Some(url), - integrity: LockIntegrity::Sha256Hex(sha), - }) - } - other => Err(format!( - "no ledger-based registry recovery for ecosystem `{other}`" - )), - } -} - -/// The integrity the REWIRED npm-family lockfile records for a vendored -/// artifact at `artifact_rel` (forward-slashed, no `./` prefix). This is -/// the integrity of OUR deterministically packed tarball — the trust -/// anchor for repair's no-ledger reconstruction: a rebuilt tarball that -/// matches it is exactly what the package manager would have installed. -/// -/// package-lock/shrinkwrap are parsed as JSON; the text formats (pnpm, -/// yarn classic/berry, bun) are scanned with a bounded forward window from -/// each reference line. -pub async fn wired_vendor_integrity( - project_root: &Path, - artifact_rel: &str, -) -> Option { - let rel = artifact_rel.trim_start_matches("./"); - - if rel.starts_with(".socket/vendor/pypi/") { - let mut pinned = None; - for path in crate::utils::python_lock::python_lock_paths(project_root).ok()? { - let Ok(text) = read_regular_to_string(&project_root.join(path)).await else { - continue; - }; - let Ok(document) = text.parse::() else { - continue; - }; - let collection = if document.contains_key("lock-version") { - "packages" - } else { - "package" - }; - let Some(packages) = document.get(collection).and_then(Item::as_array_of_tables) else { - continue; - }; - for package in packages.iter() { - let archive = package.get("archive").and_then(Item::as_table_like); - let source = - archive.or_else(|| package.get("source").and_then(Item::as_table_like)); - if source - .and_then(|source| source.get("path")) - .and_then(Item::as_str) - .is_none_or(|path| path.trim_start_matches("./") != rel) - { - continue; - } - let sha = if let Some(archive) = archive { - archive - .get("hashes") - .and_then(Item::as_table_like) - .and_then(|hashes| hashes.get("sha256")) - .and_then(Item::as_str) - } else { - package - .get("wheels") - .and_then(Item::as_array) - .and_then(|wheels| { - wheels - .iter() - .filter_map(TomlValue::as_inline_table) - .find_map(|wheel| { - if wheel.get("filename").and_then(TomlValue::as_str) - != rel.rsplit('/').next() - { - return None; - } - wheel - .get("hash") - .and_then(TomlValue::as_str) - .and_then(|value| value.strip_prefix("sha256:")) - }) - }) - }; - let sha = sha - .filter(|sha| is_hex_of_len(sha, 64)) - .map(str::to_ascii_lowercase)?; - if pinned.as_ref().is_some_and(|previous| previous != &sha) { - return None; - } - pinned = Some(sha); - } - } - return pinned.map(LockIntegrity::Sha256Hex); - } - - // Read active binary resolution records, never the append-only string - // pool: it can retain paths and digests from earlier patch generations. - if tokio::fs::symlink_metadata(project_root.join("bun.lock")) - .await - .is_err() - { - if let Ok(bytes) = read_regular_to_bytes(&project_root.join("bun.lockb")).await { - if let Ok(lock) = super::bun_lockb::BunLockb::parse(&bytes) { - if let Ok(packages) = lock.packages() { - let mut pinned: Option = None; - for package in packages { - if package - .resolution - .trim_start_matches("file:") - .trim_start_matches("./") - != rel - { - continue; - } - let sri = package.integrity.filter(|sri| looks_like_sri(sri))?; - if pinned.as_ref().is_some_and(|previous| previous != &sri) { - return None; - } - pinned = Some(sri); - } - if let Some(sri) = pinned { - return Some(LockIntegrity::Sri(sri)); - } - } - } - } - } - - // JSON locks: resolved == "file:" (npm writes exactly this form). - for lock in ["npm-shrinkwrap.json", "package-lock.json"] { - let Ok(bytes) = read_regular_to_bytes(&project_root.join(lock)).await else { - continue; - }; - let Ok(v) = serde_json::from_slice::(&bytes) else { - continue; - }; - if let Some(pkgs) = v.get("packages").and_then(serde_json::Value::as_object) { - for entry in pkgs.values() { - let resolved = entry.get("resolved").and_then(serde_json::Value::as_str); - if resolved.is_some_and(|r| r.trim_start_matches("file:") == rel) { - if let Some(sri) = entry - .get("integrity") - .and_then(serde_json::Value::as_str) - .filter(|s| looks_like_sri(s)) - { - return Some(LockIntegrity::Sri(sri.to_string())); - } - } - } - } - } - - // Text locks: any line referencing the artifact path, integrity within - // a short forward window (the same block). - for lock in ["pnpm-lock.yaml", "yarn.lock", "bun.lock"] { - let Ok(text) = read_regular_to_string(&project_root.join(lock)).await else { - continue; - }; - let lines: Vec<&str> = text.lines().collect(); - for (i, line) in lines.iter().enumerate() { - if !line.contains(rel) { - continue; - } - for probe in lines.iter().take((i + 6).min(lines.len())).skip(i) { - // pnpm `resolution: {integrity: …}` / classic `integrity …` - // / bun tuple `"sha512-…"`. - if let Some(v) = inline_yaml_field(probe, "integrity:") { - if looks_like_sri(&v) { - return Some(LockIntegrity::Sri(v)); - } - } - if let Some(rest) = probe.trim().strip_prefix("integrity ") { - let v = rest.trim().trim_matches('"'); - if looks_like_sri(v) { - return Some(LockIntegrity::Sri(v.to_string())); - } - } - if let Some(sri) = probe.split('"').rev().find(|tok| looks_like_sri(tok)) { - return Some(LockIntegrity::Sri(sri.to_string())); - } - // yarn berry: `checksum: 10c0/…`. - if let Some(v) = inline_yaml_field(probe, "checksum:") { - if v.split_once('/') - .is_some_and(|(k, b)| !k.is_empty() && !b.is_empty()) - { - return Some(LockIntegrity::BerryChecksum(v)); - } - } - } - } - } - None -} - -/// `pkg:/@` → (name, version). The name may itself -/// contain `/` (npm scopes, go modules); the version is after the LAST `@`. -/// Components percent-decode (`%40scope` → `@scope`): the ledger stores -/// `base_purl` verbatim as the manifest spelled it, while [`LockfileEntry`] -/// carries literal coordinates — the name feeds the registry URL and the -/// berry cache-zip recipe. -fn parse_base_purl_coords(base_purl: &str) -> Option<(String, String)> { - let rest = base_purl.strip_prefix("pkg:")?; - let (_, name_ver) = rest.split_once('/')?; - let (name, version) = name_ver.rsplit_once('@')?; - if name.is_empty() || version.is_empty() { - return None; - } - let name = name - .split('/') - .map(percent_decode_purl_component) - .collect::>() - .join("/"); - let version = percent_decode_purl_component(version).into_owned(); - Some((name, version)) -} - -/// First wiring record of one of `kinds` carrying an `original` payload. -fn wiring_original<'a>( - entry: &'a super::state::VendorEntry, - kinds: &[&str], -) -> Option<&'a serde_json::Value> { - entry - .wiring - .iter() - .find(|r| kinds.contains(&r.kind.as_str()) && r.original.is_some()) - .and_then(|r| r.original.as_ref()) -} - -/// Per-flavor npm recovery: the wiring kinds disambiguate the lock flavor, -/// each fragment yields (resolved?, integrity). -fn recover_npm_fragment( - entry: &super::state::VendorEntry, - name: &str, - version: &str, -) -> Result { - let mk = |resolved: Option, integrity: LockIntegrity| LockfileEntry { - ecosystem: "npm", - purl: format!("pkg:npm/{name}@{version}"), - name: name.to_string(), - version: version.to_string(), - resolved, - integrity, - }; - - // package-lock / shrinkwrap: the original is the full lock entry object. - if let Some(obj) = wiring_original(entry, &["npm_lock_entry", "npm_lock_legacy_entry"]) { - let resolved = obj - .get("resolved") - .and_then(serde_json::Value::as_str) - .and_then(http_url); - if let Some(sri) = obj - .get("integrity") - .and_then(serde_json::Value::as_str) - .filter(|s| looks_like_sri(s)) - { - return Ok(mk(resolved, LockIntegrity::Sri(sri.to_string()))); - } - } - // pnpm: the original is the packages block's lines; pull - // `resolution: {integrity: …, tarball: …}`. - if let Some(lines) = wiring_original(entry, &["pnpm_lock_package"]).and_then(lines_of) { - let mut sri = None; - let mut tarball = None; - for line in &lines { - if let Some(v) = inline_yaml_field(line, "integrity:") { - sri = sri.or(Some(v)); - } - if let Some(v) = inline_yaml_field(line, "tarball:") { - tarball = tarball.or(http_url(&v)); - } - } - if let Some(sri) = sri.filter(|s| looks_like_sri(s)) { - return Ok(mk(tarball, LockIntegrity::Sri(sri))); - } - } - // yarn classic: block lines carry `integrity ` (preferred) and/or - // `resolved "#"`. - if let Some(lines) = wiring_original(entry, &["yarn_lock_block"]).and_then(lines_of) { - let mut url = None; - let mut sha1 = None; - let mut sri = None; - for line in &lines { - let t = line.trim(); - if let Some(rest) = t.strip_prefix("integrity ") { - let v = rest.trim().trim_matches('"'); - if looks_like_sri(v) { - sri = Some(v.to_string()); - } - } - if let Some(rest) = t.strip_prefix("resolved ") { - let v = rest.trim().trim_matches('"'); - let (u, frag) = v.split_once('#').unwrap_or((v, "")); - url = http_url(u); - if is_hex_of_len(frag, 40) { - sha1 = Some(frag.to_ascii_lowercase()); - } - } - } - if let Some(sri) = sri { - return Ok(mk(url, LockIntegrity::Sri(sri))); - } - if let Some(sha1) = sha1 { - return Ok(mk(url, LockIntegrity::Sha1Hex(sha1))); - } - } - // yarn berry: block lines carry `checksum: /`. - if let Some(lines) = wiring_original(entry, &["yarn_berry_lock_entry"]).and_then(lines_of) { - for line in &lines { - if let Some(v) = inline_yaml_field(line, "checksum:") { - if v.split_once('/') - .is_some_and(|(k, b)| !k.is_empty() && !b.is_empty()) - { - return Ok(mk(None, LockIntegrity::BerryChecksum(v))); - } - } - } - } - // Binary Bun records carry semantic registry metadata alongside the - // opaque fields needed for lossless restoration. Recovery must verify - // the snapshot's coordinates before trusting its download and digest. - for wiring in &entry.wiring { - if wiring.kind != "bun_lockb_package" { - continue; - } - let Some(original) = wiring.original.as_ref() else { - continue; - }; - if original.get("name").and_then(Value::as_str) != Some(name) - || original.get("version").and_then(Value::as_str) != Some(version) - { - continue; - } - if let Some(sri) = original - .get("integrity") - .and_then(Value::as_str) - .filter(|s| looks_like_sri(s)) - { - let resolved = original - .get("resolution") - .and_then(Value::as_str) - .and_then(http_url); - return Ok(mk(resolved, LockIntegrity::Sri(sri.to_string()))); - } - } - // bun: the original is the raw tuple line; the integrity is its last - // quoted SRI string. - if let Some(line) = - wiring_original(entry, &["bun_lock_package"]).and_then(|v| v.as_str().map(str::to_string)) - { - if let Some(sri) = line - .split('"') - .rev() - .find(|tok| looks_like_sri(tok)) - .map(str::to_string) - { - return Ok(mk(None, LockIntegrity::Sri(sri))); - } - } - Err("no pre-vendor npm registry fragment with a verifiable integrity recorded".to_string()) -} - -fn looks_like_sri(s: &str) -> bool { - ["sha512-", "sha384-", "sha256-", "sha1-"] - .iter() - .any(|p| s.starts_with(p) && s.len() > p.len()) -} - -/// A wiring `original` recorded as an array of text lines. -fn lines_of(v: &serde_json::Value) -> Option> { - v.as_array().map(|arr| { - arr.iter() - .filter_map(|l| l.as_str().map(str::to_string)) - .collect() - }) -} - -/// `… field: value` (optionally inside an inline `{…}` map) → value, with -/// trailing `,`/`}` and quotes stripped. -fn inline_yaml_field(line: &str, field: &str) -> Option { - let idx = line.find(field)?; - let rest = &line[idx + field.len()..]; - let end = rest.find([',', '}']).unwrap_or(rest.len()); - let v = rest[..end].trim().trim_matches(['\'', '"']).to_string(); - (!v.is_empty()).then_some(v) -} - -/// The DISTINCT `GEM remote:` bases across ALL GEM sections of the -/// Gemfile.lock (trailing `/` trimmed), in first-appearance order. A -/// vendored gem's spec block moved into its PATH section, so which GEM -/// section it came from is unrecoverable — ledger recovery may only build -/// a download URL when the lock's GEM sources agree on a single remote. -/// Collected scheme-AGNOSTICALLY: a non-http remote (a `file://` gem repo — -/// bundler 4.0.15 locks one GEM section per `source "file://…" do` block) -/// still counts toward the ambiguity decision; filtering it out first would -/// collapse a mixed http+file lock to one "agreed" remote and send the -/// file-sourced gem's name to the http one. The caller requires the single -/// survivor to be http(s). -async fn gem_remotes(project_root: &Path) -> Vec { - let Ok(text) = read_regular_to_string(&project_root.join("Gemfile.lock")).await else { - return Vec::new(); - }; - let mut out: Vec = Vec::new(); - let mut in_gem = false; - for line in text.lines() { - if line.trim().is_empty() { - continue; - } - if !line.starts_with(' ') { - in_gem = line.trim_end() == "GEM"; - continue; - } - if in_gem { - if let Some(rest) = line.trim().strip_prefix("remote:") { - let url = rest.trim().trim_end_matches('/').to_string(); - if !url.is_empty() && !out.contains(&url) { - out.push(url); - } - } - } - } - out -} - -/// First `{ url = "…", hash = "sha256:…" }` wheel in a uv.lock `[[package]]` -/// unit whose filename is a PURE wheel (`-none-any.whl`). -fn pure_wheel_from_uv_unit(unit: &str) -> Option<(String, String)> { - let mut search = unit; - while let Some(uidx) = search.find("url = \"") { - let after = &search[uidx + 7..]; - let uend = after.find('"')?; - let url = &after[..uend]; - let rest = &after[uend..]; - let advance = uidx + 7 + uend; - if url.ends_with("-none-any.whl") { - if let Some(hidx) = rest.find("hash = \"sha256:") { - let hafter = &rest[hidx + 15..]; - let hend = hafter.find('"')?; - let sha = &hafter[..hend]; - if is_hex_of_len(sha, 64) { - if let Some(url) = http_url(url) { - return Some((url, sha.to_ascii_lowercase())); - } - } - } - } - search = &search[advance..]; - } - None -} - -#[cfg(test)] -mod tests { - use super::*; - - async fn write(root: &Path, name: &str, content: &str) { - tokio::fs::write(root.join(name), content).await.unwrap(); - } - - fn entry<'a>(entries: &'a [LockfileEntry], name: &str) -> &'a LockfileEntry { - entries - .iter() - .find(|e| e.name == name) - .unwrap_or_else(|| panic!("no entry for {name}: {entries:?}")) - } - - // ── package-lock ────────────────────────────────────────────────────── - - const PACKAGE_LOCK: &str = r#"{ - "name": "fixture", - "version": "1.0.0", - "lockfileVersion": 3, - "packages": { - "": { "name": "fixture", "version": "1.0.0" }, - "packages/member": { "name": "member", "version": "0.0.1" }, - "node_modules/member": { "resolved": "packages/member", "link": true }, - "node_modules/left-pad": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", - "integrity": "sha512-XI5MPz==" - }, - "node_modules/@scope/pkg": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@scope/pkg/-/pkg-2.0.0.tgz", - "integrity": "sha512-scoped==" - }, - "node_modules/bundled-dep": { - "version": "1.0.0", - "inBundle": true - }, - "node_modules/git-dep": { - "version": "0.5.0", - "resolved": "git+ssh://git@github.com/x/git-dep.git#abc" - }, - "node_modules/vendored": { - "version": "3.0.0", - "resolved": "file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/vendored-3.0.0.tgz", - "integrity": "sha512-ours==" - }, - "node_modules/evil": { - "version": "../../escape", - "resolved": "https://registry.npmjs.org/evil/-/evil-1.0.0.tgz", - "integrity": "sha512-evil==" - }, - "node_modules/no-version": { - "resolved": "https://registry.npmjs.org/no-version/-/no-version-1.0.0.tgz" - } - } -} -"#; - - #[tokio::test] - async fn package_lock_inventories_registry_entries() { - let tmp = tempfile::tempdir().unwrap(); - write(tmp.path(), "package-lock.json", PACKAGE_LOCK).await; - - let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); - assert_eq!(flavor, NpmLockFlavor::PackageLock); - - let lp = entry(&entries, "left-pad"); - assert_eq!(lp.version, "1.3.0"); - assert_eq!(lp.purl, "pkg:npm/left-pad@1.3.0"); - assert_eq!( - lp.resolved.as_deref(), - Some("https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz") - ); - assert_eq!(lp.integrity, LockIntegrity::Sri("sha512-XI5MPz==".into())); - - let scoped = entry(&entries, "@scope/pkg"); - assert_eq!(scoped.purl, "pkg:npm/@scope/pkg@2.0.0"); - - // git deps stay listed (discovery) but carry no fetchable URL. - let git = entry(&entries, "git-dep"); - assert_eq!(git.resolved, None); - assert_eq!(git.integrity, LockIntegrity::None); - - // Workspace members, links, bundled deps, our vendored spec, the - // unsafe-version entry, and the version-less node are all absent. - for absent in [ - "member", - "fixture", - "bundled-dep", - "vendored", - "evil", - "no-version", - ] { - assert!( - !entries.iter().any(|e| e.name == absent), - "{absent} must not be inventoried: {entries:?}" - ); - } - } - - #[tokio::test] - async fn shrinkwrap_wins_over_package_lock() { - let tmp = tempfile::tempdir().unwrap(); - write(tmp.path(), "package-lock.json", PACKAGE_LOCK).await; - write( - tmp.path(), - "npm-shrinkwrap.json", - r#"{ "lockfileVersion": 3, "packages": { - "node_modules/only-in-shrinkwrap": { "version": "9.9.9" } } }"#, - ) - .await; - - let (_, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); - assert!(entries.iter().any(|e| e.name == "only-in-shrinkwrap")); - assert!(!entries.iter().any(|e| e.name == "left-pad")); - } - - #[tokio::test] - async fn legacy_v1_lock_without_packages_map_yields_none() { - let tmp = tempfile::tempdir().unwrap(); - write( - tmp.path(), - "package-lock.json", - r#"{ "lockfileVersion": 1, "dependencies": { "left-pad": { "version": "1.3.0" } } }"#, - ) - .await; - assert!(inventory_npm_lock(tmp.path()).await.unwrap().is_none()); - } - - // ── pnpm ────────────────────────────────────────────────────────────── - - const PNPM_LOCK: &str = "lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - -importers: - - .: - dependencies: - left-pad: - specifier: 1.3.0 - version: 1.3.0 - -packages: - - left-pad@1.3.0: - resolution: {integrity: sha512-XI5MPz==} - - '@scope/pkg@2.0.0': - resolution: {integrity: sha512-scoped==} - - peer-user@4.0.0(left-pad@1.3.0): - resolution: {integrity: sha512-peer==} - - local-thing@file:packages/local: - resolution: {directory: packages/local, type: directory} - - vendored@file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/vendored-3.0.0.tgz: - resolution: {integrity: sha512-ours==, tarball: file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/vendored-3.0.0.tgz} - -snapshots: - - left-pad@1.3.0: {} -"; - - #[tokio::test] - async fn pnpm_v9_keys_parse_with_peer_suffix_and_scoped_quoting() { - let tmp = tempfile::tempdir().unwrap(); - write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK).await; - - let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); - assert_eq!(flavor, NpmLockFlavor::Pnpm); - - assert_eq!( - entry(&entries, "left-pad").integrity, - LockIntegrity::Sri("sha512-XI5MPz==".into()) - ); - assert_eq!(entry(&entries, "@scope/pkg").version, "2.0.0"); - assert_eq!(entry(&entries, "peer-user").version, "4.0.0"); - // registry entries carry no URL in v9 — constructed at fetch time. - assert_eq!(entry(&entries, "left-pad").resolved, None); - // Exact set: the legacy v5/v6 grammars must not add or reshape v9 - // entries (local-thing and vendored stay skipped). - assert_eq!( - sorted_pairs(&entries), - vec![ - ("@scope/pkg".into(), "2.0.0".into()), - ("left-pad".into(), "1.3.0".into()), - ("peer-user".into(), "4.0.0".into()), - ] - ); - } - - fn sorted_pairs(entries: &[LockfileEntry]) -> Vec<(String, String)> { - let mut pairs: Vec<(String, String)> = entries - .iter() - .map(|e| (e.name.clone(), e.version.clone())) - .collect(); - pairs.sort(); - pairs - } - - // Real pnpm 7 shapes (lockfileVersion 5.4, captured from a pnpm 7.33.5 - // install: slash-separated `/name/version` keys, no `@` at all), plus - // synthetic keys in the same grammar: scoped, `_peer@x`-suffixed, - // `_`-suffixed, and a non-default-registry key (no leading `/`) - // that must stay out fail-closed. - const PNPM_LOCK_V5: &str = "lockfileVersion: 5.4 - -specifiers: - mkdirp: 0.5.5 - -dependencies: - mkdirp: 0.5.5 - -packages: - - /minimist/1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - dev: false - - /mkdirp/0.5.5: - resolution: {integrity: sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==} - hasBin: true - dependencies: - minimist: 1.2.8 - dev: false - - /@scope/pkg/2.0.0: - resolution: {integrity: sha512-scoped==} - dev: false - - /styled-thing/5.3.3_react@17.0.2: - resolution: {integrity: sha512-peered==} - dev: false - - /hashed-thing/1.0.0_abc123deadbeef: - resolution: {integrity: sha512-hashed==} - dev: false - - example.com/private-pkg/1.0.0: - resolution: {integrity: sha512-registry==} - dev: false -"; - - #[tokio::test] - async fn pnpm_v5_slash_keys_inventory_with_peer_and_hash_suffixes() { - let tmp = tempfile::tempdir().unwrap(); - write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK_V5).await; - - let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); - // The legacy grammars route to the PnpmLegacy wiring flavor now - // (they used to reach here through the version-refusal fallback); - // the inventory content is identical either way. - assert_eq!(flavor, NpmLockFlavor::PnpmLegacy); - assert_eq!( - sorted_pairs(&entries), - vec![ - ("@scope/pkg".into(), "2.0.0".into()), - ("hashed-thing".into(), "1.0.0".into()), - ("minimist".into(), "1.2.8".into()), - ("mkdirp".into(), "0.5.5".into()), - ("styled-thing".into(), "5.3.3".into()), - ] - ); - assert_eq!( - entry(&entries, "minimist").integrity, - LockIntegrity::Sri( - "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==" - .into() - ) - ); - assert_eq!(entry(&entries, "minimist").purl, "pkg:npm/minimist@1.2.8"); - } - - // Real pnpm 8 shapes (lockfileVersion 6.0, captured from a pnpm 8.15.9 - // install: v9's `name@version` behind a leading `/`), plus synthetic - // scoped and peer-parenthesized keys in the same grammar. - const PNPM_LOCK_V6: &str = "lockfileVersion: '6.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -dependencies: - mkdirp: - specifier: 0.5.5 - version: 0.5.5 - -packages: - - /minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - dev: false - - /mkdirp@0.5.5: - resolution: {integrity: sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==} - hasBin: true - dependencies: - minimist: 1.2.8 - dev: false - - /@scope/pkg@2.0.0: - resolution: {integrity: sha512-scoped==} - dev: false - - /peer-user@4.0.0(left-pad@1.3.0): - resolution: {integrity: sha512-peer==} - dev: false -"; - - #[tokio::test] - async fn pnpm_v6_leading_slash_keys_inventory_with_peer_parens() { - let tmp = tempfile::tempdir().unwrap(); - write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK_V6).await; - - let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); - // The legacy grammars route to the PnpmLegacy wiring flavor now - // (they used to reach here through the version-refusal fallback); - // the inventory content is identical either way. - assert_eq!(flavor, NpmLockFlavor::PnpmLegacy); - assert_eq!( - sorted_pairs(&entries), - vec![ - ("@scope/pkg".into(), "2.0.0".into()), - ("minimist".into(), "1.2.8".into()), - ("mkdirp".into(), "0.5.5".into()), - ("peer-user".into(), "4.0.0".into()), - ] - ); - assert_eq!( - entry(&entries, "mkdirp").integrity, - LockIntegrity::Sri( - "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==" - .into() - ) - ); - assert_eq!( - entry(&entries, "@scope/pkg").purl, - "pkg:npm/@scope/pkg@2.0.0" - ); - } - - /// A pnpm→yarn-berry migration leaves a stale root pnpm-lock.yaml behind - /// a `.pnp.cjs` loader. The probe's refusal there is a yarn refusal, not - /// a pnpm one — the legacy-lock fallback must NOT inventory the stale - /// lock as the live dependency set; the yarn-PnP diagnosis propagates - /// instead. - #[tokio::test] - async fn stale_pnpm_lock_behind_yarn_berry_pnp_marker_is_not_inventoried() { - let tmp = tempfile::tempdir().unwrap(); - write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK).await; - write(tmp.path(), ".pnp.cjs", "/* yarn berry PnP loader */").await; - let diag = inventory_npm_lock(tmp.path()).await.unwrap_err(); - assert_eq!( - diag.code, "vendor_yarn_berry_unsupported", - "a stale pnpm-lock.yaml behind a yarn-berry PnP marker must not be inventoried" - ); - } - - /// A malformed binary lock fails closed with format context, including - /// beside a different package manager's lock. A text Bun lock wins. - #[tokio::test] - async fn malformed_bun_lockb_yields_a_diagnosis_without_inventorying_siblings() { - for sibling in [ - None, - Some(("pnpm-lock.yaml", PNPM_LOCK)), - Some(("yarn.lock", YARN_CLASSIC)), - Some(("package-lock.json", PACKAGE_LOCK)), - ] { - let tmp = tempfile::tempdir().unwrap(); - if let Some((name, content)) = sibling { - write(tmp.path(), name, content).await; - } - write(tmp.path(), "bun.lockb", "\0binary").await; - let diag = inventory_npm_lock(tmp.path()).await.unwrap_err(); - assert_eq!(diag.code, "bun_lockb_invalid"); - assert!(diag.detail.contains("bun.lockb"), "{}", diag.detail); - let (entries, unsupported) = inventory_project_diagnosed(tmp.path()).await; - assert!(entries.is_empty(), "{entries:?}"); - assert_eq!(unsupported, vec![diag]); - - write(tmp.path(), "bun.lock", BUN_LOCK).await; - let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); - assert_eq!(flavor, NpmLockFlavor::Bun); - assert_eq!(entry(&entries, "left-pad").version, "1.3.0"); - assert!(inventory_project_diagnosed(tmp.path()).await.1.is_empty()); - } - } - - #[tokio::test] - async fn bun_binary_inventory_works_without_an_install_or_runtime() { - let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bun-lockb"); - for version in [ - "0.1.1", "0.6.7", "0.6.8", "0.8.1", "1.0.0", "1.0.36", "1.1.0", "1.1.38", "1.1.45", - ] { - let tmp = tempfile::tempdir().unwrap(); - let bytes = std::fs::read(fixtures.join(version).join("bun.lockb")).unwrap(); - tokio::fs::write(tmp.path().join("bun.lockb"), &bytes) - .await - .unwrap(); - let (entries, diagnoses) = inventory_project_diagnosed(tmp.path()).await; - assert!(diagnoses.is_empty(), "Bun {version}: {diagnoses:?}"); - assert_eq!( - sorted_pairs(&entries), - vec![ - ("is-number".into(), "7.0.0".into()), - ("minimist".into(), "1.2.2".into()) - ], - "Bun {version}" - ); - let minimist = entry(&entries, "minimist"); - assert!( - minimist - .resolved - .as_deref() - .is_some_and(|url| url.ends_with("minimist-1.2.2.tgz")), - "Bun {version}: {minimist:?}" - ); - assert!( - super::super::bun_lock::preflight_vendor(tmp.path()) - .await - .is_ok(), - "Bun {version}" - ); - assert_eq!( - tokio::fs::read(tmp.path().join("bun.lockb")).await.unwrap(), - bytes, - "discovery/preflight must preserve Bun {version} bytes" - ); - assert!(!tmp.path().join("bun.lock").exists()); - assert!(!tmp.path().join("node_modules").exists()); - } - } - - #[tokio::test] - async fn bun_binary_vendor_integrity_follows_live_package_records() { - let bytes = include_bytes!("../../tests/fixtures/bun-lockb/1.1.45/bun.lockb"); - let mut lock = super::super::bun_lockb::BunLockb::parse(bytes).unwrap(); - let package = lock - .packages() - .unwrap() - .into_iter() - .find(|package| package.name == "minimist") - .unwrap(); - let tmp = tempfile::tempdir().unwrap(); - let rel = ".socket/vendor/npm/11111111-1111-4111-8111-111111111111/minimist-1.2.2.tgz"; - let first = format!("sha512-{}", "A".repeat(86) + "=="); - lock.set_package(package.id, rel, &first).unwrap(); - tokio::fs::write(tmp.path().join("bun.lockb"), lock.bytes()) - .await - .unwrap(); - assert_eq!( - wired_vendor_integrity(tmp.path(), rel).await, - Some(LockIntegrity::Sri(first)) - ); - let next = rel.replace( - "11111111-1111-4111-8111-111111111111", - "22222222-2222-4222-8222-222222222222", - ); - lock.set_package( - package.id, - &next, - &format!("sha512-{}", "A".repeat(86) + "=="), - ) - .unwrap(); - tokio::fs::write(tmp.path().join("bun.lockb"), lock.bytes()) - .await - .unwrap(); - assert_eq!( - wired_vendor_integrity(tmp.path(), rel).await, - None, - "retired strings are not active resolutions" - ); - assert!(wired_vendor_integrity(tmp.path(), &next).await.is_some()); - write(tmp.path(), "bun.lock", BUN_LOCK).await; - assert_eq!( - wired_vendor_integrity(tmp.path(), &next).await, - None, - "text lock takes precedence" - ); - } - - /// A pnpm-lock.yaml whose lockfileVersion the probe refuses — pnpm 6 - /// wrote 5.3; only 5.4/6.0/9.0 route to a backend. This is the shape - /// that reaches the version-refusal discovery fallback, where a live - /// sibling lock may be sitting beside it after a migration. - const PNPM_LOCK_V53_STALE: &str = "lockfileVersion: 5.3 - -packages: - - /dead-pnpm-dep/1.0.0: - resolution: {integrity: sha512-dead==} -"; - - /// A pnpm→yarn migration leaves a version-refused pnpm-lock.yaml beside - /// the live yarn.lock. The probe checks pnpm-lock.yaml BEFORE yarn.lock, - /// so its refusal says nothing about the sibling — the fallback must - /// surface the LIVE yarn resolutions, not the dead pnpm ones. - #[tokio::test] - async fn stale_pnpm_lock_beside_live_yarn_classic_yields_yarn_entries() { - let tmp = tempfile::tempdir().unwrap(); - write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK_V53_STALE).await; - write(tmp.path(), "yarn.lock", YARN_CLASSIC).await; - - let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); - assert_eq!(flavor, NpmLockFlavor::YarnClassic); - assert_eq!(entry(&entries, "left-pad").version, "1.3.0"); - assert!( - !entries.iter().any(|e| e.name == "dead-pnpm-dep"), - "dead pnpm resolutions must not pose as the live set: {entries:?}" - ); - } - - /// Same migration hazard toward yarn berry (node-modules linker: no PnP - /// marker, so the pnpm version refusal is what fires). - #[tokio::test] - async fn stale_pnpm_lock_beside_live_yarn_berry_yields_berry_entries() { - let tmp = tempfile::tempdir().unwrap(); - write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK_V53_STALE).await; - write(tmp.path(), "yarn.lock", YARN_BERRY).await; - - let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); - assert_eq!(flavor, NpmLockFlavor::YarnBerry); - assert_eq!(entry(&entries, "left-pad").version, "1.3.0"); - assert!( - !entries.iter().any(|e| e.name == "dead-pnpm-dep"), - "dead pnpm resolutions must not pose as the live set: {entries:?}" - ); - } - - /// Same migration hazard toward npm: the live package-lock.json wins - /// over the version-refused pnpm lock. - #[tokio::test] - async fn stale_pnpm_lock_beside_live_package_lock_yields_npm_entries() { - let tmp = tempfile::tempdir().unwrap(); - write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK_V53_STALE).await; - write(tmp.path(), "package-lock.json", PACKAGE_LOCK).await; - - let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); - assert_eq!(flavor, NpmLockFlavor::PackageLock); - assert_eq!(entry(&entries, "left-pad").version, "1.3.0"); - assert!( - !entries.iter().any(|e| e.name == "dead-pnpm-dep"), - "dead pnpm resolutions must not pose as the live set: {entries:?}" - ); - } - - /// A version-refused pnpm lock ALONE is a genuine old-pnpm project (no - /// migration happened) — the discovery fallback must still read it. - #[tokio::test] - async fn unsupported_pnpm_lock_alone_is_still_inventoried() { - let tmp = tempfile::tempdir().unwrap(); - write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK_V53_STALE).await; - - let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); - assert_eq!(flavor, NpmLockFlavor::Pnpm); - assert_eq!(entry(&entries, "dead-pnpm-dep").version, "1.0.0"); - } - - /// pnpm→bun migration with the TEXT bun.lock: the router routes Bun at - /// its bun step, which runs BEFORE the pnpm sniff, so no refusal (and no - /// fallback) ever fires — bun's entries are the inventory. Pinned here - /// because it is the router-precedence twin of the sibling checks above. - #[tokio::test] - async fn stale_pnpm_lock_beside_bun_lock_routes_to_bun() { - let tmp = tempfile::tempdir().unwrap(); - write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK_V53_STALE).await; - write(tmp.path(), "bun.lock", BUN_LOCK).await; - - let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); - assert_eq!(flavor, NpmLockFlavor::Bun); - assert_eq!(entry(&entries, "left-pad").version, "1.3.0"); - assert!( - !entries.iter().any(|e| e.name == "dead-pnpm-dep"), - "dead pnpm resolutions must not pose as the live set: {entries:?}" - ); - } - - /// A live sibling lock FILE that yields no entries (here: an empty - /// package-lock, as a fresh dep-less `npm install` writes) still proves - /// the migration happened — the dead pnpm resolutions must stay out even - /// though there is nothing live to return. - #[tokio::test] - async fn stale_pnpm_lock_beside_empty_live_lock_yields_none() { - let tmp = tempfile::tempdir().unwrap(); - write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK_V53_STALE).await; - write( - tmp.path(), - "package-lock.json", - r#"{ "lockfileVersion": 3, "packages": { "": {} } }"#, - ) - .await; - assert!( - inventory_npm_lock(tmp.path()).await.unwrap().is_none(), - "an empty live sibling must not resurrect the dead pnpm resolutions" - ); - } - - // ── shrinkwrap.yaml (pnpm 1/2) ────────────────────────────────────────── - - /// The exact grammar the 2026-08-18 legacy matrix captured from a real - /// pnpm 2 install (shrinkwrapVersion 3): v5-style `/name/version` keys, - /// BLOCK-mapped `resolution:` (integrity nested on its own line — every - /// pnpm-lock.yaml generation writes the inline `{…}` flow map instead), - /// quoted top-level `registry:`, and a transitive dep (`minimist`) - /// listed only under `packages:`. - const SHRINKWRAP_YAML: &str = "dependencies: - left-pad: 1.3.0 - mkdirp: 0.5.5 -packages: - /left-pad/1.3.0: - deprecated: use String.prototype.padStart() - dev: false - resolution: - integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA== - /minimist/1.2.8: - dev: false - resolution: - integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== - /mkdirp/0.5.5: - dependencies: - minimist: 1.2.8 - dev: false - hasBin: true - resolution: - integrity: sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ== -registry: 'https://registry.npmjs.org/' -shrinkwrapMinorVersion: 9 -shrinkwrapVersion: 3 -specifiers: - left-pad: 1.3.0 - mkdirp: 0.5.5 -"; - - /// A pnpm <=2 project (shrinkwrap.yaml, no pnpm-lock.yaml, no other - /// lock) must be inventoried through the shrinkwrap fallback: same v5 - /// key grammar, integrity read from the BLOCK-mapped resolution — - /// without it such projects report lockfileOnlyPackages=0 despite the - /// lock listing everything. - #[tokio::test] - async fn shrinkwrap_yaml_inventories_pnpm_legacy_project() { - let tmp = tempfile::tempdir().unwrap(); - write(tmp.path(), "shrinkwrap.yaml", SHRINKWRAP_YAML).await; - - let (flavor, entries) = inventory_npm_lock(tmp.path()) - .await - .unwrap() - .expect("shrinkwrap.yaml must be inventoried"); - assert_eq!(flavor, NpmLockFlavor::PnpmLegacy); - assert_eq!(entries.len(), 3, "all three packages entries: {entries:?}"); - - let lp = entry(&entries, "left-pad"); - assert_eq!(lp.version, "1.3.0"); - assert_eq!(lp.purl, "pkg:npm/left-pad@1.3.0"); - assert_eq!( - lp.integrity, - LockIntegrity::Sri( - "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/\ - aVx2HrNcqQGsdot8ghrjyrvMCoEA==" - .into() - ), - "block-mapped resolution integrity must be captured" - ); - assert_eq!(lp.resolved, None, "no tarball recorded → registry URL"); - - // The transitive dep (a dependencies: child inside mkdirp's entry - // must not shadow it) and the binary-carrying dep both inventory. - assert_eq!(entry(&entries, "minimist").version, "1.2.8"); - assert_eq!(entry(&entries, "mkdirp").version, "0.5.5"); - } - - /// A root pnpm-lock.yaml wins over shrinkwrap.yaml: the flavor probe - /// recognizes the modern lock, so the legacy fallback never runs — a - /// leftover shrinkwrap.yaml from a long-ago pnpm upgrade must not - /// inject dead resolutions. - #[tokio::test] - async fn pnpm_lock_wins_over_stale_shrinkwrap_yaml() { - let tmp = tempfile::tempdir().unwrap(); - write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK).await; - write(tmp.path(), "shrinkwrap.yaml", SHRINKWRAP_YAML).await; - - let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); - assert_eq!(flavor, NpmLockFlavor::Pnpm); - assert!( - !entries.iter().any(|e| e.name == "mkdirp"), - "shrinkwrap-only entries must not leak in: {entries:?}" - ); - } - - /// Same stale-lock hazard as the pnpm-lock fallbacks: a shrinkwrap.yaml - /// behind another family's marker (yarn-berry PnP here — the probe - /// refuses with a NON-missing code) is migration debris, not the live - /// dependency set; the yarn-PnP diagnosis propagates instead. - #[tokio::test] - async fn stale_shrinkwrap_behind_yarn_berry_pnp_marker_is_not_inventoried() { - let tmp = tempfile::tempdir().unwrap(); - write(tmp.path(), "shrinkwrap.yaml", SHRINKWRAP_YAML).await; - write(tmp.path(), ".pnp.cjs", "/* yarn berry PnP loader */").await; - let diag = inventory_npm_lock(tmp.path()).await.unwrap_err(); - assert_eq!( - diag.code, "vendor_yarn_berry_unsupported", - "a shrinkwrap.yaml behind a yarn-berry PnP marker must not be inventoried" - ); - } - - /// pnpm's own `node-linker=pnp` layout (`.pnp.cjs` + pnpm store + lock, - /// no yarn.lock) refuses with the pnpm-specific PnP code, which - /// PROPAGATES as the layout diagnosis rather than falling back to the - /// lock read — under PnP the installed-tree crawl is also structurally - /// empty, so the honest answer is the refusal, not a lock-only - /// inventory posing as a served project (see - /// `pnp_layouts_propagate_the_diagnosis_instead_of_yielding_none`). - #[tokio::test] - async fn pnpm_pnp_layout_propagates_the_diagnosis() { - let tmp = tempfile::tempdir().unwrap(); - write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK).await; - write(tmp.path(), ".pnp.cjs", "/* pnpm node-linker=pnp loader */").await; - write_nested(tmp.path(), "node_modules/.modules.yaml", "").await; - - let diag = inventory_npm_lock(tmp.path()).await.unwrap_err(); - assert_eq!(diag.code, "vendor_pnpm_pnp_unsupported"); - } - - // ── Rush monorepo ─────────────────────────────────────────────────────── - - /// Write `content` to `rel` under `root`, creating parent dirs. - async fn write_nested(root: &Path, rel: &str, content: &str) { - let path = root.join(rel); - tokio::fs::create_dir_all(path.parent().unwrap()) - .await - .unwrap(); - tokio::fs::write(path, content).await.unwrap(); - } - - #[tokio::test] - async fn rush_monorepo_inventories_common_and_subspace_locks() { - // No root package.json/lock — only rush.json plus the generated - // source-of-truth lock under common/config and one subspace lock. - let tmp = tempfile::tempdir().unwrap(); - write(tmp.path(), "rush.json", r#"{"rushVersion":"5.0.0"}"#).await; - write_nested(tmp.path(), "common/config/rush/pnpm-lock.yaml", PNPM_LOCK).await; - write_nested( - tmp.path(), - "common/config/subspaces/frontend/pnpm-lock.yaml", - "lockfileVersion: '9.0' - -packages: - - only-in-subspace@9.9.9: - resolution: {integrity: sha512-sub==} -", - ) - .await; - - let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); - assert_eq!(flavor, NpmLockFlavor::Pnpm); - // Union across the common lock and the subspace lock. - assert_eq!(entry(&entries, "left-pad").version, "1.3.0"); - assert_eq!(entry(&entries, "only-in-subspace").version, "9.9.9"); - } - - #[tokio::test] - async fn rush_json_without_any_lock_yields_none() { - // rush.json but no common/subspace lock at all: nothing to inventory. - let tmp = tempfile::tempdir().unwrap(); - write(tmp.path(), "rush.json", r#"{"rushVersion":"5.0.0"}"#).await; - assert!(inventory_npm_lock(tmp.path()).await.unwrap().is_none()); - } - - #[tokio::test] - async fn root_pnpm_lock_wins_over_rush_fallback() { - // A plain pnpm project that also happens to carry a stray rush.json - // must route through the normal root-lock path, never the fallback. - let tmp = tempfile::tempdir().unwrap(); - write(tmp.path(), "rush.json", r#"{"rushVersion":"5.0.0"}"#).await; - write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK).await; - write_nested( - tmp.path(), - "common/config/rush/pnpm-lock.yaml", - "lockfileVersion: '9.0' - -packages: - - only-in-common@1.0.0: - resolution: {integrity: sha512-common==} -", - ) - .await; - - let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); - assert_eq!(flavor, NpmLockFlavor::Pnpm); - assert!(entries.iter().any(|e| e.name == "left-pad")); - assert!( - !entries.iter().any(|e| e.name == "only-in-common"), - "the root lock must win; the rush fallback must not run: {entries:?}" - ); - } - - // ── yarn classic ────────────────────────────────────────────────────── - - const YARN_CLASSIC: &str = "# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -\"@scope/pkg@^2.0.0\": - version \"2.0.0\" - resolved \"https://registry.yarnpkg.com/@scope/pkg/-/pkg-2.0.0.tgz#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\" - integrity sha512-scoped== - -left-pad@1.3.0, left-pad@^1.3.0: - version \"1.3.0\" - resolved \"https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\" - integrity sha512-XI5MPz== - -old-school@0.1.0: - version \"0.1.0\" - resolved \"https://registry.yarnpkg.com/old-school/-/old-school-0.1.0.tgz#cccccccccccccccccccccccccccccccccccccccc\" - -aliased@npm:real-name@^3.0.0: - version \"3.0.0\" - resolved \"https://registry.yarnpkg.com/real-name/-/real-name-3.0.0.tgz#dddddddddddddddddddddddddddddddddddddddd\" - integrity sha512-alias== -"; - - #[tokio::test] - async fn yarn_classic_blocks_yield_resolved_sha1_and_integrity() { - let tmp = tempfile::tempdir().unwrap(); - write(tmp.path(), "yarn.lock", YARN_CLASSIC).await; - - let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); - assert_eq!(flavor, NpmLockFlavor::YarnClassic); - - let lp = entry(&entries, "left-pad"); - assert_eq!( - lp.resolved.as_deref(), - Some("https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz"), - "the #sha1 fragment is split off the URL" - ); - assert_eq!(lp.integrity, LockIntegrity::Sri("sha512-XI5MPz==".into())); - - // Integrity-less old locks fall back to the sha1 fragment. - assert_eq!( - entry(&entries, "old-school").integrity, - LockIntegrity::Sha1Hex("c".repeat(40)) - ); - - // `alias@npm:real@range` resolves to the real name. - assert!(entries.iter().any(|e| e.name == "real-name")); - assert_eq!(entry(&entries, "@scope/pkg").version, "2.0.0"); - } - - // ── yarn berry ──────────────────────────────────────────────────────── - - const YARN_BERRY: &str = - "# This file is generated by running \"yarn install\" inside your project. -# Manifest files (package.json) are also used. - -__metadata: - version: 8 - cacheKey: 10c0 - -\"fixture@workspace:.\": - version: 0.0.0-use.local - resolution: \"fixture@workspace:.\" - languageName: unknown - linkType: soft - -\"left-pad@npm:1.3.0\": - version: 1.3.0 - resolution: \"left-pad@npm:1.3.0\" - checksum: 10c0/deadbeefcafe== - languageName: node - linkType: hard - -\"@scope/pkg@npm:^2.0.0\": - version: 2.0.0 - resolution: \"@scope/pkg@npm:2.0.0\" - checksum: 10c0/scopedchecksum== - languageName: node - linkType: hard -"; - - #[tokio::test] - async fn yarn_berry_registry_resolutions_inventory_with_checksums() { - let tmp = tempfile::tempdir().unwrap(); - write(tmp.path(), "yarn.lock", YARN_BERRY).await; - - let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); - assert_eq!(flavor, NpmLockFlavor::YarnBerry); - - let lp = entry(&entries, "left-pad"); - assert_eq!(lp.version, "1.3.0"); - assert_eq!( - lp.integrity, - LockIntegrity::BerryChecksum("10c0/deadbeefcafe==".into()) - ); - assert_eq!(entry(&entries, "@scope/pkg").version, "2.0.0"); - // The workspace root is not a registry package. - assert!(!entries.iter().any(|e| e.name == "fixture"), "{entries:?}"); - } - - // ── bun ─────────────────────────────────────────────────────────────── - - const BUN_LOCK: &str = r#"{ - "lockfileVersion": 1, - "workspaces": { - "": { "name": "fixture", "dependencies": { "left-pad": "1.3.0" } }, - }, - "packages": { - "left-pad": ["left-pad@1.3.0", "", {}, "sha512-XI5MPz=="], - "@scope/pkg": ["@scope/pkg@2.0.0", "", {}, "sha512-scoped=="], - "vendored": ["vendored@file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/vendored-3.0.0.tgz", {}], - "linked": ["linked@workspace:packages/linked", {}], - "consumer": ["consumer@workspace:packages/consumer", { "dependencies": { "left-pad": "1.3.0" } }], - } -} -"#; - - #[tokio::test] - async fn bun_registry_tuples_parse_and_locals_are_skipped() { - // lockfileVersion 0 (bun 1.1.39–1.1.45 text opt-in), 1 (bun 1.2/1.3) - // and 2 (bun 1.4) share one registry-tuple grammar, so inventory - // must read all three identically. The workspace entries carry the - // real v0 spelling — a 2-tuple with the member's deps object - // (`{}` when dep-less) — and are skipped like every non-registry - // shape. - for version in [0u64, 1, 2] { - let lock = BUN_LOCK.replace( - "\"lockfileVersion\": 1,", - &format!("\"lockfileVersion\": {version},"), - ); - let tmp = tempfile::tempdir().unwrap(); - write(tmp.path(), "bun.lock", &lock).await; - - let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); - assert_eq!(flavor, NpmLockFlavor::Bun); - - assert_eq!( - entry(&entries, "left-pad").integrity, - LockIntegrity::Sri("sha512-XI5MPz==".into()), - "lockfileVersion {version}" - ); - assert_eq!(entry(&entries, "left-pad").resolved, None); - assert_eq!(entry(&entries, "@scope/pkg").version, "2.0.0"); - for absent in ["vendored", "linked", "consumer"] { - assert!( - !entries.iter().any(|e| e.name == absent), - "lockfileVersion {version}: `{absent}` must be skipped: {entries:?}" - ); - } - assert_eq!(entries.len(), 2, "lockfileVersion {version}: {entries:?}"); - } - - // An unsupported lockfileVersion (a future 3) yields no inventory at - // all — fail closed, same posture as the vendor/redirect gates. - let lock = BUN_LOCK.replace("\"lockfileVersion\": 1,", "\"lockfileVersion\": 3,"); - assert_ne!(lock, BUN_LOCK, "replacement must hit"); - let tmp = tempfile::tempdir().unwrap(); - write(tmp.path(), "bun.lock", &lock).await; - assert!( - inventory_npm_lock(tmp.path()).await.unwrap().is_none(), - "a lockfileVersion-3 bun.lock must not be inventoried" - ); - } - - // ── shared semantics ────────────────────────────────────────────────── - - #[tokio::test] - async fn lookup_bridges_percent_encoded_purls() { - let entries = vec![ - LockfileEntry::npm("@scope/pkg", "2.0.0", None, LockIntegrity::None), - LockfileEntry::npm("left-pad", "1.3.0", None, LockIntegrity::None), - ]; - assert!(lookup(&entries, "pkg:npm/%40scope/pkg@2.0.0").is_some()); - assert!(lookup(&entries, "pkg:npm/@scope/pkg@2.0.0").is_some()); - assert!(lookup(&entries, "pkg:npm/left-pad@1.3.0?artifact_id=x").is_some()); - assert!(lookup(&entries, "pkg:npm/left-pad@9.9.9").is_none()); - assert!(lookup(&entries, "pkg:pypi/left-pad@1.3.0").is_none()); - } - - #[tokio::test] - async fn dedup_prefers_integrity_bearing_instance() { - let raw = vec![ - LockfileEntry::npm("dup", "1.0.0", None, LockIntegrity::None), - LockfileEntry::npm( - "dup", - "1.0.0", - None, - LockIntegrity::Sri("sha512-x==".into()), - ), - LockfileEntry::npm("dup", "1.0.0", None, LockIntegrity::None), - ]; - let out = finalize_npm(raw); - assert_eq!(out.len(), 1); - assert_eq!(out[0].integrity, LockIntegrity::Sri("sha512-x==".into())); - } - - #[tokio::test] - async fn cargo_lock_inventories_crates_io_entries() { - let tmp = tempfile::tempdir().unwrap(); - write( - tmp.path(), - "Cargo.lock", - r#"# This file is automatically @generated by Cargo. -version = 4 - -[[package]] -name = "fixture" -version = "0.1.0" - -[[package]] -name = "serde" -version = "1.0.200" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddc6f9cc94d67c0e21aaf7eda3a010fd3af78ebf6e096aa6e2e13c79749cce4f" - -[[package]] -name = "git-dep" -version = "0.5.0" -source = "git+https://github.com/x/git-dep?rev=abc#abc" - -[[package]] -name = "sparse-crate" -version = "2.0.0" -source = "sparse+https://index.crates.io/" -checksum = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" -"#, - ) - .await; - - let entries = inventory_cargo_lock(tmp.path()).await.unwrap(); - let serde_entry = entry(&entries, "serde"); - assert_eq!(serde_entry.version, "1.0.200"); - assert_eq!(serde_entry.purl, "pkg:cargo/serde@1.0.200"); - assert_eq!( - serde_entry.integrity, - LockIntegrity::Sha256Hex( - "ddc6f9cc94d67c0e21aaf7eda3a010fd3af78ebf6e096aa6e2e13c79749cce4f".into() - ) - ); - assert!(matches!( - entry(&entries, "sparse-crate").integrity, - LockIntegrity::Sha256Hex(_) - )); - // Workspace member (no source) excluded; git source unverifiable. - assert!(!entries.iter().any(|e| e.name == "fixture")); - assert_eq!(entry(&entries, "git-dep").integrity, LockIntegrity::None); - } - - #[tokio::test] - async fn go_sum_inventories_module_zip_lines() { - let tmp = tempfile::tempdir().unwrap(); - write( - tmp.path(), - "go.sum", - "github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=\n\ - github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=\n\ - golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=\n", - ) - .await; - - let entries = inventory_go_sum(tmp.path()).await.unwrap(); - assert_eq!(entries.len(), 2, "the /go.mod line is skipped: {entries:?}"); - let gin = entry(&entries, "github.com/gin-gonic/gin"); - assert_eq!(gin.version, "v1.9.1"); - assert_eq!(gin.purl, "pkg:golang/github.com/gin-gonic/gin@v1.9.1"); - assert_eq!( - gin.integrity, - LockIntegrity::GoH1("h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=".into()) - ); - } - - #[tokio::test] - async fn lookup_matches_cargo_and_golang_purls() { - let entries = vec![ - LockfileEntry { - ecosystem: "cargo", - name: "serde".into(), - version: "1.0.200".into(), - purl: "pkg:cargo/serde@1.0.200".into(), - resolved: None, - integrity: LockIntegrity::None, - }, - LockfileEntry { - ecosystem: "golang", - name: "github.com/x/y".into(), - version: "v1.0.0".into(), - purl: "pkg:golang/github.com/x/y@v1.0.0".into(), - resolved: None, - integrity: LockIntegrity::None, - }, - ]; - assert!(lookup(&entries, "pkg:cargo/serde@1.0.200").is_some()); - assert!(lookup(&entries, "pkg:golang/github.com/x/y@v1.0.0").is_some()); - assert!(lookup(&entries, "pkg:cargo/serde@9.9.9").is_none()); - assert!( - lookup(&entries, "pkg:npm/serde@1.0.200").is_none(), - "ecosystem tags must match, not just name@version" - ); - } - - #[tokio::test] - async fn composer_lock_inventories_dist_entries() { - let tmp = tempfile::tempdir().unwrap(); - write( - tmp.path(), - "composer.lock", - r#"{ - "packages": [ - { - "name": "Monolog/Monolog", - "version": "v3.5.0", - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/abc", - "shasum": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" - } - }, - { - "name": "vendored/pkg", - "version": "1.0.0", - "dist": { "type": "path", "url": ".socket/vendor/composer/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/vendored/pkg@1.0.0" } - } - ], - "packages-dev": [ - { - "name": "symfony/console", - "version": "v6.4.1", - "dist": { "type": "zip", "url": "https://example.com/console.zip", "shasum": "" } - } - ] -}"#, - ) - .await; - - let entries = inventory_composer_lock(tmp.path()).await.unwrap(); - let monolog = entry(&entries, "monolog/monolog"); - assert_eq!( - monolog.version, "3.5.0", - "leading v dropped, name lowercased" - ); - assert_eq!(monolog.purl, "pkg:composer/monolog/monolog@3.5.0"); - assert!(matches!(monolog.integrity, LockIntegrity::Sha1Hex(_))); - assert!(monolog.resolved.as_deref().unwrap().contains("zipball")); - // Empty shasum → discovery-only; path dist (ours) excluded. - assert_eq!( - entry(&entries, "symfony/console").integrity, - LockIntegrity::None - ); - assert!(!entries.iter().any(|e| e.name == "vendored/pkg")); - } - - #[tokio::test] - async fn gemfile_lock_inventories_specs_and_checksums() { - let tmp = tempfile::tempdir().unwrap(); - write( - tmp.path(), - "Gemfile.lock", - "GEM\n remote: https://rubygems.org/\n specs:\n rails (7.1.0)\n \ - actionpack (= 7.1.0)\n rack (3.0.8)\n nokogiri (1.16.5-arm64-darwin)\n\n\ - PLATFORMS\n ruby\n\nDEPENDENCIES\n rails\n\nCHECKSUMS\n \ - rails (7.1.0) sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n\n\ - BUNDLED WITH\n 2.6.0\n", - ) - .await; - - let entries = inventory_gemfile_lock(tmp.path()).await.unwrap(); - let rails = entry(&entries, "rails"); - assert_eq!(rails.version, "7.1.0"); - assert_eq!(rails.purl, "pkg:gem/rails@7.1.0"); - assert!(matches!(rails.integrity, LockIntegrity::Sha256Hex(_))); - assert_eq!( - rails.resolved.as_deref(), - Some("https://rubygems.org/downloads/rails-7.1.0.gem") - ); - // No CHECKSUMS entry → discovery-only; platform gem skipped; - // dependency range lines never parse as specs. - assert_eq!(entry(&entries, "rack").integrity, LockIntegrity::None); - assert!(!entries.iter().any(|e| e.name == "nokogiri")); - assert!(!entries.iter().any(|e| e.name == "actionpack")); - } - - /// Multi-source lock (two GEM sections, the exact shape bundler 4.0.15 - /// writes for a Gemfile `source … do` block — fixture mirrors a real - /// `bundle lock --add-checksums` run): each spec must resolve against - /// its OWN section's remote, never the first remote in the file. - #[tokio::test] - async fn gemfile_lock_multi_source_resolves_each_spec_against_its_own_remote() { - let tmp = tempfile::tempdir().unwrap(); - write( - tmp.path(), - "Gemfile.lock", - &format!( - "GEM\n remote: https://gems.corp.example/\n specs:\n private-gem (1.0.0)\n\n\ - GEM\n remote: https://rubygems.org/\n specs:\n rack (3.2.6)\n\n\ - PLATFORMS\n ruby\n\nDEPENDENCIES\n private-gem (= 1.0.0)!\n rack (= 3.2.6)\n\n\ - CHECKSUMS\n private-gem (1.0.0) sha256={}\n rack (3.2.6) sha256={}\n\n\ - BUNDLED WITH\n 4.0.15\n", - "a".repeat(64), - "b".repeat(64), - ), - ) - .await; - - let entries = inventory_gemfile_lock(tmp.path()).await.unwrap(); - assert_eq!( - entry(&entries, "private-gem").resolved.as_deref(), - Some("https://gems.corp.example/downloads/private-gem-1.0.0.gem"), - "first section's spec resolves against its own remote" - ); - assert_eq!( - entry(&entries, "rack").resolved.as_deref(), - Some("https://rubygems.org/downloads/rack-3.2.6.gem"), - "second section's spec must NOT inherit the first section's remote" - ); - // Both keep their CHECKSUMS integrity. - assert_eq!( - entry(&entries, "rack").integrity, - LockIntegrity::Sha256Hex("b".repeat(64)) - ); - } - - /// A GEM section with SEVERAL `remote:` lines is a legacy bundler 1.x - /// multisource lock (bundler ≥ 2 hard-errors on multiple global - /// sources — verified against 4.0.15): per-spec origin is ambiguous, - /// so its specs stay discovery-only — no guessed download URL, which - /// would leak private gem names to the public registry. - #[tokio::test] - async fn gemfile_lock_legacy_multi_remote_section_is_discovery_only() { - let tmp = tempfile::tempdir().unwrap(); - write( - tmp.path(), - "Gemfile.lock", - &format!( - "GEM\n remote: https://rubygems.org/\n remote: https://gems.corp.example/\n \ - specs:\n rack (3.0.8)\n\nPLATFORMS\n ruby\n\nDEPENDENCIES\n rack\n\n\ - CHECKSUMS\n rack (3.0.8) sha256={}\n", - "c".repeat(64), - ), - ) - .await; - - let entries = inventory_gemfile_lock(tmp.path()).await.unwrap(); - let rack = entry(&entries, "rack"); - assert_eq!( - rack.resolved, None, - "ambiguous origin must never guess a remote: {rack:?}" - ); - // Discovery + integrity survive; only the URL is withheld. - assert_eq!(rack.purl, "pkg:gem/rack@3.0.8"); - assert_eq!(rack.integrity, LockIntegrity::Sha256Hex("c".repeat(64))); - } - - #[tokio::test] - async fn inventories_script_and_pylock_files_without_installed_packages() { - let tmp = tempfile::tempdir().unwrap(); - let sha = "a".repeat(64); - write(tmp.path(), "example.py.lock", &format!("version=1\n[[package]]\nname='alpha'\nversion='1'\nsource={{registry='https://pypi.org/simple'}}\nwheels=[{{url='https://pypi.org/alpha-1-py3-none-any.whl',hash='sha256:{sha}'}}]\n")).await; - write(tmp.path(), "pylock.dev.toml", &format!("lock-version='1.0'\n[[packages]]\nname='bravo'\nversion='2'\narchive={{url='https://pypi.org/bravo-2-py3-none-any.whl',hashes={{sha256='{sha}'}}}}\n[[packages]]\nname='local'\nversion='1'\narchive={{path='.socket/vendor/pypi/uuid/local-1-py3-none-any.whl',hashes={{sha256='{sha}'}}}}\n")).await; - let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); - assert_eq!(entries.len(), 2); - assert_eq!( - entry(&entries, "alpha").integrity, - LockIntegrity::Sha256Hex(sha.clone()) - ); - assert_eq!( - entry(&entries, "bravo").integrity, - LockIntegrity::Sha256Hex(sha) - ); - assert!(!entries.iter().any(|entry| entry.name == "local")); - } - - #[tokio::test] - async fn pylock_repair_uses_the_exact_artifact_hash_and_refuses_conflicts() { - let tmp = tempfile::tempdir().unwrap(); - let path = ".socket/vendor/pypi/uuid/alpha-1-py3-none-any.whl"; - let sha = "a".repeat(64); - let pylock = format!("lock-version='1.0'\n[[packages]]\nname='alpha'\nversion='1'\narchive={{path='{path}',hashes={{sha256='{sha}'}}}}\n"); - write(tmp.path(), "pylock.toml", &pylock).await; - assert_eq!( - wired_vendor_integrity(tmp.path(), path).await, - Some(LockIntegrity::Sha256Hex(sha.clone())) - ); - assert_eq!( - wired_vendor_integrity(tmp.path(), &format!("{path}.other")).await, - None - ); - write(tmp.path(), "example.py.lock", &format!("version=1\n[[package]]\nname='alpha'\nversion='1'\nsource={{path='{path}'}}\nwheels=[{{filename='alpha-1-py3-none-any.whl',hash='sha256:{sha}'}}]\n")).await; - assert_eq!( - wired_vendor_integrity(tmp.path(), path).await, - Some(LockIntegrity::Sha256Hex(sha.clone())) - ); - write( - tmp.path(), - "pylock.toml", - &pylock.replace(&sha, &"b".repeat(64)), - ) - .await; - assert_eq!(wired_vendor_integrity(tmp.path(), path).await, None); - } - - #[test] - fn legacy_and_pep751_archive_hashes_stay_with_their_own_wheels() { - let sha = "b".repeat(64); - let legacy = format!("version=1\n[[distribution]]\nname='alpha'\nversion='1'\nsource='registry+https://pypi.org/simple'\n[[distribution.wheel]]\nurl='https://pypi.org/alpha-1-py3-none-any.whl'\nhash='sha256:{sha}'\n"); - assert_eq!( - python_lock_inventory(&legacy).unwrap()[0].integrity, - LockIntegrity::Sha256Hex(sha.clone()) - ); - let lock = format!("lock-version='1.0'\n[[packages]]\nname='alpha'\nversion='1'\nwheels=[{{url='https://pypi.org/alpha-1-py3-none-any.whl'}},{{url='https://pypi.org/alpha-1-cp312-cp312-macosx.whl',hashes={{sha256='{sha}'}}}}]\n"); - let entries = python_lock_inventory(&lock).unwrap(); - assert_eq!(entries[0].integrity, LockIntegrity::None); - assert_eq!(entries[0].resolved, None); - assert!(python_lock_inventory("version=2\n[[package]]\nname='x'\nversion='1'").is_none()); - } - - #[tokio::test] - async fn uv_lock_inventories_pure_wheels() { - let tmp = tempfile::tempdir().unwrap(); - write( - tmp.path(), - "uv.lock", - r#"version = 1 - -[[package]] -name = "Requests" -version = "2.28.0" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/requests-2.28.0-py3-none-any.whl", hash = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, -] - -[[package]] -name = "native-only" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bb/native_only-1.0.0-cp312-macosx.whl", hash = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" }, -] - -[[package]] -name = "local-proj" -version = "0.0.1" -source = { editable = "." } -"#, - ) - .await; - - let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); - let requests = entry(&entries, "requests"); - assert_eq!(requests.purl, "pkg:pypi/requests@2.28.0", "PEP 503 name"); - assert!(matches!(requests.integrity, LockIntegrity::Sha256Hex(_))); - assert!(requests - .resolved - .as_deref() - .unwrap() - .ends_with("py3-none-any.whl")); - // Platform-only wheels → discovery-only; editable sources excluded. - assert_eq!( - entry(&entries, "native-only").integrity, - LockIntegrity::None - ); - assert!(!entries.iter().any(|e| e.name == "local-proj")); - } - - #[tokio::test] - async fn uv_lock_one_line_wheels_array_pairs_the_pure_wheel_with_its_own_hash() { - // A one-line `wheels = […]` array (valid TOML — hand-maintained or - // formatter-collapsed locks) listing a platform wheel BEFORE the - // pure one: the entry must carry the pure wheel's url+hash, never - // the first url/hash on the line. - let tmp = tempfile::tempdir().unwrap(); - let platform_sha = "a".repeat(64); - let pure_sha = "b".repeat(64); - write( - tmp.path(), - "uv.lock", - &format!( - "version = 1\n\n[[package]]\nname = \"six\"\nversion = \"1.16.0\"\n\ - source = {{ registry = \"https://pypi.org/simple\" }}\n\ - wheels = [{{ url = \"https://files.pythonhosted.org/packages/aa/six-1.16.0-cp312-cp312-macosx_11_0_arm64.whl\", hash = \"sha256:{platform_sha}\" }}, {{ url = \"https://files.pythonhosted.org/packages/bb/six-1.16.0-py2.py3-none-any.whl\", hash = \"sha256:{pure_sha}\" }}]\n" - ), - ) - .await; - - let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); - let six = entry(&entries, "six"); - assert!( - six.resolved.as_deref().unwrap().ends_with("-none-any.whl"), - "the platform wheel must never be resolved as pure: {six:?}" - ); - assert_eq!(six.integrity, LockIntegrity::Sha256Hex(pure_sha)); - } - - #[tokio::test] - async fn poetry_and_requirements_are_discovery_only() { - let tmp = tempfile::tempdir().unwrap(); - write( - tmp.path(), - "poetry.lock", - "[[package]]\nname = \"Flask_Login\"\nversion = \"0.6.3\"\n\n[metadata]\nlock-version = \"2.0\"\n", - ) - .await; - let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); - let fl = entry(&entries, "flask-login"); - assert_eq!(fl.purl, "pkg:pypi/flask-login@0.6.3"); - assert_eq!(fl.integrity, LockIntegrity::None); - - let tmp = tempfile::tempdir().unwrap(); - write( - tmp.path(), - "requirements.txt", - "# pinned\nrequests[security]==2.28.0 --hash=sha256:abc \\\n --hash=sha256:def\nflask>=2.0\n-e .\n", - ) - .await; - let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); - assert_eq!(entries.len(), 1, "{entries:?}"); - assert_eq!(entries[0].purl, "pkg:pypi/requests@2.28.0"); - } - - /// Pipfile.lock: every category is read, registry pins carry the lock's - /// digest SET (lowercased), non-registry sources / range pins / a user's - /// file references are skipped while our own vendored reference stays - /// discoverable, the same package in two categories yields one entry, - /// requirements.txt is read alongside it, and a parseable uv.lock - /// outranks both. - #[tokio::test] - async fn pipfile_lock_inventory_reads_every_category_with_its_digest_set() { - let wheel = "a".repeat(64); - let sdist = "B".repeat(64); - let lock = format!( - r#"{{ - "_meta": {{"hash": {{"sha256": "x"}}, "pipfile-spec": 6, "requires": {{}}, "sources": []}}, - "default": {{ - "URLlib3": {{"hashes": ["sha256:{wheel}", "sha256:{sdist}"], "index": "pypi", "version": "==1.26.18", "markers": "python_version < '4'"}}, - "requests": {{"git": "https://example.org/requests", "ref": "abc", "version": "==2.31.0"}}, - "loose": {{"version": "*"}}, - "wired": {{"file": "./.socket/vendor/pypi/00000000-0000-4000-8000-000000000000/wired-1.0-py3-none-any.whl", "hashes": ["sha256:{wheel}"]}} - }}, - "develop": {{ - "Six": {{"hashes": ["sha256:{sdist}"], "version": "==1.16.0"}} - }}, - "tests": {{ - "urllib3": {{"hashes": ["sha256:{wheel}"], "version": "==1.26.18"}} - }} -}} -"# - ); - let tmp = tempfile::tempdir().unwrap(); - write(tmp.path(), "Pipfile.lock", &lock).await; - write(tmp.path(), "requirements.txt", "flask==3.0.0\n").await; - let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); - let mut names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect(); - names.sort_unstable(); - // requirements.txt is read alongside the Pipfile.lock, not hidden by - // it; our own vendored reference stays discoverable (discovery-only). - assert_eq!( - names, - vec!["flask", "six", "urllib3", "wired"], - "{entries:?}" - ); - assert_eq!(entry(&entries, "wired").integrity, LockIntegrity::None); - assert_eq!(entry(&entries, "wired").purl, "pkg:pypi/wired@1.0"); - let urllib3 = entry(&entries, "urllib3"); - assert_eq!(urllib3.purl, "pkg:pypi/urllib3@1.26.18"); - assert_eq!(urllib3.resolved, None); - assert_eq!( - urllib3.integrity, - LockIntegrity::Sha256AnyOf(vec![wheel.clone(), sdist.to_ascii_lowercase()]), - "every recorded digest, lowercased, first category wins" - ); - assert_eq!( - entry(&entries, "six").integrity, - LockIntegrity::Sha256AnyOf(vec![sdist.to_ascii_lowercase()]) - ); - - // A parseable uv.lock stays the exclusive inventory. - write( - tmp.path(), - "uv.lock", - "version = 1\n\n[[package]]\nname = \"other\"\nversion = \"1.0.0\"\nsource = { registry = \"https://pypi.org/simple\" }\n", - ) - .await; - let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); - assert!(entries.iter().all(|e| e.name == "other"), "{entries:?}"); - - // Unparseable lock → nothing from it, requirements.txt read instead. - let tmp = tempfile::tempdir().unwrap(); - write(tmp.path(), "Pipfile.lock", "{ not json").await; - write(tmp.path(), "requirements.txt", "flask==3.0.0\n").await; - let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].name, "flask"); - - // No hashes at all → discovery-only entry. - let tmp = tempfile::tempdir().unwrap(); - write( - tmp.path(), - "Pipfile.lock", - r#"{"_meta": {"pipfile-spec": 6}, "default": {"urllib3": {"version": "==1.26.18"}}}"#, - ) - .await; - let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); - assert_eq!(entry(&entries, "urllib3").integrity, LockIntegrity::None); - } - - /// Socket's own references in a Pipfile.lock (a hosted URL, a vendored - /// path) keep the package discoverable on a lock-only re-scan; a lock - /// whose sources are private indexes only never carries a fetchable - /// digest set (no pypi.org lookups for it). - #[tokio::test] - async fn pipfile_lock_inventory_keeps_socket_references_discoverable_and_respects_private_indexes( - ) { - let hosted = r#"{"_meta": {"pipfile-spec": 6, "sources": [{"name": "pypi", "url": "https://pypi.org/simple", "verify_ssl": true}]}, -"default": { - "urllib3": {"file": "https://patch.socket.dev/patch/pypi/urllib3/1.26.18/grant/e828efa5-5c6d-43f3-9909-03f5ac232b98/urllib3-1.26.18-py2.py3-none-any.whl#sha256=cc", "hashes": ["sha256:cc"], "markers": "x"}, - "Six": {"file": "./.socket/vendor/pypi/00000000-0000-4000-8000-000000000000/six-1.16.0-py2.py3-none-any.whl", "hashes": ["sha256:dd"]}, - "fork": {"file": "./forks/fork-1.0-py3-none-any.whl"}, - "requests": {"version": "==2.31.0", "hashes": ["sha256:%s"]} -}}"#.replace("%s", &"a".repeat(64)); - let tmp = tempfile::tempdir().unwrap(); - write(tmp.path(), "Pipfile.lock", &hosted).await; - let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); - let mut names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect(); - names.sort_unstable(); - assert_eq!(names, vec!["requests", "six", "urllib3"], "{entries:?}"); - assert_eq!(entry(&entries, "urllib3").purl, "pkg:pypi/urllib3@1.26.18"); - assert_eq!( - entry(&entries, "urllib3").integrity, - LockIntegrity::None, - "a hosted reference is discovery-only" - ); - assert_eq!(entry(&entries, "six").purl, "pkg:pypi/six@1.16.0"); - assert_eq!(entry(&entries, "six").integrity, LockIntegrity::None); - assert!(matches!( - entry(&entries, "requests").integrity, - LockIntegrity::Sha256AnyOf(_) - )); - - // Private index only → the registry pin is discovery-only. - let private = hosted.replace( - "https://pypi.org/simple", - "https://pypi.internal.example/simple", - ); - let tmp = tempfile::tempdir().unwrap(); - write(tmp.path(), "Pipfile.lock", &private).await; - let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); - assert_eq!( - entry(&entries, "requests").integrity, - LockIntegrity::None, - "no pypi.org lookup for a private-index lock" - ); - // A mirror listed next to PyPI keeps the digest set. - let mixed = hosted.replace(r#"[{"name": "pypi", "url": "https://pypi.org/simple", "verify_ssl": true}]"#, r#"[{"name": "mirror", "url": "https://mirror.example/simple", "verify_ssl": true}, {"name": "pypi", "url": "https://pypi.org/simple", "verify_ssl": true}]"#); - let tmp = tempfile::tempdir().unwrap(); - write(tmp.path(), "Pipfile.lock", &mixed).await; - let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); - assert!(matches!( - entry(&entries, "requests").integrity, - LockIntegrity::Sha256AnyOf(_) - )); - assert!(is_public_pypi_url("https://user:tok@pypi.org/simple")); - assert!(!is_public_pypi_url("https://pypi.org.evil.example/simple")); - assert_eq!( - socket_reference_coords("./forks/fork-1.0-py3-none-any.whl"), - None - ); - assert_eq!( - socket_reference_coords("https://example.org/patch/pypi/a/1/g/u/a-1-py3-none-any.whl"), - Some(("a".into(), "1".into())) - ); - } - - /// A lock that lists a pure-Python wheel carries its sha256 (lock 2.x - /// `files`, lock 1.x `[metadata.files]`), so a lock-only checkout can - /// vendor like uv does; platform wheels only, or 0.12's bare - /// `[metadata.hashes]`, stay discovery-only. - #[tokio::test] - async fn poetry_lock_carries_the_pure_wheel_sha256_when_listed() { - let sha = "34b97092d7e0a3a8cf7cd10e386f401b3737364026c45e622aa02903dffe0f07"; - let lock2 = format!( - "[[package]]\nname = \"urllib3\"\nversion = \"1.26.18\"\nfiles = [\n {{file = \"urllib3-1.26.18.tar.gz\", hash = \"sha256:{}\"}},\n {{file = \"urllib3-1.26.18-py2.py3-none-any.whl\", hash = \"sha256:{sha}\"}},\n]\n\n[[package]]\nname = \"numpy\"\nversion = \"2.0.0\"\nfiles = [\n {{file = \"numpy-2.0.0-cp312-cp312-macosx_11_0_arm64.whl\", hash = \"sha256:{}\"}},\n]\n\n[metadata]\nlock-version = \"2.1\"\n", - "f".repeat(64), - "e".repeat(64) - ); - let tmp = tempfile::tempdir().unwrap(); - write(tmp.path(), "poetry.lock", &lock2).await; - let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); - assert_eq!( - entry(&entries, "urllib3").integrity, - LockIntegrity::Sha256Hex(sha.into()) - ); - assert_eq!(entry(&entries, "urllib3").resolved, None); - assert_eq!(entry(&entries, "numpy").integrity, LockIntegrity::None); - - let lock1 = format!( - "[[package]]\nname = \"urllib3\"\nversion = \"1.26.18\"\n\n[metadata]\nlock-version = \"1.1\"\n\n[metadata.files]\nurllib3 = [\n {{file = \"urllib3-1.26.18-py2.py3-none-any.whl\", hash = \"sha256:{}\"}},\n]\n", - sha.to_uppercase() - ); - let tmp = tempfile::tempdir().unwrap(); - write(tmp.path(), "poetry.lock", &lock1).await; - let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); - assert_eq!( - entry(&entries, "urllib3").integrity, - LockIntegrity::Sha256Hex(sha.into()), - "lowercased" - ); - - let lock0 = format!( - "[[package]]\nname = \"urllib3\"\nversion = \"1.26.18\"\n\n[metadata]\ncontent-hash = \"x\"\n\n[metadata.hashes]\nurllib3 = [\"{sha}\"]\n" - ); - let tmp = tempfile::tempdir().unwrap(); - write(tmp.path(), "poetry.lock", &lock0).await; - let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); - assert_eq!( - entry(&entries, "urllib3").integrity, - LockIntegrity::None, - "bare digests name no wheel" - ); - } - - #[tokio::test] - async fn pnp_layouts_propagate_the_diagnosis_instead_of_yielding_none() { - // PnP marker wins over any lockfile — and the diagnosis must - // PROPAGATE, not collapse into the calm no-lockfile `None`. Under - // yarn PnP the installed-tree crawl is also structurally empty, so - // swallowing this here made `scan` a silent success-0 no-op in - // every mode (the P0 this pins). - let tmp = tempfile::tempdir().unwrap(); - write(tmp.path(), ".pnp.cjs", "/* pnp */").await; - write(tmp.path(), "package-lock.json", PACKAGE_LOCK).await; - let diag = inventory_npm_lock(tmp.path()).await.unwrap_err(); - assert_eq!(diag.code, "vendor_yarn_berry_unsupported"); - assert!(diag.detail.contains("Plug'n'Play"), "{}", diag.detail); - assert!(diag.detail.contains("yarn patch"), "{}", diag.detail); - - // pnpm's own `node-linker=pnp` twin (same loader, pnpm store): - // same channel, pnpm diagnosis. - let tmp = tempfile::tempdir().unwrap(); - write(tmp.path(), ".pnp.cjs", "/* pnp */").await; - write(tmp.path(), "pnpm-lock.yaml", "lockfileVersion: '9.0'\n").await; - tokio::fs::create_dir_all(tmp.path().join("node_modules/.pnpm")) - .await - .unwrap(); - write(&tmp.path().join("node_modules"), ".modules.yaml", "").await; - let diag = inventory_npm_lock(tmp.path()).await.unwrap_err(); - assert_eq!(diag.code, "vendor_pnpm_pnp_unsupported"); - assert!(diag.detail.contains("node-linker=pnp"), "{}", diag.detail); - - // And the project-level union surfaces the same diagnosis while - // still serving the OTHER ecosystems' lockfiles. - let (entries, unsupported) = inventory_project_diagnosed(tmp.path()).await; - assert!(entries.is_empty(), "{entries:?}"); - assert_eq!(unsupported.len(), 1, "{unsupported:?}"); - assert_eq!(unsupported[0].code, "vendor_pnpm_pnp_unsupported"); - } - - #[cfg(unix)] - fn mkfifo(path: &Path) { - use std::os::unix::ffi::OsStrExt; - let c_path = - std::ffi::CString::new(path.as_os_str().as_bytes()).expect("fifo path has no NUL"); - let rc = unsafe { libc::mkfifo(c_path.as_ptr(), 0o644) }; - assert_eq!( - rc, - 0, - "mkfifo(2) failed: {}", - std::io::Error::last_os_error() - ); - } - - /// A FIFO planted as any inventoried lockfile must fail fast instead of - /// wedging every consumer — scan's lockfile supplement, vendor's - /// auto-fetch, and repair's no-ledger reconstruction all read these - /// files — forever in an `open(2)` that waits for a writer that never - /// comes. Same `open_regular_file` guard class as the vendor siblings - /// (cargo_lock.rs, composer_lock.rs, gem.rs, common.rs). Inventories - /// stay fail-soft: a non-regular lockfile reads as absent. - #[cfg(unix)] - #[tokio::test] - async fn fifo_lockfiles_fail_fast_instead_of_wedging() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().to_path_buf(); - // Every filename this module opens: the per-ecosystem inventories, - // the npm-family readers (reached without the flavor probe touching - // the same file via the shrinkwrap/sibling/rush fallbacks), and - // wired_vendor_integrity (no probe at all). - let names = [ - "Cargo.lock", - "go.sum", - "composer.lock", - "Gemfile.lock", - "uv.lock", - "poetry.lock", - "requirements.txt", - "npm-shrinkwrap.json", - "package-lock.json", - "pnpm-lock.yaml", - "yarn.lock", - "bun.lock", - "shrinkwrap.yaml", - ]; - for name in names { - mkfifo(&root.join(name)); - } - - // On timeout the open is wedged in a `spawn_blocking` thread that - // the runtime waits for on shutdown; connect a non-blocking writer - // to release it so the test can FAIL instead of hanging the suite. - let deadline = std::time::Duration::from_secs(5); - let all = async { - ( - inventory_cargo_lock(&root).await, - inventory_go_sum(&root).await, - inventory_composer_lock(&root).await, - inventory_gemfile_lock(&root).await, - inventory_pypi_locks(&root).await, - inventory_package_lock(&root).await, - inventory_pnpm_lock(&root).await, - inventory_yarn_classic(&root).await, - inventory_yarn_berry(&root).await, - inventory_bun(&root).await, - inventory_pnpm_lock_at(&root.join("shrinkwrap.yaml")).await, - gem_remotes(&root).await, - wired_vendor_integrity(&root, ".socket/vendor/npm/x/x.tgz").await, - ) - }; - let Ok(results) = tokio::time::timeout(deadline, all).await else { - for name in names { - use std::os::unix::fs::OpenOptionsExt; - let _ = std::fs::OpenOptions::new() - .write(true) - .custom_flags(libc::O_NONBLOCK) - .open(root.join(name)); - } - panic!("lockfile inventories must fail fast on FIFO lockfiles"); - }; - let ( - cargo, - go, - composer, - gem, - pypi, - npm, - pnpm, - yarn_c, - yarn_b, - bun, - legacy, - remotes, - wired, - ) = results; - for (label, opt) in [ - ("cargo", cargo), - ("go", go), - ("composer", composer), - ("gem", gem), - ("pypi", pypi), - ("npm", npm), - ("pnpm", pnpm), - ("yarn classic", yarn_c), - ("yarn berry", yarn_b), - ("bun", bun), - ("pnpm legacy", legacy), - ] { - assert!( - opt.is_none(), - "{label}: a FIFO lockfile must read as absent" - ); - } - assert!(remotes.is_empty(), "{remotes:?}"); - assert!(wired.is_none(), "{wired:?}"); - } - - #[tokio::test] - async fn unsupported_flavors_yield_none() { - // pnpm v6.0: the probe passes it (the 6.0 grammar has a wiring - // backend), and a dep-less lock inventories to nothing — the calm - // None, not a refusal. - let tmp = tempfile::tempdir().unwrap(); - write(tmp.path(), "pnpm-lock.yaml", "lockfileVersion: '6.0'\n").await; - assert!(inventory_npm_lock(tmp.path()).await.unwrap().is_none()); - - // No lockfile at all. - let tmp = tempfile::tempdir().unwrap(); - assert!(inventory_npm_lock(tmp.path()).await.unwrap().is_none()); - let (entries, unsupported) = inventory_project_diagnosed(tmp.path()).await; - assert!(entries.is_empty()); - assert!(unsupported.is_empty(), "{unsupported:?}"); - } - - /// A version-refused pnpm lock (pnpm 6 wrote 5.3) with NO live sibling - /// and NO dependencies at all: the direct read yields nothing, and the - /// fall-through past it must land on the calm `Ok(None)` via the rush - /// check — never a phantom inventory and never an error. - #[tokio::test] - async fn version_refused_depless_pnpm_lock_yields_calm_none() { - let tmp = tempfile::tempdir().unwrap(); - write(tmp.path(), "pnpm-lock.yaml", "lockfileVersion: 5.3\n").await; - assert!( - inventory_npm_lock(tmp.path()).await.unwrap().is_none(), - "a dep-less version-refused pnpm lock must inventory to the calm None" - ); - } - - /// The union entrypoint reads EVERY ecosystem's lock out of one polyglot - /// root — each per-ecosystem reader is unit-covered, but the union arms - /// (go.sum, pypi, …) only execute here. `lookup` bridges one purl per - /// ecosystem, and an unknown purl type (nuget has no lock inventory) - /// yields None instead of a cross-ecosystem false match. - #[tokio::test] - async fn inventory_project_unions_every_ecosystem_lock() { - let tmp = tempfile::tempdir().unwrap(); - write(tmp.path(), "package-lock.json", PACKAGE_LOCK).await; - write( - tmp.path(), - "Cargo.lock", - "[[package]]\nname = \"serde\"\nversion = \"1.0.200\"\n\ - source = \"registry+https://github.com/rust-lang/crates.io-index\"\n\ - checksum = \"ddc6f9cc94d67c0e21aaf7eda3a010fd3af78ebf6e096aa6e2e13c79749cce4f\"\n", - ) - .await; - write( - tmp.path(), - "go.sum", - "github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=\n", - ) - .await; - write( - tmp.path(), - "composer.lock", - r#"{ "packages": [ { "name": "Monolog/Monolog", "version": "v3.5.0", - "dist": { "type": "zip", "url": "https://example.com/monolog.zip", - "shasum": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" } } ] }"#, - ) - .await; - write( - tmp.path(), - "Gemfile.lock", - "GEM\n remote: https://rubygems.org/\n specs:\n rails (7.1.0)\n", - ) - .await; - write(tmp.path(), "requirements.txt", "requests==2.31.0\n").await; - - let (entries, unsupported) = inventory_project_diagnosed(tmp.path()).await; - assert!(unsupported.is_empty(), "{unsupported:?}"); - for purl in [ - "pkg:npm/left-pad@1.3.0", - "pkg:cargo/serde@1.0.200", - "pkg:golang/github.com/gin-gonic/gin@v1.9.1", - "pkg:composer/monolog/monolog@3.5.0", - "pkg:gem/rails@7.1.0", - "pkg:pypi/requests@2.31.0", - ] { - assert!( - lookup(&entries, purl).is_some(), - "the union must serve {purl}: {entries:?}" - ); - } - // Unknown purl type: no inventory ever answers for nuget. - assert!( - lookup(&entries, "pkg:nuget/Newtonsoft.Json@13.0.1").is_none(), - "an unrecognized purl type must never match: {entries:?}" - ); - } - - /// The `[metadata]` tail of a v1-era Cargo.lock flushes the in-flight - /// block (its key=value lines must not bleed a foreign checksum into the - /// LAST package), and an unsafe name is dropped fail-closed — the - /// lockfile is committed, tamperable input feeding paths/URLs. - #[tokio::test] - async fn cargo_lock_metadata_section_flushes_and_unsafe_name_drops() { - let tmp = tempfile::tempdir().unwrap(); - write( - tmp.path(), - "Cargo.lock", - &format!( - "version = 3\n\n\ - [[package]]\nname = \"../evil\"\nversion = \"1.0.0\"\n\ - source = \"registry+https://github.com/rust-lang/crates.io-index\"\n\ - checksum = \"{}\"\n\n\ - [[package]]\nname = \"serde\"\nversion = \"1.0.200\"\n\ - source = \"registry+https://github.com/rust-lang/crates.io-index\"\n\ - checksum = \"{}\"\n\n\ - [metadata]\n\ - \"checksum foo 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)\" = \"{}\"\n", - "a".repeat(64), - "d".repeat(64), - "b".repeat(64), - ), - ) - .await; - - let entries = inventory_cargo_lock(tmp.path()).await.unwrap(); - assert_eq!( - entries.len(), - 1, - "only the safe crates.io package inventories: {entries:?}" - ); - let serde_entry = entry(&entries, "serde"); - assert_eq!( - serde_entry.integrity, - LockIntegrity::Sha256Hex("d".repeat(64)), - "the [metadata] line's checksum must not bleed into the last block" - ); - assert!( - !entries.iter().any(|e| e.name.contains("..")), - "{entries:?}" - ); - assert!(!entries.iter().any(|e| e.name == "foo"), "{entries:?}"); - } - - /// go.sum lines with fewer than 3 fields are skipped, and unsafe module - /// paths / versions are dropped fail-closed (SECURITY: both feed - /// filesystem paths and download URLs). - #[tokio::test] - async fn go_sum_skips_short_and_unsafe_lines() { - let tmp = tempfile::tempdir().unwrap(); - write( - tmp.path(), - "go.sum", - "lonely\n\ - example.com/../up v1.0.0 h1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n\ - example.com/mod v1.0.0/../x h1:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\n\ - golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=\n", - ) - .await; - - let entries = inventory_go_sum(tmp.path()).await.unwrap(); - assert_eq!( - entries.len(), - 1, - "short and unsafe lines must be skipped: {entries:?}" - ); - assert_eq!(entries[0].name, "golang.org/x/text"); - assert!( - !entries - .iter() - .any(|e| e.name.contains("..") || e.version.contains("..")), - "{entries:?}" - ); - } - - /// shrinkwrap.yaml BLOCK-mapped `resolution:` with a `tarball:` child - /// AND a following shallower-indented field: the mapping must terminate - /// at the shallower line (the SHRINKWRAP_YAML fixture happens to put - /// resolution last in every entry, so the terminator never ran) and the - /// tarball child must be captured as the resolved URL. - #[tokio::test] - async fn shrinkwrap_block_mapped_tarball_reads_and_stops_at_shallower_indent() { - let tmp = tempfile::tempdir().unwrap(); - write( - tmp.path(), - "shrinkwrap.yaml", - "dependencies: - left-pad: 1.3.0 -packages: - /left-pad/1.3.0: - resolution: - integrity: sha512-blockmapped== - tarball: https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz - dev: false -registry: 'https://registry.npmjs.org/' -shrinkwrapVersion: 3 -", - ) - .await; - - let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); - assert_eq!(flavor, NpmLockFlavor::PnpmLegacy); - let lp = entry(&entries, "left-pad"); - assert_eq!( - lp.resolved.as_deref(), - Some("https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz"), - "the block-mapped tarball child must be captured" - ); - assert_eq!( - lp.integrity, - LockIntegrity::Sri("sha512-blockmapped==".into()), - "the shallower `dev:` line must terminate the mapping without eating fields" - ); - } - - /// A registry-shaped pnpm key (digit version) whose resolution tarball - /// points into `.socket/vendor/` is OUR OWN vendored artifact, not a - /// registry dependency — self-exclusion fail-closed. (Rewired v9 locks - /// are keyed `name@file:…` and die at the digit check instead, but a - /// crafted or v5.4-era lock can present exactly this shape.) - #[tokio::test] - async fn pnpm_registry_keyed_entry_with_vendored_tarball_is_skipped() { - let tmp = tempfile::tempdir().unwrap(); - write( - tmp.path(), - "pnpm-lock.yaml", - "lockfileVersion: '6.0' - -packages: - - /left-pad@1.3.0: - resolution: {integrity: sha512-x==, tarball: file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz} - - /other@2.0.0: - resolution: {integrity: sha512-y==} -", - ) - .await; - - let (_, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); - assert!( - !entries.iter().any(|e| e.name == "left-pad"), - "a vendored tarball must self-exclude even behind a registry key: {entries:?}" - ); - assert_eq!( - entry(&entries, "other").integrity, - LockIntegrity::Sri("sha512-y==".into()) - ); - } - - /// Real classic-lock degenerations: a `resolved` URL without the legacy - /// `#sha1` fragment (registries that strip fragments) and a block with - /// no `resolved` at all (offline-pruned locks). Both stay listed for - /// discovery with no verifier — never dropped, never guessed. - #[tokio::test] - async fn yarn_classic_fragmentless_and_resolvedless_blocks_stay_discovery_only() { - let tmp = tempfile::tempdir().unwrap(); - write( - tmp.path(), - "yarn.lock", - "# yarn lockfile v1\n\n\ - no-fragment@^1.0.0:\n version \"1.0.0\"\n \ - resolved \"https://registry.npmjs.org/no-fragment/-/no-fragment-1.0.0.tgz\"\n\n\ - no-resolved@^2.0.0:\n version \"2.0.0\"\n", - ) - .await; - - let entries = inventory_yarn_classic(tmp.path()).await.unwrap(); - let nf = entry(&entries, "no-fragment"); - assert_eq!( - nf.resolved.as_deref(), - Some("https://registry.npmjs.org/no-fragment/-/no-fragment-1.0.0.tgz"), - "a fragmentless URL is still a usable artifact URL" - ); - assert_eq!(nf.integrity, LockIntegrity::None); - let nr = entry(&entries, "no-resolved"); - assert_eq!(nr.resolved, None); - assert_eq!(nr.integrity, LockIntegrity::None); - } - - /// bun.lock is attacker-shaped committed input; each malformed 4-tuple - /// (undecodable spec/registry/integrity elements, unsplittable spec, - /// non-registry version) is skipped fail-soft — the well-formed entry - /// still inventories and no malformed one leaks through. - #[tokio::test] - async fn bun_malformed_tuples_are_skipped() { - let tmp = tempfile::tempdir().unwrap(); - write( - tmp.path(), - "bun.lock", - r#"{ - "lockfileVersion": 1, - "workspaces": { - "": { "name": "fixture", "dependencies": { "left-pad": "1.3.0" } }, - }, - "packages": { - "left-pad": ["left-pad@1.3.0", "", {}, "sha512-XI5MPz=="], - "bad-elem0": [123, "", {}, "sha512-a=="], - "noat": ["noatsign", "", {}, "sha512-b=="], - "wsdep": ["wsdep@workspace:*", "", {}, "sha512-c=="], - "badreg": ["badreg@1.0.0", 42, {}, "sha512-d=="], - "badint": ["badint@1.0.0", "", {}, 99], - } -} -"#, - ) - .await; - - let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); - assert_eq!(flavor, NpmLockFlavor::Bun); - assert_eq!( - sorted_pairs(&entries), - vec![("left-pad".into(), "1.3.0".into())], - "every malformed tuple must be skipped, the good one kept" - ); - } - - /// composer.lock packages missing a name or version are skipped, and - /// names that are unsafe or not `vendor/pkg`-shaped are dropped - /// fail-closed (SECURITY: they feed paths and download URLs). - #[tokio::test] - async fn composer_lock_drops_nameless_versionless_and_unsafe_packages() { - let tmp = tempfile::tempdir().unwrap(); - write( - tmp.path(), - "composer.lock", - r#"{ - "packages": [ - { "version": "1.0.0" }, - { "name": "nameless/partner" }, - { "name": "singleseg", "version": "1.0.0" }, - { "name": "a/../b", "version": "1.0.0" }, - { "name": "good/pkg", "version": "1.0.0" } - ] -}"#, - ) - .await; - - let entries = inventory_composer_lock(tmp.path()).await.unwrap(); - assert_eq!( - entries.len(), - 1, - "only the well-formed safe package inventories: {entries:?}" - ); - assert_eq!(entries[0].purl, "pkg:composer/good/pkg@1.0.0"); - } - - /// The pre-multisource Gemfile.lock shape: ONE remote-less GEM section - /// defaults to rubygems.org. Rides along: an unsafe spec name is dropped - /// fail-closed, and a CHECKSUMS value that is not 64-hex is ignored (the - /// entry stays discovery-fetchable but unverified — LockIntegrity::None). - #[tokio::test] - async fn gemfile_lock_remoteless_single_section_defaults_to_rubygems() { - let tmp = tempfile::tempdir().unwrap(); - write( - tmp.path(), - "Gemfile.lock", - "GEM\n specs:\n rake (13.0.6)\n ../evil (1.0.0)\n\n\ - CHECKSUMS\n rake (13.0.6) sha256=zznothexzznothexzznothexzznothex\n", - ) - .await; - - let entries = inventory_gemfile_lock(tmp.path()).await.unwrap(); - assert_eq!( - entries.len(), - 1, - "the unsafe spec name must be dropped: {entries:?}" - ); - let rake = entry(&entries, "rake"); - assert_eq!( - rake.resolved.as_deref(), - Some("https://rubygems.org/downloads/rake-13.0.6.gem"), - "a lone remote-less GEM section defaults to rubygems.org" - ); - assert_eq!( - rake.integrity, - LockIntegrity::None, - "a non-64-hex CHECKSUMS value must be ignored" - ); - } - - /// A poetry.lock with zero `[[package]]` blocks yields None, which - /// routes `inventory_pypi_locks` onward to requirements.txt — where a - /// non-digit-version pin is guard-dropped; and a requirements.txt with - /// no `==` pin at all yields None. - #[tokio::test] - async fn depless_poetry_lock_falls_through_to_requirements() { - let tmp = tempfile::tempdir().unwrap(); - write( - tmp.path(), - "poetry.lock", - "[metadata]\nlock-version = \"2.0\"\n", - ) - .await; - write( - tmp.path(), - "requirements.txt", - "requests==2.31.0\nbad==vNaN\n", - ) - .await; - let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); - assert_eq!( - sorted_pairs(&entries), - vec![("requests".into(), "2.31.0".into())], - "a package-less poetry.lock must route onward; the vNaN pin is dropped" - ); - - // No exact pin anywhere: the calm None, not an empty inventory. - let tmp = tempfile::tempdir().unwrap(); - write( - tmp.path(), - "requirements.txt", - "# comment\n-r other.txt\nflask>=2.0\n", - ) - .await; - assert!(inventory_pypi_locks(tmp.path()).await.is_none()); - } - - /// `pure_wheel_from_uv_unit` rejection fall-throughs: a pure wheel whose - /// hash is not 64-hex, one with no hash at all, and one whose URL is not - /// http(s) all yield None — fail-closed, never a guessed pairing. - #[tokio::test] - async fn pure_wheel_rejects_short_hash_missing_hash_and_non_http_url() { - let short = - "wheels = [{ url = \"https://h/x-1.0-py3-none-any.whl\", hash = \"sha256:abcd\" }]"; - assert_eq!(pure_wheel_from_uv_unit(short), None, "short hash"); - - let hashless = "wheels = [{ url = \"https://h/x-1.0-py3-none-any.whl\" }]"; - assert_eq!(pure_wheel_from_uv_unit(hashless), None, "no hash"); - - let ftp = format!( - "wheels = [{{ url = \"ftp://h/x-1.0-py3-none-any.whl\", hash = \"sha256:{}\" }}]", - "a".repeat(64) - ); - assert_eq!(pure_wheel_from_uv_unit(&ftp), None, "non-http url"); - } - - /// The yarn-classic `integrity ` branch of `wired_vendor_integrity` - /// — the trust anchor for repair's no-ledger reconstruction on - /// yarn-classic projects (rewired classic locks carry exactly this - /// line). Rides along fail-soft: an unparseable JSON lock and a v1 lock - /// without a `packages` map are both skipped, not fatal. - #[tokio::test] - async fn wired_vendor_integrity_reads_rewired_yarn_classic_and_skips_bad_json_locks() { - let tmp = tempfile::tempdir().unwrap(); - let rel = ".socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz"; - // Unparseable JSON lock: skipped fail-soft. - write(tmp.path(), "npm-shrinkwrap.json", "not json").await; - // v1 lock without a packages map: skipped fail-soft. - write( - tmp.path(), - "package-lock.json", - r#"{"lockfileVersion":1,"dependencies":{}}"#, - ) - .await; - // The rewired classic block, exactly as yarn_classic_lock rewires it. - write( - tmp.path(), - "yarn.lock", - &format!( - "# yarn lockfile v1\n\n\ - \"left-pad@file:./{rel}\":\n \ - version \"1.3.0\"\n \ - resolved \"file:./{rel}#0000000000000000000000000000000000000000\"\n \ - integrity sha512-ours==\n" - ), - ) - .await; - - assert_eq!( - wired_vendor_integrity(tmp.path(), rel).await, - Some(LockIntegrity::Sri("sha512-ours==".into())), - "the classic `integrity ` line is the wired trust anchor" - ); - } -} - -#[cfg(test)] -mod recover_tests { - use super::super::state::WiringAction; - use super::super::state::{CargoLockOriginal, VendorArtifact, VendorEntry, WiringRecord}; - use super::*; - - const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; - - fn entry(eco: &str, base_purl: &str, wiring: Vec) -> VendorEntry { - VendorEntry { - ecosystem: eco.into(), - base_purl: base_purl.into(), - uuid: UUID.into(), - artifact: VendorArtifact { - path: format!(".socket/vendor/{eco}/{UUID}/x"), - sha256: String::new(), - size: None, - platform_locked: None, - file_inventory: None, - }, - wiring, - lock: None, - took_over_go_patches: false, - detached: false, - record: None, - flavor: None, - uv: None, - pnpm: None, - poetry: None, - pdm: None, - pipenv: None, - } - } - - fn rec(kind: &str, original: serde_json::Value) -> WiringRecord { - WiringRecord { - file: "lock".into(), - kind: kind.into(), - action: WiringAction::Rewritten, - key: Some("k".into()), - original: Some(original), - new: None, - } - } - - #[tokio::test] - async fn python_document_recovery_selects_the_requested_package() { - let tmp = tempfile::tempdir().unwrap(); - let sha = "c".repeat(64); - let lock = format!("lock-version='1.0'\n[[packages]]\nname='other'\nversion='1'\narchive={{url='https://pypi.org/other-1-py3-none-any.whl',hashes={{sha256='{}'}}}}\n[[packages]]\nname='target'\nversion='2'\narchive={{url='https://pypi.org/target-2-py3-none-any.whl',hashes={{sha256='{sha}'}}}}\n", "d".repeat(64)); - let record = rec("python_lock_document", serde_json::json!(lock)); - let ledger = entry("pypi", "pkg:pypi/target@2", vec![record.clone()]); - let recovered = recover_lock_entry(tmp.path(), &ledger).await.unwrap(); - assert_eq!( - recovered.resolved.as_deref(), - Some("https://pypi.org/target-2-py3-none-any.whl") - ); - assert_eq!(recovered.integrity, LockIntegrity::Sha256Hex(sha)); - let absent = entry("pypi", "pkg:pypi/target@3", vec![record]); - assert!(recover_lock_entry(tmp.path(), &absent).await.is_err()); - } - - #[tokio::test] - async fn npm_lock_entry_fragment_recovers_sri_and_url() { - let tmp = tempfile::tempdir().unwrap(); - let e = entry( - "npm", - "pkg:npm/@scope/x@1.2.3", - vec![rec( - "npm_lock_entry", - serde_json::json!({ - "resolved": "https://registry.npmjs.org/@scope/x/-/x-1.2.3.tgz", - "integrity": "sha512-AAAA", - }), - )], - ); - let got = recover_lock_entry(tmp.path(), &e).await.unwrap(); - assert_eq!(got.ecosystem, "npm"); - assert_eq!(got.name, "@scope/x"); - assert_eq!(got.version, "1.2.3"); - assert_eq!( - got.resolved.as_deref(), - Some("https://registry.npmjs.org/@scope/x/-/x-1.2.3.tgz") - ); - assert_eq!(got.integrity, LockIntegrity::Sri("sha512-AAAA".into())); - } - - #[tokio::test] - async fn bun_binary_snapshot_recovers_registry_metadata_and_checks_coordinates() { - let tmp = tempfile::tempdir().unwrap(); - let original = serde_json::json!({ - "name": "@scope/x", "version": "1.2.3", - "resolution": "https://registry.example/@scope/x/-/x-1.2.3.tgz", - "integrity": "sha512-AAAA", - }); - let good = entry( - "npm", - "pkg:npm/@scope/x@1.2.3", - vec![rec("bun_lockb_package", original.clone())], - ); - let recovered = recover_lock_entry(tmp.path(), &good).await.unwrap(); - assert_eq!( - recovered.resolved.as_deref(), - Some("https://registry.example/@scope/x/-/x-1.2.3.tgz") - ); - assert_eq!( - recovered.integrity, - LockIntegrity::Sri("sha512-AAAA".into()) - ); - let mismatched = entry( - "npm", - "pkg:npm/@scope/x@2.0.0", - vec![rec("bun_lockb_package", original)], - ); - assert!(recover_lock_entry(tmp.path(), &mismatched).await.is_err()); - } - - #[tokio::test] - async fn pnpm_package_lines_recover_integrity_and_tarball() { - let tmp = tempfile::tempdir().unwrap(); - let e = entry( - "npm", - "pkg:npm/left-pad@1.3.0", - vec![rec( - "pnpm_lock_package", - serde_json::json!([ - " left-pad@1.3.0:", - " resolution: {integrity: sha512-BBBB, tarball: https://npm.corp/left-pad-1.3.0.tgz}", - ]), - )], - ); - let got = recover_lock_entry(tmp.path(), &e).await.unwrap(); - assert_eq!(got.integrity, LockIntegrity::Sri("sha512-BBBB".into())); - assert_eq!( - got.resolved.as_deref(), - Some("https://npm.corp/left-pad-1.3.0.tgz") - ); - } - - #[tokio::test] - async fn yarn_classic_block_prefers_sri_else_sha1() { - let tmp = tempfile::tempdir().unwrap(); - let sha1 = "a".repeat(40); - let with_both = entry( - "npm", - "pkg:npm/x@1.0.0", - vec![rec( - "yarn_lock_block", - serde_json::json!([ - "x@^1.0.0:", - " version \"1.0.0\"", - format!(" resolved \"https://registry.yarnpkg.com/x/-/x-1.0.0.tgz#{sha1}\""), - " integrity sha512-CCCC", - ]), - )], - ); - let got = recover_lock_entry(tmp.path(), &with_both).await.unwrap(); - assert_eq!(got.integrity, LockIntegrity::Sri("sha512-CCCC".into())); - assert_eq!( - got.resolved.as_deref(), - Some("https://registry.yarnpkg.com/x/-/x-1.0.0.tgz") - ); - - let sha1_only = entry( - "npm", - "pkg:npm/x@1.0.0", - vec![rec( - "yarn_lock_block", - serde_json::json!([format!( - " resolved \"https://registry.yarnpkg.com/x/-/x-1.0.0.tgz#{sha1}\"" - )]), - )], - ); - let got = recover_lock_entry(tmp.path(), &sha1_only).await.unwrap(); - assert_eq!(got.integrity, LockIntegrity::Sha1Hex(sha1)); - } - - #[tokio::test] - async fn berry_checksum_and_bun_tuple_recover() { - let tmp = tempfile::tempdir().unwrap(); - let berry = entry( - "npm", - "pkg:npm/x@1.0.0", - vec![rec( - "yarn_berry_lock_entry", - serde_json::json!(["x@npm:1.0.0:", " checksum: 10c0/abcdef"]), - )], - ); - let got = recover_lock_entry(tmp.path(), &berry).await.unwrap(); - assert_eq!( - got.integrity, - LockIntegrity::BerryChecksum("10c0/abcdef".into()) - ); - assert_eq!(got.resolved, None); - - let bun = entry( - "npm", - "pkg:npm/x@1.0.0", - vec![rec( - "bun_lock_package", - serde_json::json!(" \"x\": [\"x@1.0.0\", \"\", {}, \"sha512-DDDD\"],"), - )], - ); - let got = recover_lock_entry(tmp.path(), &bun).await.unwrap(); - assert_eq!(got.integrity, LockIntegrity::Sri("sha512-DDDD".into())); - } - - #[tokio::test] - async fn cargo_recovers_from_entry_lock_checksum() { - let tmp = tempfile::tempdir().unwrap(); - let sha = "b".repeat(64); - let mut e = entry("cargo", "pkg:cargo/serde@1.0.0", vec![]); - e.lock = Some(CargoLockOriginal { - source: "registry+https://github.com/rust-lang/crates.io-index".into(), - checksum: Some(sha.clone()), - }); - let got = recover_lock_entry(tmp.path(), &e).await.unwrap(); - assert_eq!(got.ecosystem, "cargo"); - assert_eq!(got.integrity, LockIntegrity::Sha256Hex(sha)); - assert_eq!(got.resolved, None); - - // No checksum recorded → unrecoverable, never an unverified fetch. - let mut bare = entry("cargo", "pkg:cargo/serde@1.0.0", vec![]); - bare.lock = None; - assert!(recover_lock_entry(tmp.path(), &bare).await.is_err()); - } - - #[tokio::test] - async fn composer_gem_uv_fragments_recover() { - let tmp = tempfile::tempdir().unwrap(); - let sha1 = "c".repeat(40); - let composer = entry( - "composer", - "pkg:composer/monolog/monolog@2.9.1", - vec![rec( - "composer_lock_package", - serde_json::json!({ - "name": "monolog/monolog", - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/abc", - "shasum": sha1, - }, - }), - )], - ); - let got = recover_lock_entry(tmp.path(), &composer).await.unwrap(); - assert_eq!(got.name, "monolog/monolog"); - assert_eq!(got.integrity, LockIntegrity::Sha1Hex(sha1)); - - // gem: checksum line + remote read from the unrewired Gemfile.lock. - let sha256 = "d".repeat(64); - tokio::fs::write( - tmp.path().join("Gemfile.lock"), - "GEM\n remote: https://rubygems.org/\n specs:\n rack (3.0.0)\n", - ) - .await - .unwrap(); - let gem = entry( - "gem", - "pkg:gem/rack@3.0.0", - vec![rec( - "gemfile_lock_checksum", - serde_json::json!(format!(" rack (3.0.0) sha256={sha256}")), - )], - ); - let got = recover_lock_entry(tmp.path(), &gem).await.unwrap(); - assert_eq!(got.integrity, LockIntegrity::Sha256Hex(sha256.clone())); - assert_eq!( - got.resolved.as_deref(), - Some("https://rubygems.org/downloads/rack-3.0.0.gem") - ); - - // uv: the original [[package]] unit lists wheels; only the PURE one - // is recoverable. - let wheel_sha = "e".repeat(64); - let unit = format!( - "[[package]]\nname = \"six\"\nversion = \"1.16.0\"\nwheels = [\n {{ url = \"https://files.pythonhosted.org/packages/six-1.16.0-cp39-cp39-linux_x86_64.whl\", hash = \"sha256:{}\" }},\n {{ url = \"https://files.pythonhosted.org/packages/six-1.16.0-py2.py3-none-any.whl\", hash = \"sha256:{wheel_sha}\" }},\n]\n", - "f".repeat(64) - ); - let uv = entry( - "pypi", - "pkg:pypi/six@1.16.0", - vec![rec("uv_lock_package", serde_json::json!(unit))], - ); - let got = recover_lock_entry(tmp.path(), &uv).await.unwrap(); - assert_eq!(got.integrity, LockIntegrity::Sha256Hex(wheel_sha)); - assert!(got.resolved.unwrap().ends_with("py2.py3-none-any.whl")); - - // platform-locked wheels are explicitly unrepairable from the registry. - let mut locked = entry("pypi", "pkg:pypi/six@1.16.0", vec![]); - locked.artifact.platform_locked = Some(true); - assert!(recover_lock_entry(tmp.path(), &locked).await.is_err()); - } - - // A pdm.lock produced with the `static_urls` strategy inlines the wheel - // URL exactly like uv.lock, but records it under the `pdm_lock_package` - // wiring kind. Recovery used to look only at `uv_lock_package`, so it was - // blind to pdm/poetry/pipenv projects; it now accepts every pypi kind. - #[tokio::test] - async fn recover_pypi_pdm_static_urls_recovers_pure_wheel() { - let tmp = tempfile::tempdir().unwrap(); - let wheel_sha = "a".repeat(64); - let unit = format!( - "[[package]]\nname = \"six\"\nversion = \"1.16.0\"\nfiles = [\n {{url = \"https://files.pythonhosted.org/packages/71/39/six-1.16.0.tar.gz\", hash = \"sha256:{}\"}},\n {{url = \"https://files.pythonhosted.org/packages/d9/5a/six-1.16.0-py2.py3-none-any.whl\", hash = \"sha256:{wheel_sha}\"}},\n]\n", - "b".repeat(64) - ); - let pdm = entry( - "pypi", - "pkg:pypi/six@1.16.0", - vec![rec("pdm_lock_package", serde_json::json!(unit))], - ); - let got = recover_lock_entry(tmp.path(), &pdm).await.unwrap(); - assert_eq!(got.ecosystem, "pypi"); - assert_eq!(got.name, "six"); - assert_eq!(got.integrity, LockIntegrity::Sha256Hex(wheel_sha)); - assert!(got.resolved.unwrap().ends_with("py2.py3-none-any.whl")); - } - - // Default pdm/poetry (`file = …`), pipenv (`hashes` only) and pip - // (`--hash=`) locks record the wheel hash but no fetchable URL. Recovery - // now RECOGNIZES those fragments (previously they fell through to the - // uv-specific "no uv.lock fragment recorded" error) and returns an - // accurate, actionable message instead of the false "not installed / no - // recoverable fragment". - #[tokio::test] - async fn recover_pypi_urlless_locks_report_no_fetchable_url() { - let tmp = tempfile::tempdir().unwrap(); - - // poetry / default-pdm shape: `files = [{file = …, hash = …}]`. - let poetry_unit = format!( - "[[package]]\nname = \"six\"\nversion = \"1.16.0\"\nfiles = [\n {{file = \"six-1.16.0-py2.py3-none-any.whl\", hash = \"sha256:{}\"}},\n]\n", - "a".repeat(64) - ); - for kind in ["poetry_lock_package", "pdm_lock_package"] { - let e = entry( - "pypi", - "pkg:pypi/six@1.16.0", - vec![rec(kind, serde_json::json!(poetry_unit))], - ); - let err = recover_lock_entry(tmp.path(), &e).await.unwrap_err(); - assert!(err.contains("no fetchable registry URL"), "{kind}: {err}"); - assert!(!err.contains("uv.lock fragment recorded"), "{kind}: {err}"); - } - - // pipenv records a JSON object (hashes + version), not a string unit: - // its digest set IS fetchable (PyPI JSON API lookup by digest), so a - // lock-only checkout of an already-vendored project recovers. - let pipenv = entry( - "pypi", - "pkg:pypi/six@1.16.0", - vec![rec( - "pipenv_lock_entry", - serde_json::json!({ - "hashes": [format!("sha256:{}", "a".repeat(64)), format!("sha256:{}", "B".repeat(64))], - "version": "==1.16.0", - }), - )], - ); - let recovered = recover_lock_entry(tmp.path(), &pipenv).await.unwrap(); - assert_eq!(recovered.purl, "pkg:pypi/six@1.16.0"); - assert_eq!(recovered.resolved, None); - assert_eq!( - recovered.integrity, - LockIntegrity::Sha256AnyOf(vec!["a".repeat(64), "b".repeat(64)]), - "lowercased digest set" - ); - // …but a pipenv fragment without digests has nothing to fetch by. - let digestless = entry( - "pypi", - "pkg:pypi/six@1.16.0", - vec![rec( - "pipenv_lock_entry", - serde_json::json!({"version": "==1.16.0"}), - )], - ); - let err = recover_lock_entry(tmp.path(), &digestless) - .await - .unwrap_err(); - assert!(err.contains("no sha256 digests"), "pipenv: {err}"); - - // A ledger with no pypi fragment at all is still a hard error. - let bare = entry("pypi", "pkg:pypi/six@1.16.0", vec![]); - assert!(recover_lock_entry(tmp.path(), &bare).await.is_err()); - } - - /// Ledger recovery cannot know which GEM section a vendored gem came - /// from (its spec moved into the PATH section), so a multi-source lock - /// makes the download origin ambiguous: refuse rather than guess (a - /// wrong remote 404s at best and leaks a private gem name at worst). - /// Sections that AGREE on one remote stay recoverable. - #[tokio::test] - async fn gem_recovery_refuses_ambiguous_multi_source_lock() { - let tmp = tempfile::tempdir().unwrap(); - let sha256 = "d".repeat(64); - let gem = entry( - "gem", - "pkg:gem/rack@3.0.0", - vec![rec( - "gemfile_lock_checksum", - serde_json::json!(format!(" rack (3.0.0) sha256={sha256}")), - )], - ); - - // Two GEM sections, two different remotes → ambiguous, fail closed. - tokio::fs::write( - tmp.path().join("Gemfile.lock"), - "GEM\n remote: https://gems.corp.example/\n specs:\n private-gem (1.0.0)\n\n\ - GEM\n remote: https://rubygems.org/\n specs:\n", - ) - .await - .unwrap(); - let err = recover_lock_entry(tmp.path(), &gem).await.unwrap_err(); - assert!( - err.contains("multiple GEM sources"), - "ambiguity must be named: {err}" - ); - - // Two GEM sections agreeing on ONE remote (dedup) → recoverable. - tokio::fs::write( - tmp.path().join("Gemfile.lock"), - "GEM\n remote: https://gems.corp.example/\n specs:\n other (1.0.0)\n\n\ - GEM\n remote: https://gems.corp.example/\n specs:\n", - ) - .await - .unwrap(); - let got = recover_lock_entry(tmp.path(), &gem).await.unwrap(); - assert_eq!( - got.resolved.as_deref(), - Some("https://gems.corp.example/downloads/rack-3.0.0.gem"), - "the agreed remote is used, not a rubygems.org guess" - ); - assert_eq!(got.integrity, LockIntegrity::Sha256Hex(sha256)); - } - - /// The ambiguity count must see NON-http remotes too (a `source - /// "file://…" do` block locks its own GEM section with a `file:///` - /// remote — real bundler 4.0.15 output). Filtering to http(s) first - /// would collapse a mixed http+file lock to one "agreed" remote and - /// send a possibly-file-sourced gem's name to the http registry — the - /// same leak class the multi-http refusal closes. A lock whose ONLY - /// remote is non-http must refuse too, never default to rubygems.org. - #[tokio::test] - async fn gem_recovery_counts_non_http_remotes_as_ambiguity() { - let tmp = tempfile::tempdir().unwrap(); - let gem = entry( - "gem", - "pkg:gem/rack@3.0.0", - vec![rec( - "gemfile_lock_checksum", - serde_json::json!(format!(" rack (3.0.0) sha256={}", "d".repeat(64))), - )], - ); - - // Mixed schemes: one file:// section + one https section → ambiguous. - tokio::fs::write( - tmp.path().join("Gemfile.lock"), - "GEM\n remote: file:///srv/gems/\n specs:\n private-gem (1.0.0)\n\n\ - GEM\n remote: https://rubygems.org/\n specs:\n rake (13.3.1)\n", - ) - .await - .unwrap(); - let err = recover_lock_entry(tmp.path(), &gem).await.unwrap_err(); - assert!( - err.contains("multiple GEM sources"), - "a file:// section must count toward the ambiguity refusal: {err}" - ); - - // A single file:// remote: not fetchable, and never a rubygems.org - // fallback (that would leak the private repo's gem name off-site). - tokio::fs::write( - tmp.path().join("Gemfile.lock"), - "GEM\n remote: file:///srv/gems/\n specs:\n private-gem (1.0.0)\n", - ) - .await - .unwrap(); - let err = recover_lock_entry(tmp.path(), &gem).await.unwrap_err(); - assert!( - err.contains("file:///srv/gems") && err.contains("not an http(s) registry"), - "a lone non-http remote must refuse, not guess: {err}" - ); - } - - #[tokio::test] - async fn recover_decodes_percent_encoded_base_purl() { - // The ledger stores base_purl verbatim as the manifest spelled it — - // often percent-encoded (`pkg:npm/%40scope/x@1.2.3`). The recovered - // entry must carry literal coordinates: the name feeds the registry - // tarball URL and the berry cache-zip recipe (which embeds it in - // member paths), so an encoded name fails every checksum rebuild. - let tmp = tempfile::tempdir().unwrap(); - let e = entry( - "npm", - "pkg:npm/%40scope/x@1.2.3", - vec![rec( - "yarn_berry_lock_entry", - serde_json::json!(["\"@scope/x@npm:1.2.3\":", " checksum: 10c0/abcdef"]), - )], - ); - let got = recover_lock_entry(tmp.path(), &e).await.unwrap(); - assert_eq!(got.name, "@scope/x"); - assert_eq!(got.purl, "pkg:npm/@scope/x@1.2.3"); - - // Version components decode too (`1.0.0%2Bbuild` → `1.0.0+build`). - let e = entry( - "npm", - "pkg:npm/x@1.0.0%2Bbuild", - vec![rec( - "npm_lock_entry", - serde_json::json!({ - "resolved": "https://registry.npmjs.org/x/-/x-1.0.0+build.tgz", - "integrity": "sha512-AAAA", - }), - )], - ); - let got = recover_lock_entry(tmp.path(), &e).await.unwrap(); - assert_eq!(got.version, "1.0.0+build"); - } - - #[tokio::test] - async fn unrecoverable_fragments_fail_closed() { - let tmp = tempfile::tempdir().unwrap(); - // No wiring at all. - let bare = entry("npm", "pkg:npm/x@1.0.0", vec![]); - assert!(recover_lock_entry(tmp.path(), &bare).await.is_err()); - // golang routes through go.sum, never the ledger. - let go = entry("golang", "pkg:golang/golang.org/x/text@v0.14.0", vec![]); - assert!(recover_lock_entry(tmp.path(), &go).await.is_err()); - // Poisoned integrity shapes are rejected. - let bad = entry( - "npm", - "pkg:npm/x@1.0.0", - vec![rec( - "npm_lock_entry", - serde_json::json!({"resolved": "https://x/", "integrity": "lol"}), - )], - ); - assert!(recover_lock_entry(tmp.path(), &bad).await.is_err()); - } - - /// composer dists FREQUENTLY record `shasum: ""` — that common recovery - /// outcome refuses the unverifiable fetch; the gem twin refuses when the - /// recorded checksum line carries no extractable 64-hex sha256. - #[tokio::test] - async fn recover_composer_empty_shasum_and_gem_hexless_checksum_fail_closed() { - let tmp = tempfile::tempdir().unwrap(); - let composer = entry( - "composer", - "pkg:composer/monolog/monolog@2.9.1", - vec![rec( - "composer_lock_package", - serde_json::json!({ - "dist": { "type": "zip", "url": "https://example.com/a.zip", "shasum": "" }, - }), - )], - ); - let err = recover_lock_entry(tmp.path(), &composer).await.unwrap_err(); - assert!( - err.contains("records no shasum"), - "an empty shasum must refuse the fetch: {err}" - ); - - // The sha256 check precedes gem_remotes, so no Gemfile.lock needed. - let gem = entry( - "gem", - "pkg:gem/rake@13.0.6", - vec![rec( - "gemfile_lock_checksum", - serde_json::json!(" rake (13.0.6) sha256=zz"), - )], - ); - let err = recover_lock_entry(tmp.path(), &gem).await.unwrap_err(); - assert!( - err.contains("has no sha256"), - "a hex-less checksum line must refuse the fetch: {err}" - ); - } - - /// Fragments PRESENT but invalid (non-SRI pnpm integrity, a yarn block - /// with neither SRI nor 40-hex fragment, a malformed berry checksum, a - /// bun tuple without an SRI token) must all fall through to the final - /// fail-closed error — only the no-fragment-at-all path was tested. An - /// empty-name base purl is unparseable outright. - #[tokio::test] - async fn recover_present_but_invalid_npm_fragments_fail_closed() { - let tmp = tempfile::tempdir().unwrap(); - let nameless = entry("npm", "pkg:npm/@1.0.0", vec![]); - let err = recover_lock_entry(tmp.path(), &nameless).await.unwrap_err(); - assert!( - err.contains("unparseable base purl"), - "an empty-name purl must be rejected: {err}" - ); - - let all_invalid = entry( - "npm", - "pkg:npm/x@1.0.0", - vec![ - rec( - "pnpm_lock_package", - serde_json::json!([" resolution: {integrity: garbage}"]), - ), - rec( - "yarn_lock_block", - serde_json::json!([" resolved \"https://x/y.tgz\""]), - ), - rec( - "yarn_berry_lock_entry", - serde_json::json!([" checksum: malformed"]), - ), - rec( - "bun_lock_package", - serde_json::json!(" \"x\": [\"x@1.0.0\", \"\", {}, \"notsri\"],"), - ), - ], - ); - let err = recover_lock_entry(tmp.path(), &all_invalid) - .await - .unwrap_err(); - assert!( - err.contains("no pre-vendor npm registry fragment"), - "every invalid fragment must fall through to the fail-closed error: {err}" - ); - } -} - -#[cfg(test)] -mod python_lock_union_tests { - use super::*; - - const WHEEL_SHA: &str = "abababababababababababababababababababababababababababababababab"; - - fn uv_style_lock(name: &str, version: &str) -> String { - format!( - "version = 1\n\n[[package]]\nname = \"{name}\"\nversion = \"{version}\"\nsource = {{ registry = \"https://pypi.org/simple\" }}\nwheels = [{{ url = \"https://files.pythonhosted.org/{name}-{version}-py3-none-any.whl\", hash = \"sha256:{WHEEL_SHA}\" }}]\n" - ) - } - - fn names(entries: &[LockfileEntry]) -> Vec<(String, String)> { - let mut pairs: Vec<_> = entries - .iter() - .map(|entry| (entry.name.clone(), entry.version.clone())) - .collect(); - pairs.sort(); - pairs - } - - /// A script lock is scoped to its script: it must ADD to the project's - /// requirements.txt / poetry.lock pins, not replace them (the base only - /// ever let uv.lock short-circuit the fallbacks). - #[tokio::test] - async fn script_lock_supplements_project_pins() { - let tmp = tempfile::tempdir().unwrap(); - tokio::fs::write(tmp.path().join("requirements.txt"), "requests==2.31.0\n") - .await - .unwrap(); - tokio::fs::write( - tmp.path().join("tool.py.lock"), - uv_style_lock("flask", "3.0.0"), - ) - .await - .unwrap(); - let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); - assert_eq!( - names(&entries), - vec![ - ("flask".to_string(), "3.0.0".to_string()), - ("requests".to_string(), "2.31.0".to_string()), - ] - ); - } - - /// uv.lock keeps its exclusive precedence over the fallbacks. - #[tokio::test] - async fn uv_lock_still_hides_requirements_pins() { - let tmp = tempfile::tempdir().unwrap(); - tokio::fs::write(tmp.path().join("requirements.txt"), "requests==2.31.0\n") - .await - .unwrap(); - tokio::fs::write(tmp.path().join("uv.lock"), uv_style_lock("flask", "3.0.0")) - .await - .unwrap(); - let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); - assert_eq!( - names(&entries), - vec![("flask".to_string(), "3.0.0".to_string())] - ); - } - - /// Without a uv.lock, poetry.lock is the project's tool lock: it hides - /// requirements.txt (the base's poetry → requirements ordering) while a - /// script lock still UNIONS with it — the standalone lock supplements - /// whichever tool lock the project has, never just uv.lock. - #[tokio::test] - async fn poetry_lock_unions_with_script_lock_and_hides_requirements() { - let tmp = tempfile::tempdir().unwrap(); - tokio::fs::write( - tmp.path().join("poetry.lock"), - "[[package]]\nname = \"requests\"\nversion = \"2.31.0\"\n\n[metadata]\nlock-version = \"2.0\"\n", - ) - .await - .unwrap(); - tokio::fs::write( - tmp.path().join("tool.py.lock"), - uv_style_lock("flask", "3.0.0"), - ) - .await - .unwrap(); - tokio::fs::write(tmp.path().join("requirements.txt"), "click==8.1.7\n") - .await - .unwrap(); - let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); - assert_eq!( - names(&entries), - vec![ - ("flask".to_string(), "3.0.0".to_string()), - ("requests".to_string(), "2.31.0".to_string()), - ] - ); - } - - /// Exclusivity is keyed on a uv.lock that PARSES, not on the file's - /// presence: garbage TOML contributes nothing and must not hide the - /// requirements.txt pins behind it (hosted skips the same file with - /// `redirect_uv_lock_unsupported`, so the inventories agree). - #[tokio::test] - async fn unparseable_uv_lock_falls_through_to_requirements() { - let tmp = tempfile::tempdir().unwrap(); - tokio::fs::write( - tmp.path().join("uv.lock"), - "version = 1\n[[package]\nname = \"flask\"\n= broken\n", - ) - .await - .unwrap(); - tokio::fs::write(tmp.path().join("requirements.txt"), "requests==2.31.0\n") - .await - .unwrap(); - let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); - assert_eq!( - names(&entries), - vec![("requests".to_string(), "2.31.0".to_string())] - ); - } - - /// Ledger recovery must match a purl spelled the project's way - /// (`PyYAML`) against the PEP 503 names the inventory records. - #[tokio::test] - async fn python_document_recovery_canonicalizes_the_purl_name() { - let tmp = tempfile::tempdir().unwrap(); - let lock = format!( - "lock-version = '1.0'\n[[packages]]\nname = 'pyyaml'\nversion = '6.0.1'\narchive = {{ url = 'https://pypi.org/PyYAML-6.0.1-py3-none-any.whl', hashes = {{ sha256 = '{WHEEL_SHA}' }} }}\n" - ); - let entry = crate::vendor::state::VendorEntry { - ecosystem: "pypi".into(), - base_purl: "pkg:pypi/PyYAML@6.0.1".into(), - uuid: "11111111-1111-4111-8111-111111111111".into(), - artifact: crate::vendor::state::VendorArtifact { - path: ".socket/vendor/pypi/11111111-1111-4111-8111-111111111111/PyYAML-6.0.1-py3-none-any.whl".into(), - sha256: String::new(), - size: None, - platform_locked: None, - file_inventory: None, - }, - wiring: vec![crate::vendor::state::WiringRecord { - file: "pylock.toml".into(), - kind: "python_lock_document".into(), - action: crate::vendor::state::WiringAction::Rewritten, - key: Some("pyyaml".into()), - original: Some(serde_json::Value::String(lock)), - new: None, - }], - lock: None, - took_over_go_patches: false, - detached: false, - record: None, - flavor: Some("python-lock".into()), - uv: None, - pnpm: None, - poetry: None, - pdm: None, - pipenv: None, - }; - let recovered = recover_lock_entry(tmp.path(), &entry).await.unwrap(); - assert_eq!( - recovered.resolved.as_deref(), - Some("https://pypi.org/PyYAML-6.0.1-py3-none-any.whl") - ); - assert_eq!( - recovered.integrity, - LockIntegrity::Sha256Hex(WHEEL_SHA.into()) - ); - } -} diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/bun.rs b/crates/socket-patch-core/src/vendor/lock_inventory/bun.rs new file mode 100644 index 00000000..176766ba --- /dev/null +++ b/crates/socket-patch-core/src/vendor/lock_inventory/bun.rs @@ -0,0 +1,91 @@ +//! `bun.lock` / `bun.lockb`: the registry views. + +use std::path::Path; + +use crate::utils::fs::{read_regular_to_bytes, read_regular_to_string}; +use crate::vendor::bun_lock_text; + +use super::{http_url, LockIntegrity, LockfileEntry, UnsupportedNpmLayout}; + +pub(super) async fn inventory_bun_binary( + root: &Path, +) -> Result, UnsupportedNpmLayout> { + let invalid = |detail: String| UnsupportedNpmLayout { + code: "bun_lockb_invalid", + detail: format!("cannot inventory bun.lockb: {detail}"), + }; + let bytes = read_regular_to_bytes(&root.join("bun.lockb")) + .await + .map_err(|error| invalid(error.to_string()))?; + let lock = crate::vendor::bun_lockb::BunLockb::parse(&bytes).map_err(invalid)?; + let packages = lock.packages().map_err(invalid)?; + Ok(packages + .into_iter() + .filter_map(|package| { + let version = package.version?; + // Only resolved registry versions participate. Workspace, file and + // git sources have no registry version; a local vendored tarball's + // pristine metadata is recovered from its wiring ledger instead. + if !version.chars().next().is_some_and(|c| c.is_ascii_digit()) { + return None; + } + Some(LockfileEntry::npm( + package.name, + version, + http_url(&package.resolution), + package + .integrity + .map(LockIntegrity::Sri) + .unwrap_or(LockIntegrity::None), + )) + }) + .collect()) +} + +pub(super) async fn inventory_bun(root: &Path) -> Option> { + let text = read_regular_to_string(&root.join("bun.lock")).await.ok()?; + bun_lock_text::check_lock_version(&text).ok()?; + let lines: Vec = text.split('\n').map(str::to_string).collect(); + let entries = bun_lock_text::parse_packages_section(&lines).ok()?; + + let mut out = Vec::new(); + for entry in entries { + // Registry entries are 4-tuples `[spec, registry, {deps}, sha512]`; + // our vendored 3-tuples and other shapes are skipped. + if entry.elems.len() != 4 || !entry.elems[2].starts_with('{') { + continue; + } + let Some(spec) = entry + .elems + .first() + .and_then(|e| bun_lock_text::decode_json_string(e)) + else { + continue; + }; + let Some((name, version)) = bun_lock_text::split_name_spec(&spec) else { + continue; + }; + if !version.chars().next().is_some_and(|c| c.is_ascii_digit()) { + continue; + } + let Some(registry) = bun_lock_text::decode_json_string(&entry.elems[1]) else { + continue; + }; + let Some(integrity) = bun_lock_text::decode_json_string(&entry.elems[3]) else { + continue; + }; + // elem[1] is `""` for the default registry; a full `.tgz` URL is + // used verbatim; any other base falls back to conventional URL + // construction (the integrity check still gates the content). + let resolved = (registry.ends_with(".tgz")) + .then(|| http_url(®istry)) + .flatten(); + out.push(LockfileEntry::npm( + name, + version, + resolved, + LockIntegrity::Sri(integrity), + )); + } + Some(out) +} diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/cargo.rs b/crates/socket-patch-core/src/vendor/lock_inventory/cargo.rs new file mode 100644 index 00000000..6510cb29 --- /dev/null +++ b/crates/socket-patch-core/src/vendor/lock_inventory/cargo.rs @@ -0,0 +1,80 @@ +//! `Cargo.lock`: the registry view. + +use std::path::Path; + +use crate::patch::path_safety; +use crate::utils::fs::read_regular_to_string; + +use super::{dedup_prefer_integrity, is_hex_of_len, LockIntegrity, LockfileEntry}; + +/// Inventory `Cargo.lock` `[[package]]` blocks. Only crates.io-sourced +/// entries are fetchable (their `checksum` is the sha256 of the `.crate` +/// file); workspace members (no `source`) are skipped, and git/custom- +/// registry sources stay listed for discovery without a verifier. +pub(super) async fn inventory_cargo_lock(project_root: &Path) -> Option> { + let text = read_regular_to_string(&project_root.join("Cargo.lock")) + .await + .ok()?; + /// One in-flight `[[package]]` block: name, version, source, checksum. + type CargoBlock = ( + Option, + Option, + Option, + Option, + ); + let mut out = Vec::new(); + let mut cur: Option = None; + let flush = |cur: &mut Option, out: &mut Vec| { + if let Some((Some(name), Some(version), source, checksum)) = cur.take() { + let Some(source) = source else { + return; // workspace member + }; + if !path_safety::is_safe_single_segment(&name) + || !path_safety::is_safe_single_segment(&version) + { + return; + } + let crates_io = source.contains("github.com/rust-lang/crates.io-index") + || source.contains("index.crates.io"); + let integrity = match checksum { + Some(c) if crates_io && is_hex_of_len(&c, 64) => LockIntegrity::Sha256Hex(c), + _ => LockIntegrity::None, + }; + let purl = format!("pkg:cargo/{name}@{version}"); + out.push(LockfileEntry { + ecosystem: "cargo", + name, + version, + purl, + resolved: None, + integrity, + }); + } + }; + for line in text.lines() { + let line = line.trim(); + if line == "[[package]]" { + flush(&mut cur, &mut out); + cur = Some((None, None, None, None)); + continue; + } + if line.starts_with('[') { + flush(&mut cur, &mut out); + continue; + } + let Some(slot) = cur.as_mut() else { continue }; + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let value = value.trim().trim_matches('"').to_string(); + match key.trim() { + "name" => slot.0 = Some(value), + "version" => slot.1 = Some(value), + "source" => slot.2 = Some(value), + "checksum" => slot.3 = Some(value), + _ => {} + } + } + flush(&mut cur, &mut out); + Some(dedup_prefer_integrity(out)) +} diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/composer.rs b/crates/socket-patch-core/src/vendor/lock_inventory/composer.rs new file mode 100644 index 00000000..06ac1e80 --- /dev/null +++ b/crates/socket-patch-core/src/vendor/lock_inventory/composer.rs @@ -0,0 +1,88 @@ +//! `composer.lock`: the registry view. + +use std::path::Path; + +use serde_json::Value; + +use crate::crawlers::composer_crawler::normalize_version; +use crate::patch::path_safety; +use crate::utils::fs::read_regular_to_bytes; +use crate::vendor::path::parse_vendor_path; + +use super::{dedup_prefer_integrity, http_url, is_hex_of_len, LockIntegrity, LockfileEntry}; + +/// Inventory `composer.lock` `packages`/`packages-dev`. The `dist.shasum` +/// (sha1 of the dist zip) is frequently empty — such entries stay +/// discovery-only. Names lowercase to the canonical packagist form; +/// versions drop the pretty leading `v`/`V` through the crawler's +/// [`normalize_version`], so installed and lockfile rows agree. +pub(super) async fn inventory_composer_lock(project_root: &Path) -> Option> { + let bytes = read_regular_to_bytes(&project_root.join("composer.lock")) + .await + .ok()?; + let doc: Value = serde_json::from_slice(&bytes).ok()?; + let mut out = Vec::new(); + for section in ["packages", "packages-dev"] { + let Some(list) = doc.get(section).and_then(Value::as_array) else { + continue; + }; + for pkg in list { + let Some(name) = pkg.get("name").and_then(Value::as_str) else { + continue; + }; + let Some(version) = pkg.get("version").and_then(Value::as_str) else { + continue; + }; + let name = name.to_ascii_lowercase(); + // Share the crawler's normalization rather than re-deriving it: + // it strips `v` AND `V` (both are legal Composer tags), and a + // lockfile row that normalizes differently from the installed + // row double-counts the package — one installed `@1.2.3` plus a + // phantom lockfile-only `@V1.2.3`, both POSTed. + let version = normalize_version(version).to_string(); + if !path_safety::is_safe_multi_segment(&name) + || name.split('/').count() != 2 + || !path_safety::is_safe_single_segment(&version) + { + continue; + } + let dist = pkg.get("dist"); + let dist_url = dist + .and_then(|d| d.get("url")) + .and_then(Value::as_str) + .unwrap_or(""); + // Our own vendored entries use a path dist — skip. + if dist + .and_then(|d| d.get("type")) + .and_then(Value::as_str) + .is_some_and(|t| t == "path") + || parse_vendor_path(dist_url).is_some() + { + continue; + } + let is_zip = dist + .and_then(|d| d.get("type")) + .and_then(Value::as_str) + .is_some_and(|t| t == "zip"); + let shasum = dist + .and_then(|d| d.get("shasum")) + .and_then(Value::as_str) + .unwrap_or(""); + let integrity = if is_zip && is_hex_of_len(shasum, 40) { + LockIntegrity::Sha1Hex(shasum.to_ascii_lowercase()) + } else { + LockIntegrity::None + }; + let purl = format!("pkg:composer/{name}@{version}"); + out.push(LockfileEntry { + ecosystem: "composer", + name, + version, + purl, + resolved: is_zip.then(|| http_url(dist_url)).flatten(), + integrity, + }); + } + } + Some(dedup_prefer_integrity(out)) +} diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/gem.rs b/crates/socket-patch-core/src/vendor/lock_inventory/gem.rs new file mode 100644 index 00000000..5a6ab2ba --- /dev/null +++ b/crates/socket-patch-core/src/vendor/lock_inventory/gem.rs @@ -0,0 +1,168 @@ +//! `Gemfile.lock`: the registry view and the GEM remote set ledger recovery +//! reads. + +use std::collections::HashMap; +use std::path::Path; + +use crate::patch::path_safety; +use crate::utils::fs::read_regular_to_string; + +use super::{dedup_prefer_integrity, http_url, is_hex_of_len, LockIntegrity, LockfileEntry}; + +/// Inventory `Gemfile.lock`: `GEM`-section `specs:` entries (4-space +/// indent; deeper lines are dependency ranges) plus the bundler ≥ 2.6 +/// `CHECKSUMS` section's sha256 values when present (older locks stay +/// discovery-only). Platform-suffixed specs (`nokogiri (1.16.5-arm64-…)`) +/// are skipped — platform gems are unsupported for vendoring anyway. +/// +/// Multi-source locks: bundler ≥ 2 emits ONE GEM section per source +/// (Gemfile `source … do` blocks; verified against bundler 4.0.15) and +/// hard-errors on multiple global sources, so each spec resolves against +/// its OWN section's remote — never the first remote in the file, which +/// for a private-server section would 404 at best and leak private gem +/// names to the public registry at worst. A section carrying SEVERAL +/// distinct `remote:` lines is a legacy bundler 1.x multisource lock whose +/// per-spec origin is genuinely ambiguous: its specs stay discovery-only +/// (no resolved URL — the fetch layer then refuses), fail-closed. +pub(super) async fn inventory_gemfile_lock(project_root: &Path) -> Option> { + let text = read_regular_to_string(&project_root.join("Gemfile.lock")) + .await + .ok()?; + let mut section_remotes: Vec> = Vec::new(); + let mut checksums: HashMap<(String, String), String> = HashMap::new(); + let mut specs: Vec<(String, String, usize)> = Vec::new(); + + let mut section = ""; + let mut in_specs = false; + for line in text.lines() { + if !line.starts_with(' ') { + section = line.trim(); + in_specs = false; + if section == "GEM" { + section_remotes.push(Vec::new()); + } + continue; + } + let trimmed = line.trim_start(); + let indent = line.len() - trimmed.len(); + match section { + "GEM" => { + if indent == 2 { + if let Some(r) = trimmed.strip_prefix("remote:") { + let r = r.trim().trim_end_matches('/'); + if !r.is_empty() { + if let Some(remotes) = section_remotes.last_mut() { + remotes.push(r.to_string()); + } + } + } + in_specs = trimmed == "specs:"; + } else if in_specs && indent == 4 { + if let Some((name, version)) = parse_gem_spec_line(trimmed) { + specs.push((name, version, section_remotes.len() - 1)); + } + } + } + "CHECKSUMS" => { + // ` name (version) sha256=hex` + if let Some((spec_part, hash_part)) = + trimmed.rsplit_once(" sha256=").map(|(s, h)| (s, h.trim())) + { + if let Some((name, version)) = parse_gem_spec_line(spec_part) { + if is_hex_of_len(hash_part, 64) { + checksums.insert((name, version), hash_part.to_ascii_lowercase()); + } + } + } + } + _ => {} + } + } + if specs.is_empty() { + return None; + } + let mut out = Vec::new(); + for (name, version, sec) in specs { + if !path_safety::is_safe_single_segment(&name) + || !path_safety::is_safe_single_segment(&version) + { + continue; + } + let integrity = checksums + .get(&(name.clone(), version.clone())) + .map(|h| LockIntegrity::Sha256Hex(h.clone())) + .unwrap_or(LockIntegrity::None); + let resolved = match section_remotes.get(sec).map(Vec::as_slice) { + Some([base]) => http_url(&format!("{base}/downloads/{name}-{version}.gem")), + // No remote (a missing `remote:` line defaults to rubygems.org + // ONLY when the whole lock has one remote-less GEM section — + // the pre-multisource shape) or several remotes: fail closed. + Some([]) if section_remotes.len() == 1 => http_url(&format!( + "https://rubygems.org/downloads/{name}-{version}.gem" + )), + _ => None, + }; + out.push(LockfileEntry { + ecosystem: "gem", + purl: format!("pkg:gem/{name}@{version}"), + resolved, + name, + version, + integrity, + }); + } + Some(dedup_prefer_integrity(out)) +} + +/// `name (version)` → parts; platform-suffixed versions (`1.2.3-x86_64…`) +/// and dependency lines (no parens / range operators) yield `None`. +fn parse_gem_spec_line(line: &str) -> Option<(String, String)> { + let (name, rest) = line.split_once(" (")?; + let version = rest.strip_suffix(')')?; + if name.is_empty() + || version.is_empty() + || version.contains(' ') + || version.contains('-') + || !version.chars().next().is_some_and(|c| c.is_ascii_digit()) + { + return None; + } + Some((name.to_string(), version.to_string())) +} + +/// The DISTINCT `GEM remote:` bases across ALL GEM sections of the +/// Gemfile.lock (trailing `/` trimmed), in first-appearance order. A +/// vendored gem's spec block moved into its PATH section, so which GEM +/// section it came from is unrecoverable — ledger recovery may only build +/// a download URL when the lock's GEM sources agree on a single remote. +/// Collected scheme-AGNOSTICALLY: a non-http remote (a `file://` gem repo — +/// bundler 4.0.15 locks one GEM section per `source "file://…" do` block) +/// still counts toward the ambiguity decision; filtering it out first would +/// collapse a mixed http+file lock to one "agreed" remote and send the +/// file-sourced gem's name to the http one. The caller requires the single +/// survivor to be http(s). +pub(super) async fn gem_remotes(project_root: &Path) -> Vec { + let Ok(text) = read_regular_to_string(&project_root.join("Gemfile.lock")).await else { + return Vec::new(); + }; + let mut out: Vec = Vec::new(); + let mut in_gem = false; + for line in text.lines() { + if line.trim().is_empty() { + continue; + } + if !line.starts_with(' ') { + in_gem = line.trim_end() == "GEM"; + continue; + } + if in_gem { + if let Some(rest) = line.trim().strip_prefix("remote:") { + let url = rest.trim().trim_end_matches('/').to_string(); + if !url.is_empty() && !out.contains(&url) { + out.push(url); + } + } + } + } + out +} diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/golang.rs b/crates/socket-patch-core/src/vendor/lock_inventory/golang.rs new file mode 100644 index 00000000..b6805523 --- /dev/null +++ b/crates/socket-patch-core/src/vendor/lock_inventory/golang.rs @@ -0,0 +1,44 @@ +//! `go.sum`: the registry view. + +use std::path::Path; + +use crate::patch::path_safety; +use crate::utils::fs::read_regular_to_string; + +use super::{dedup_prefer_integrity, LockIntegrity, LockfileEntry}; + +/// Inventory `go.sum` module-zip lines (` h1:`); the +/// `/go.mod`-suffixed lines hash only the manifest and are skipped. go.sum +/// may list more modules than the final build graph — acceptable for +/// discovery, and the manifest decides what actually gets vendored. +pub(super) async fn inventory_go_sum(project_root: &Path) -> Option> { + let text = read_regular_to_string(&project_root.join("go.sum")) + .await + .ok()?; + let mut out = Vec::new(); + for line in text.lines() { + let mut parts = line.split_whitespace(); + let (Some(module), Some(version), Some(hash)) = (parts.next(), parts.next(), parts.next()) + else { + continue; + }; + if version.ends_with("/go.mod") || !hash.starts_with("h1:") { + continue; + } + // SECURITY: module path segments and the version feed paths/URLs. + if !path_safety::is_safe_multi_segment(module) + || !path_safety::is_safe_single_segment(version) + { + continue; + } + out.push(LockfileEntry { + ecosystem: "golang", + name: module.to_string(), + version: version.to_string(), + purl: format!("pkg:golang/{module}@{version}"), + resolved: None, + integrity: LockIntegrity::GoH1(hash.to_string()), + }); + } + Some(dedup_prefer_integrity(out)) +} diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs b/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs new file mode 100644 index 00000000..e7a9807c --- /dev/null +++ b/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs @@ -0,0 +1,254 @@ +//! Read-only lockfile inventories: the dependency set a project's lockfile +//! resolves, independent of what is installed on disk. +//! +//! Two consumers: +//! +//! * `scan` supplements its installed-tree crawl with lockfile-only entries +//! (discovery on fresh clones and partial installs), warning that those +//! packages are not yet installed; +//! * `vendor` fetches the pristine artifact for a lockfile-resolved package +//! with no installed copy ([`super::registry_fetch`]), verifying the bytes +//! against the integrity the lock records — FAIL-CLOSED: an entry whose +//! lock carries no content verifier is never fetched. +//! +//! Parsing is fail-soft per entry (a malformed entry is skipped, never an +//! error; a malformed text file yields `None`, while a malformed binary Bun +//! lock emits `bun_lockb_invalid`) and fail-closed per value: +//! names/versions are path-safety-guarded before an entry is emitted — the +//! lockfile is committed, tamperable input that later feeds filesystem paths +//! and download URLs. + +use std::collections::HashMap; +use std::path::Path; + +use crate::crawlers::python_crawler::canonicalize_pypi_name; +use crate::utils::purl::strip_purl_qualifiers; + +pub(crate) mod bun; +pub(crate) mod cargo; +pub(crate) mod composer; +pub(crate) mod gem; +pub(crate) mod golang; +pub(crate) mod npm; +pub(crate) mod npm_family; +pub(crate) mod pnpm; +pub(crate) mod pypi; +pub(crate) mod recover; +pub(crate) mod wired; +pub(crate) mod yarn; + +pub(crate) use self::npm_family::inventory_npm_lock; +pub use self::recover::recover_lock_entry; +pub use self::wired::wired_vendor_integrity; + +// The per-format views `inventory_project_diagnosed` unions (and the test +// modules reach through `super::*`). +use self::cargo::inventory_cargo_lock; +use self::composer::inventory_composer_lock; +use self::gem::inventory_gemfile_lock; +use self::golang::inventory_go_sum; +use self::pypi::inventory_pypi_locks; +#[cfg(test)] +use self::{ + bun::inventory_bun, + gem::gem_remotes, + npm::inventory_package_lock, + npm_family::finalize_npm, + pnpm::{inventory_pnpm_lock, inventory_pnpm_lock_at}, + pypi::{is_public_pypi_url, python_lock_inventory, socket_reference_coords}, + recover::pure_wheel_from_uv_unit, + yarn::{inventory_yarn_berry, inventory_yarn_classic}, +}; +#[cfg(test)] +use crate::vendor::npm_flavor::NpmLockFlavor; + +/// The content verifier a lockfile records for an entry. The fetch layer +/// refuses entries whose verifier is [`LockIntegrity::None`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LockIntegrity { + /// SRI string (`sha512-`, possibly multi-hash space-separated) — + /// npm family; verified against the raw tarball bytes. + Sri(String), + /// yarn classic `resolved "...#"` fragment (40-hex) — verified + /// against the raw tarball bytes. + Sha1Hex(String), + /// yarn berry cache-zip checksum (`/`, e.g. `10c0/…`) — + /// verified by rebuilding the deterministic cache zip from the fetched + /// tarball and comparing (the lock never hashes the tarball itself). + BerryChecksum(String), + /// Hex sha256 of the artifact (Cargo.lock `checksum`, pypi file hashes, + /// Gemfile.lock `CHECKSUMS`). + Sha256Hex(String), + /// One of several hex sha256 digests: the lock records every release + /// file's digest without saying which file is which (Pipfile.lock + /// `hashes`), so the fetcher picks the pure-Python wheel whose PyPI + /// digest is in the set and verifies the download against that digest. + Sha256AnyOf(Vec), + /// go.sum module-zip dirhash (`h1:`). + GoH1(String), + /// The lock records no content verifier. + None, +} + +/// One lockfile-resolved package. +#[derive(Debug, Clone)] +pub struct LockfileEntry { + /// Vendor-ecosystem tag (`npm`, `cargo`, `golang`, `pypi`, `gem`, + /// `composer`) — matches `VendorEntry::ecosystem`. + pub ecosystem: &'static str, + /// Literal (percent-decoded) package name, e.g. `@scope/name`. + pub name: String, + /// Exact resolved version. + pub version: String, + /// Canonical literal purl (`pkg:npm/@scope/name@1.0.0`) — the same form + /// the crawlers emit. + pub purl: String, + /// Artifact URL when the lock records one (package-lock `resolved`, + /// yarn `resolved` minus its `#sha1` fragment, pnpm `tarball:`); `None` + /// means the fetcher constructs the conventional registry URL. + pub resolved: Option, + pub integrity: LockIntegrity, +} + +impl LockfileEntry { + fn npm( + name: impl Into, + version: impl Into, + resolved: Option, + integrity: LockIntegrity, + ) -> Self { + let (name, version) = (name.into(), version.into()); + let purl = format!("pkg:npm/{name}@{version}"); + LockfileEntry { + ecosystem: "npm", + name, + version, + purl, + resolved, + integrity, + } + } +} + +/// A project layout or lockfile that cannot be inventoried safely. +/// Consumers surface these diagnoses instead of treating an unreadable +/// dependency graph as an empty project. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnsupportedNpmLayout { + /// Stable diagnosis code, including `bun_lockb_invalid` for malformed + /// binary Bun locks and the flavor probe's Plug'n'Play refusal codes. + pub code: &'static str, + /// Human-readable diagnosis with format or filesystem error details. + pub detail: String, +} + +/// Match a manifest/API purl (possibly percent-encoded, possibly carrying +/// qualifiers) against the inventory: components decode via +/// [`crate::utils::purl::normalize_purl`], so `pkg:npm/%40scope/x@1` +/// matches the literal entry. +pub fn lookup<'a>(entries: &'a [LockfileEntry], purl: &str) -> Option<&'a LockfileEntry> { + let decoded = crate::utils::purl::normalize_purl(strip_purl_qualifiers(purl)).into_owned(); + let rest = decoded.strip_prefix("pkg:")?; + let (purl_type, rest) = rest.split_once('/')?; + // purl types double as the vendor-ecosystem tags (same set the + // dispatcher recognizes). + let eco = match purl_type { + "npm" | "cargo" | "golang" | "pypi" | "gem" | "composer" => purl_type, + _ => return None, + }; + let at = rest.rfind('@').filter(|&i| i > 0)?; + let (name, version) = (&rest[..at], &rest[at + 1..]); + // pypi names compare in PEP 503 normalized form. + let name = if eco == "pypi" { + canonicalize_pypi_name(name) + } else { + name.to_string() + }; + entries + .iter() + .find(|e| e.ecosystem == eco && e.name == name && e.version == version) +} + +/// Everything every recognized lockfile in the project resolves — the +/// union the scan supplement and the vendor auto-fetch consume. Drops the +/// npm-layout diagnosis; callers that must surface refusals (scan) use +/// [`inventory_project_diagnosed`]. +pub async fn inventory_project(project_root: &Path) -> Vec { + inventory_project_diagnosed(project_root).await.0 +} + +/// [`inventory_project`] plus the npm-family layout refusals it hit: a +/// Plug'n'Play project yields no npm entries AND a diagnosis, so consumers +/// can tell "nothing to inventory" from "packages structurally unreachable" +/// and refuse explicitly instead of silently reporting an empty project. +pub async fn inventory_project_diagnosed( + project_root: &Path, +) -> (Vec, Vec) { + let mut out: Vec = Vec::new(); + let mut unsupported: Vec = Vec::new(); + match inventory_npm_lock(project_root).await { + Ok(Some((_, entries))) => out.extend(entries), + Ok(None) => {} + Err(diag) => unsupported.push(diag), + } + if let Some(entries) = inventory_cargo_lock(project_root).await { + out.extend(entries); + } + if let Some(entries) = inventory_go_sum(project_root).await { + out.extend(entries); + } + if let Some(entries) = inventory_composer_lock(project_root).await { + out.extend(entries); + } + if let Some(entries) = inventory_gemfile_lock(project_root).await { + out.extend(entries); + } + if let Some(entries) = inventory_pypi_locks(project_root).await { + out.extend(entries); + } + (out, unsupported) +} + +/// Collapse duplicate (name, version) instances, preferring one that +/// carries a verifier. +fn dedup_prefer_integrity(raw: Vec) -> Vec { + let mut seen: HashMap<(String, String), usize> = HashMap::new(); + let mut out: Vec = Vec::new(); + for entry in raw { + let key = (entry.name.clone(), entry.version.clone()); + match seen.get(&key) { + Some(&i) => { + if out[i].integrity == LockIntegrity::None && entry.integrity != LockIntegrity::None + { + out[i] = entry; + } + } + None => { + seen.insert(key, out.len()); + out.push(entry); + } + } + } + out +} + +/// Keep a lock-recorded URL only when it is a plain http(s) artifact URL +/// (drops `git+…`, `file:…`, `link:…` — content the registry conventions +/// cannot reproduce; such entries stay listed for discovery but the fetch +/// layer's integrity rule decides fetchability). +fn http_url(raw: &str) -> Option { + (raw.starts_with("https://") || raw.starts_with("http://")).then(|| raw.to_string()) +} + +fn is_hex_of_len(s: &str, len: usize) -> bool { + s.len() == len && s.bytes().all(|b| b.is_ascii_hexdigit()) +} + +#[cfg(test)] +mod tests; + +#[cfg(test)] +mod recover_tests; + +#[cfg(test)] +mod python_lock_union_tests; diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/npm.rs b/crates/socket-patch-core/src/vendor/lock_inventory/npm.rs new file mode 100644 index 00000000..f5ad459b --- /dev/null +++ b/crates/socket-patch-core/src/vendor/lock_inventory/npm.rs @@ -0,0 +1,66 @@ +//! `package-lock.json` / `npm-shrinkwrap.json`: the registry view. + +use std::path::Path; + +use serde_json::Value; + +use crate::utils::fs::read_regular_to_bytes; +use crate::vendor::path::parse_vendor_path; + +use super::{http_url, LockIntegrity, LockfileEntry}; + +pub(super) async fn inventory_package_lock(root: &Path) -> Option> { + // Shrinkwrap wins, mirroring `npm_lock::select_lockfile`. + let mut bytes = None; + for lock in ["npm-shrinkwrap.json", "package-lock.json"] { + if let Ok(b) = read_regular_to_bytes(&root.join(lock)).await { + bytes = Some(b); + break; + } + } + let doc: Value = serde_json::from_slice(&bytes?).ok()?; + // v1 legacy locks have no `packages` map — no inventory (documented). + let packages = doc.get("packages")?.as_object()?; + + let mut out = Vec::new(); + for (key, node) in packages { + // "" is the root project; keys without node_modules/ are workspace + // members (mirrors npm_lock::scan_lock_matches' member rule). + let Some((_, key_name)) = key.rsplit_once("node_modules/") else { + continue; + }; + if node.get("link").and_then(Value::as_bool).unwrap_or(false) + || node + .get("inBundle") + .and_then(Value::as_bool) + .unwrap_or(false) + { + continue; + } + let name = node + .get("name") + .and_then(Value::as_str) + .unwrap_or(key_name) + .to_string(); + let Some(version) = node.get("version").and_then(Value::as_str) else { + continue; + }; + let resolved_raw = node.get("resolved").and_then(Value::as_str); + // Our own vendored spec: not a registry dependency. + if resolved_raw.is_some_and(|r| parse_vendor_path(r).is_some()) { + continue; + } + let integrity = node + .get("integrity") + .and_then(Value::as_str) + .map(|i| LockIntegrity::Sri(i.to_string())) + .unwrap_or(LockIntegrity::None); + out.push(LockfileEntry::npm( + name, + version, + resolved_raw.and_then(http_url), + integrity, + )); + } + Some(out) +} diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/npm_family.rs b/crates/socket-patch-core/src/vendor/lock_inventory/npm_family.rs new file mode 100644 index 00000000..bf32443c --- /dev/null +++ b/crates/socket-patch-core/src/vendor/lock_inventory/npm_family.rs @@ -0,0 +1,210 @@ +//! npm-family routing: which lock the project's npm-family package manager +//! installs from ([`inventory_npm_lock`]), the migration-leftover sibling +//! probe, and the shared name/version guard + dedup of npm entries. + +use std::path::Path; + +use crate::patch::path_safety; +use crate::vendor::npm_common::is_safe_npm_name; +use crate::vendor::npm_flavor::{detect_npm_lock_flavor, NpmLockFlavor}; + +use super::bun::{inventory_bun, inventory_bun_binary}; +use super::npm::inventory_package_lock; +use super::pnpm::{inventory_pnpm_lock, inventory_pnpm_lock_at, inventory_rush_pnpm_locks}; +use super::yarn::{inventory_yarn_berry, inventory_yarn_classic}; +use super::{dedup_prefer_integrity, LockfileEntry, UnsupportedNpmLayout}; + +/// Inventory the project's npm-family lockfile. Routes by +/// [`detect_npm_lock_flavor`]. `Ok(None)` means there is nothing to +/// inventory (missing lockfile, dep-less locks); `Err` propagates the +/// probe's Plug'n'Play diagnosis — a layout whose packages the inventory +/// can NEVER serve — and malformed binary Bun locks, which callers must +/// not conflate with the calm no-lockfile case. Two +/// pnpm-specific refusals fall back instead of +/// yielding `None`: an unsupported `lockfileVersion` reads the root +/// `pnpm-lock.yaml` directly — unless a live sibling lock the router would +/// otherwise have chosen sits beside it (a pnpm→yarn/npm migration +/// leftover), in which case the SIBLING is inventoried instead +/// ([`inventory_live_sibling_lock`]) — and `vendor_lockfile_missing` reads +/// the pnpm <=2-era `shrinkwrap.yaml` (same v5 grammar, older filename). +/// Any remaining probe failure falls back to Rush's common lock when +/// `rush.json` is present. +pub(crate) async fn inventory_npm_lock( + project_root: &Path, +) -> Result)>, UnsupportedNpmLayout> { + let (flavor, _warnings) = match detect_npm_lock_flavor(project_root).await { + Ok(found) => found, + Err((code, detail)) => { + // The PnP loaders are a refusal, not an absence: propagate the + // diagnosis instead of discarding it. Under PnP the + // installed-tree crawl is ALSO structurally empty, so + // swallowing this here made `scan` a silent success-0 no-op in + // every mode. Every other probe error keeps the fallbacks below + // and the calm `Ok(None)`. + if matches!( + code, + "vendor_yarn_berry_unsupported" | "vendor_pnpm_pnp_unsupported" + ) { + return Err(UnsupportedNpmLayout { code, detail }); + } + // The flavor probe passes only pnpm locks the WIRING backends + // support (lockfileVersion 5.4/6.0/9.0), but inventory is + // read-only discovery — an out-of-family (pnpm <= 6-era or + // future) lock still names the resolved set, so on the probe's + // pnpm version refusal a present root lock is read directly + // rather than leaving fresh clones of such projects blind. Only + // that code: on any other refusal a root pnpm-lock.yaml is + // stale debris from a migration, and inventorying it would + // present dead resolutions as the live dependency set. + // (`vendor_lockfile_version_unsupported` also covers the + // unrecognizable-yarn.lock refusal, but the probe only sniffs + // yarn.lock when no root pnpm-lock.yaml exists, so the direct + // read is a no-op there.) + if code == "vendor_lockfile_version_unsupported" { + // The version refusal fires from the probe's pnpm step, + // which runs BEFORE its yarn/npm steps — so it says nothing + // about whether a LIVE sibling lock sits beside the refused + // pnpm lock (a pnpm→yarn/npm migration leaves exactly that + // shape behind). Prefer whichever sibling the router would + // have chosen had the pnpm lock not shadowed it; only a + // sibling-less project is a genuine old-pnpm project whose + // lock the fallback may surface. + match inventory_live_sibling_lock(project_root).await { + Some((flavor, entries)) if !entries.is_empty() => { + return Ok(Some((flavor, finalize_npm(entries)))); + } + // A sibling lock FILE exists but yields no entries + // (dep-less project, or a grammar we cannot read): the + // migration still happened, so the pnpm lock stays out — + // blind beats presenting dead resolutions as live. + Some(_) => {} + None => { + let pnpm = inventory_pnpm_lock(project_root).await.unwrap_or_default(); + if !pnpm.is_empty() { + return Ok(Some((NpmLockFlavor::Pnpm, finalize_npm(pnpm)))); + } + } + } + } + // pnpm 1/2 wrote the v5-era lock grammar under the name + // `shrinkwrap.yaml` (shrinkwrapVersion 3) — pnpm 3 renamed the + // file to pnpm-lock.yaml. The flavor probe doesn't know that + // filename, so such a project refuses as + // `vendor_lockfile_missing`; the lock still names the full + // resolved set, so read it directly rather than leaving pnpm<=2 + // projects (and their fresh clones) lockfile-blind. Gated on + // that ONE code: any other refusal means a DIFFERENT lock + // family is present (bun markers, an unsupported recognized + // lock), where a shrinkwrap.yaml is stale debris from a + // long-ago migration whose dead resolutions must not pose as + // the live dependency set. + if code == "vendor_lockfile_missing" { + let legacy = inventory_pnpm_lock_at(&project_root.join("shrinkwrap.yaml")) + .await + .unwrap_or_default(); + if !legacy.is_empty() { + return Ok(Some((NpmLockFlavor::PnpmLegacy, finalize_npm(legacy)))); + } + } + // Rush monorepos have no root package.json/lock pair; their + // single pnpm source-of-truth lives under common/config/rush/. + // The flavor probe (root-relative) can't see it, so fall back + // explicitly when the root lock is absent but rush.json is + // present. + let rush = inventory_rush_pnpm_locks(project_root).await; + return Ok((!rush.is_empty()).then(|| (NpmLockFlavor::Pnpm, finalize_npm(rush)))); + } + }; + let raw = match flavor { + NpmLockFlavor::PackageLock => inventory_package_lock(project_root).await, + // The pnpm reader is grammar-agnostic (it already served legacy + // 5.4/6.0 locks through the refusal fallback below before those + // grammars had a wiring backend), so both pnpm flavors share it. + NpmLockFlavor::Pnpm | NpmLockFlavor::PnpmLegacy => inventory_pnpm_lock(project_root).await, + NpmLockFlavor::YarnClassic => inventory_yarn_classic(project_root).await, + NpmLockFlavor::YarnBerry => inventory_yarn_berry(project_root).await, + NpmLockFlavor::Bun => { + if tokio::fs::symlink_metadata(project_root.join("bun.lock")) + .await + .is_ok() + { + inventory_bun(project_root).await + } else { + Some(inventory_bun_binary(project_root).await?) + } + } + }; + Ok(raw.map(|raw| (flavor, finalize_npm(raw)))) +} + +/// The live sibling lock a version-refused root `pnpm-lock.yaml` may be +/// shadowing, or `None` when no sibling lock file exists at all. +/// +/// [`detect_npm_lock_flavor`] cannot be re-asked (it already refused on its +/// pnpm step), so this mirrors the rest of its precedence by hand — bun, +/// then yarn, then npm — on file EXISTENCE, and returns the first present +/// sibling's inventory (possibly empty: presence alone proves the pnpm lock +/// is migration debris, so the caller must not fall back to it). Raw +/// entries — the caller applies [`finalize_npm`]. +pub(super) async fn inventory_live_sibling_lock( + root: &Path, +) -> Option<(NpmLockFlavor, Vec)> { + let exists = |name: &str| { + let p = root.join(name); + async move { tokio::fs::metadata(&p).await.is_ok() } + }; + // bun.lock — router step 2. That step runs BEFORE the pnpm sniff, so + // when the version refusal fired no bun.lock can actually be present; + // probed anyway to keep this a literal transcription of the router's + // order. The binary lock shares the same routing precedence. + if exists("bun.lock").await { + return Some(( + NpmLockFlavor::Bun, + inventory_bun(root).await.unwrap_or_default(), + )); + } + if exists("bun.lockb").await { + return Some(( + NpmLockFlavor::Bun, + inventory_bun_binary(root).await.unwrap_or_default(), + )); + } + // yarn.lock — router step 4, where classic vs berry is a content + // decision. Rather than re-deriving that head sniff, try both readers: + // each yields entries only for its own grammar (classic's `version "…"` + // fields vs berry's `resolution:` lines), so a non-empty result is the + // sniff's answer. Berry PnP needs no carve-out: a PnP marker would have + // refused at the router's step 1 with a code this fallback ignores. + if exists("yarn.lock").await { + let classic = inventory_yarn_classic(root).await.unwrap_or_default(); + if !classic.is_empty() { + return Some((NpmLockFlavor::YarnClassic, classic)); + } + return Some(( + NpmLockFlavor::YarnBerry, + inventory_yarn_berry(root).await.unwrap_or_default(), + )); + } + // npm — router step 5 (`inventory_package_lock` itself prefers the + // shrinkwrap when both exist, mirroring npm). + if exists("npm-shrinkwrap.json").await || exists("package-lock.json").await { + return Some(( + NpmLockFlavor::PackageLock, + inventory_package_lock(root).await.unwrap_or_default(), + )); + } + None +} + +/// Guard + dedup the raw npm entries: unsafe names/versions are dropped +/// fail-closed; duplicate (name, version) instances collapse to one, +/// preferring the instance that carries a verifier. +pub(super) fn finalize_npm(raw: Vec) -> Vec { + dedup_prefer_integrity( + raw.into_iter() + .filter(|e| { + is_safe_npm_name(&e.name) && path_safety::is_safe_single_segment(&e.version) + }) + .collect(), + ) +} diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/pnpm.rs b/crates/socket-patch-core/src/vendor/lock_inventory/pnpm.rs new file mode 100644 index 00000000..6ee09d4e --- /dev/null +++ b/crates/socket-patch-core/src/vendor/lock_inventory/pnpm.rs @@ -0,0 +1,171 @@ +//! `pnpm-lock.yaml` and Rush's pnpm locks: the registry view. + +use std::path::Path; + +use crate::utils::fs::read_regular_to_string; +use crate::vendor::path::parse_vendor_path; +use crate::vendor::pnpm_lock; + +use super::recover::inline_yaml_field; +use super::{http_url, LockIntegrity, LockfileEntry}; + +pub(super) async fn inventory_pnpm_lock(root: &Path) -> Option> { + inventory_pnpm_lock_at(&root.join("pnpm-lock.yaml")).await +} + +/// Inventory a specific `pnpm-lock.yaml` (path given explicitly so the Rush +/// fallback can point it at `common/config/rush/…` and subspace locks). +pub(super) async fn inventory_pnpm_lock_at(lock_path: &Path) -> Option> { + let text = read_regular_to_string(lock_path).await.ok()?; + let lines = pnpm_lock::split_lines(&text); + let (start, end) = pnpm_lock::section_bounds(&lines, "packages")?; + + let mut out = Vec::new(); + let mut i = start + 1; + while let Some(block) = pnpm_lock::next_block(&lines, i, end) { + i = block.end; + // Key grammar by lock generation: v9 `name@version`, v6 (pnpm 8) + // the same behind a leading `/`, v5.4 (pnpm 7) `/name/version` — + // names may be scoped (`@scope/name`) in all three. Peer suffixes: + // v6/v9 append `(peer@1.2.3)…` after the version; v5 appends + // `_peer@x`/`_` to the version itself. + let trimmed = match block.key.find('(') { + Some(p) => block.key[..p].trim_end(), + None => block.key.as_str(), + }; + let (base, legacy) = match trimmed.strip_prefix('/') { + Some(stripped) => (stripped, true), + None => (trimmed, false), + }; + let Some((name, version)) = split_pnpm_key(base, legacy) else { + continue; + }; + // Only plain registry versions: `file:`/`link:`/`https:`/git specs + // are not registry-resolvable. + if !version.chars().next().is_some_and(|c| c.is_ascii_digit()) { + continue; + } + let mut integrity = LockIntegrity::None; + let mut tarball: Option = None; + let entry_lines = &lines[block.header + 1..block.end]; + for (j, line) in entry_lines.iter().enumerate() { + let t = line.trim(); + let Some(rest) = t.strip_prefix("resolution:") else { + continue; + }; + if rest.trim().is_empty() { + // shrinkwrap.yaml (pnpm <=2, shrinkwrapVersion 3) nests the + // resolution as a BLOCK mapping — + // resolution: + // integrity: sha512-… + // — where every pnpm-lock.yaml generation writes the inline + // `resolution: {…}` flow map. Its fields are exactly the + // following deeper-indented lines (a shallower or blank + // line ends the mapping). + let indent = pnpm_lock::indent_of(line); + for child in &entry_lines[j + 1..] { + if child.trim().is_empty() || pnpm_lock::indent_of(child) <= indent { + break; + } + if let Some(v) = inline_yaml_field(child, "integrity:") { + integrity = LockIntegrity::Sri(v); + } + if let Some(v) = inline_yaml_field(child, "tarball:") { + tarball = Some(v); + } + } + } else { + if let Some(v) = inline_yaml_field(rest, "integrity:") { + integrity = LockIntegrity::Sri(v); + } + tarball = inline_yaml_field(rest, "tarball:"); + } + break; + } + // Our own vendored spec: not a registry dependency. + if tarball + .as_deref() + .is_some_and(|t| parse_vendor_path(t).is_some()) + { + continue; + } + out.push(LockfileEntry::npm( + name, + version, + tarball.as_deref().and_then(http_url), + integrity, + )); + } + Some(out) +} + +/// Split a peer-paren-stripped, slash-stripped pnpm packages key into +/// `(name, version)`; `None` is skipped by the caller, never guessed. +/// `legacy` marks a key that carried the v5/v6 leading `/` — only those may +/// use the v5 `name/version` grammar. What tells v5 `/@scope/name/1.2.3` +/// apart from v6 `/@scope/name@1.2.3` is the segment after the last `/`: +/// a v5 version (its `_peer`/`_hash` suffix dropped) starts with a digit +/// and never contains `@`, while a v6 scoped key's trailing segment is +/// `name@version`. v5 non-default-registry keys (`example.com/name/1.2.3`) +/// carry no leading `/` and fall through to the `@` split, where they are +/// dropped fail-closed downstream. +fn split_pnpm_key(base: &str, legacy: bool) -> Option<(&str, &str)> { + if legacy { + if let Some((name, rest)) = base.rsplit_once('/') { + let version = rest.split('_').next().unwrap_or(rest); + if !name.is_empty() + && version.chars().next().is_some_and(|c| c.is_ascii_digit()) + && !version.contains('@') + { + return Some((name, version)); + } + } + } + let at = base.rfind('@').filter(|&p| p > 0)?; + Some((&base[..at], &base[at + 1..])) +} + +/// Inventory a Rush monorepo's pnpm locks. Rush keeps a single +/// source-of-truth lock at `common/config/rush/pnpm-lock.yaml` and, when +/// subspaces are enabled, one lock per subspace under +/// `common/config/subspaces//pnpm-lock.yaml`. `rush install` copies +/// the source lock into common/temp and runs pnpm there. +/// +/// Only called (via [`inventory_npm_lock`]) when there is NO root lock but +/// `rush.json` is present, so it never shadows a plain pnpm project. The +/// subspace directory is read sorted for deterministic output. Missing +/// files/dirs are skipped fail-soft; the caller drops the whole result when +/// it comes back empty. +pub(super) async fn inventory_rush_pnpm_locks(project_root: &Path) -> Vec { + if tokio::fs::metadata(project_root.join("rush.json")) + .await + .is_err() + { + return Vec::new(); + } + let mut out = Vec::new(); + + // The single source-of-truth lock. + let common_lock = project_root.join(crate::constants::npm_family::RUSH_COMMON_LOCK_REL); + if let Some(entries) = inventory_pnpm_lock_at(&common_lock).await { + out.extend(entries); + } + + // Per-subspace locks, sorted for determinism. + let subspaces_dir = project_root.join("common/config/subspaces"); + if let Ok(mut read_dir) = tokio::fs::read_dir(&subspaces_dir).await { + let mut subspace_dirs: Vec = Vec::new(); + while let Ok(Some(entry)) = read_dir.next_entry().await { + if entry.file_type().await.is_ok_and(|t| t.is_dir()) { + subspace_dirs.push(entry.path()); + } + } + subspace_dirs.sort(); + for dir in subspace_dirs { + if let Some(entries) = inventory_pnpm_lock_at(&dir.join("pnpm-lock.yaml")).await { + out.extend(entries); + } + } + } + out +} diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/pypi.rs b/crates/socket-patch-core/src/vendor/lock_inventory/pypi.rs new file mode 100644 index 00000000..54818453 --- /dev/null +++ b/crates/socket-patch-core/src/vendor/lock_inventory/pypi.rs @@ -0,0 +1,570 @@ +//! pypi locks (uv / pylock, poetry, pdm, Pipfile, requirements): the +//! registry views. + +use std::collections::HashMap; +use std::path::Path; + +use toml_edit::{DocumentMut, Item, TableLike, Value as TomlValue}; + +use crate::crawlers::python_crawler::canonicalize_pypi_name; +use crate::patch::path_safety; +use crate::utils::fs::read_regular_to_string; + +use super::{dedup_prefer_integrity, http_url, is_hex_of_len, LockIntegrity, LockfileEntry}; + +// pypi purls and lock entries compare in PEP 503 normalized form +// (`Foo._Bar` → `foo-bar`) — see `canonicalize_pypi_name`. + +/// Inventory the pypi lock the project carries. Fetchable resolution +/// (URL + sha256 of a pure `py3-none-any` wheel) comes from `uv.lock`; +/// `poetry.lock` and `--hash`-pinned `requirements.txt` contribute +/// DISCOVERY-only entries (no recorded URL; platform-independent wheel +/// choice is not derivable offline). `pdm.lock` contributes discovery-only +/// entries. Pipfile.lock contributes entries whose integrity is its digest SET +/// (see `inventory_pipfile_lock`). +pub(super) async fn inventory_pypi_locks(project_root: &Path) -> Option> { + let mut out = Vec::new(); + let mut found = false; + let mut uv_lock = false; + if let Ok(paths) = crate::utils::python_lock::python_lock_paths(project_root) { + for path in paths { + let Ok(text) = read_regular_to_string(&project_root.join(&path)).await else { + continue; + }; + if let Some(entries) = python_lock_inventory(&text) { + found = true; + uv_lock |= path == "uv.lock"; + out.extend(entries); + } + } + } + // A PARSEABLE uv.lock stays the EXCLUSIVE project inventory (its + // precedence over poetry.lock / requirements.txt predates standalone-lock + // support). Exclusivity is keyed on parse SUCCESS, not on the file's + // presence: an unparseable uv.lock contributed nothing above, so it falls + // through to poetry.lock / requirements.txt exactly like a package-less + // poetry.lock does (`depless_poetry_lock_falls_through_to_requirements`). + // Keying on presence would hide every requirements pin behind a corrupt + // lock AND diverge from hosted, which skips an unparseable uv.lock with + // `redirect_uv_lock_unsupported` and still reads the other pins. A + // PEP 723 script lock or a PEP 751 lock is scoped to its own install, + // so it SUPPLEMENTS the project's tool lock: a stray `tool.py.lock` + // must not hide every poetry.lock / requirements.txt pin from scan's + // lockfile supplement and vendor's lookup. + if !uv_lock { + if let Some(entries) = inventory_poetry_lock(project_root).await { + found = true; + out.extend(entries); + } else if let Some(entries) = inventory_pdm_lock(project_root).await { + found = true; + out.extend(entries); + } else { + // Pipfile.lock and requirements.txt are read TOGETHER: Pipenv + // projects routinely ship both (`pipenv requirements` exports the + // same pins — deduplicated below), and a stale Pipfile.lock left in + // a requirements project must not hide the pins the project + // actually installs from (the hosted rewriter judges each file on + // its own). + if let Some(entries) = inventory_pipfile_lock(project_root).await { + found = true; + out.extend(entries); + } + if let Some(entries) = inventory_requirements_txt(project_root).await { + found = true; + out.extend(entries); + } + } + } + found.then(|| dedup_prefer_integrity(out)) +} + +fn python_archive(archive: &dyn TableLike) -> Option<(String, String)> { + let url = archive.get("url")?.as_str()?; + if !url.split(['?', '#']).next()?.ends_with("-none-any.whl") { + return None; + } + let sha = archive + .get("hash") + .and_then(Item::as_str) + .and_then(|value| value.strip_prefix("sha256:")) + .or_else(|| { + archive + .get("hashes")? + .as_table_like()? + .get("sha256")? + .as_str() + })?; + if !is_hex_of_len(sha, 64) { + return None; + } + Some((http_url(url)?, sha.to_ascii_lowercase())) +} + +fn python_package_archive(package: &dyn TableLike) -> Option<(String, String)> { + if let Some(archive) = package + .get("archive") + .and_then(Item::as_table_like) + .and_then(python_archive) + { + return Some(archive); + } + if let Some(wheels) = package.get("wheels").and_then(Item::as_array) { + for wheel in wheels.iter().filter_map(TomlValue::as_inline_table) { + if let Some(archive) = python_archive(wheel) { + return Some(archive); + } + } + } + if let Some(wheels) = package.get("wheel").and_then(Item::as_array_of_tables) { + for wheel in wheels.iter() { + if let Some(archive) = python_archive(wheel) { + return Some(archive); + } + } + } + None +} + +pub(super) fn python_lock_inventory(text: &str) -> Option> { + let document: DocumentMut = text.parse().ok()?; + let pep751 = document.get("lock-version").is_some(); + let collection = if pep751 { + if document.get("lock-version").and_then(Item::as_str) != Some("1.0") { + return None; + } + "packages" + } else { + if document.get("version").and_then(Item::as_integer) != Some(1) { + return None; + } + if document.contains_key("distribution") { + "distribution" + } else { + "package" + } + }; + let mut out = Vec::new(); + let packages = document.get(collection)?.as_array_of_tables()?; + for package in packages.iter() { + let Some(name) = package + .get("name") + .and_then(Item::as_str) + .map(canonicalize_pypi_name) + else { + continue; + }; + let Some(version) = package.get("version").and_then(Item::as_str) else { + continue; + }; + if !path_safety::is_safe_single_segment(&name) + || !path_safety::is_safe_single_segment(version) + { + continue; + } + let remote = if pep751 { + !package.contains_key("vcs") + && !package.contains_key("directory") + && !package + .get("archive") + .and_then(Item::as_table_like) + .is_some_and(|archive| archive.contains_key("path")) + } else { + package.get("source").is_some_and(|source| { + source.as_str().is_some_and(|value| { + value.starts_with("registry+") || value.starts_with("direct+") + }) || source.as_table_like().is_some_and(|table| { + table.contains_key("registry") || table.contains_key("url") + }) + }) + }; + if !remote { + continue; + } + let (resolved, integrity) = match python_package_archive(package) { + Some((url, sha)) => (Some(url), LockIntegrity::Sha256Hex(sha)), + None => (None, LockIntegrity::None), + }; + out.push(LockfileEntry { + ecosystem: "pypi", + purl: format!("pkg:pypi/{name}@{version}"), + name, + version: version.to_string(), + resolved, + integrity, + }); + } + Some(out) +} + +/// The sha256 of each package's pure-Python (`-none-any.whl`) wheel as the +/// lock records it — `files = [...]` inside `[[package]]` (lock 2.x) or the +/// `[metadata.files]` entry (lock 1.0/1.1). Poetry 0.12's `[metadata.hashes]` +/// lists bare digests without filenames, so no wheel can be chosen there. +/// Keyed by canonical name. An unparseable lock contributes nothing (the +/// line-based name/version walk below still runs). +fn poetry_pure_wheel_hashes(text: &str) -> HashMap { + fn pure_wheel_sha(files: &Item) -> Option { + let files = files.as_array()?; + files + .iter() + .filter_map(TomlValue::as_inline_table) + .find_map(|entry| { + let file = entry.get("file")?.as_str()?; + if !file.ends_with("-none-any.whl") { + return None; + } + let sha = entry.get("hash")?.as_str()?.strip_prefix("sha256:")?; + is_hex_of_len(sha, 64).then(|| sha.to_ascii_lowercase()) + }) + } + let mut out = HashMap::new(); + let Ok(document) = text.parse::() else { + return out; + }; + if let Some(packages) = document.get("package").and_then(Item::as_array_of_tables) { + for package in packages.iter() { + let Some(name) = package.get("name").and_then(Item::as_str) else { + continue; + }; + if let Some(sha) = package.get("files").and_then(pure_wheel_sha) { + out.entry(canonicalize_pypi_name(name)).or_insert(sha); + } + } + } + if let Some(files) = document + .get("metadata") + .and_then(|m| m.get("files")) + .and_then(Item::as_table_like) + { + for (name, entry) in files.iter() { + if let Some(sha) = pure_wheel_sha(entry) { + out.entry(canonicalize_pypi_name(name)).or_insert(sha); + } + } + } + out +} + +/// poetry.lock: `[[package]]` blocks with `name`/`version`. The lock records +/// file hashes but no URLs and no platform choice, so an entry carries the +/// pure-Python wheel's sha256 when the lock lists one (the pypi fetcher then +/// resolves the matching file through PyPI's JSON API) and stays +/// discovery-only otherwise. +async fn inventory_poetry_lock(project_root: &Path) -> Option> { + let text = read_regular_to_string(&project_root.join("poetry.lock")) + .await + .ok()?; + let hashes = poetry_pure_wheel_hashes(&text); + let mut out = Vec::new(); + let mut in_package = false; + let mut name: Option = None; + for line in text.lines() { + let t = line.trim(); + if t == "[[package]]" { + in_package = true; + name = None; + continue; + } + if t.starts_with('[') && t != "[[package]]" { + in_package = false; + continue; + } + if !in_package { + continue; + } + if let Some(v) = t.strip_prefix("name = ") { + name = Some(canonicalize_pypi_name(v.trim_matches('"'))); + } else if let Some(v) = t.strip_prefix("version = ") { + if let Some(n) = name.take() { + let v = v.trim_matches('"').to_string(); + if path_safety::is_safe_single_segment(&n) + && path_safety::is_safe_single_segment(&v) + { + let integrity = hashes + .get(&n) + .map(|sha| LockIntegrity::Sha256Hex(sha.clone())) + .unwrap_or(LockIntegrity::None); + out.push(LockfileEntry { + ecosystem: "pypi", + purl: format!("pkg:pypi/{n}@{v}"), + name: n, + version: v, + resolved: None, + integrity, + }); + } + } + } + } + if out.is_empty() { + return None; + } + Some(dedup_prefer_integrity(out)) +} + +/// `https://pypi.org/simple`, `https://pypi.python.org/simple`, +/// `https://files.pythonhosted.org/…`: the public index PyPI's JSON API +/// describes. +pub(super) fn is_public_pypi_url(url: &str) -> bool { + let host = url + .split("://") + .nth(1) + .and_then(|rest| rest.split(['/', '?', '#']).next()) + .unwrap_or("") + .to_ascii_lowercase(); + let host = host.rsplit('@').next().unwrap_or(&host); + matches!( + host, + "pypi.org" | "www.pypi.org" | "pypi.python.org" | "files.pythonhosted.org" + ) +} + +/// The `(canonical name, version)` a Socket-written Pipfile.lock reference +/// stands for: a hosted URL +/// `https:///patch/pypi/////[#…]` +/// (coordinates from the path) or a vendored path +/// `[./].socket/vendor/pypi//--…whl` (coordinates from +/// the wheel filename). `None` for a user's own file/path reference. +pub(super) fn socket_reference_coords(reference: &str) -> Option<(String, String)> { + let reference = reference.split('#').next().unwrap_or(reference); + if let Some(rest) = reference.strip_prefix("https://") { + let path = rest.split_once('/')?.1; + let parts: Vec<&str> = path.split('/').collect(); + if parts.len() == 7 + && parts[0] == "patch" + && parts[1] == "pypi" + && parts[6].ends_with(".whl") + { + return Some((canonicalize_pypi_name(parts[2]), parts[3].to_string())); + } + return None; + } + let rel = reference.trim_start_matches("./"); + let rest = rel.strip_prefix(".socket/vendor/pypi/")?; + let (_uuid, wheel) = rest.split_once('/')?; + let stem = wheel.strip_suffix(".whl")?; + let mut fields = stem.split('-'); + let name = fields.next()?; + let version = fields.next()?; + if name.is_empty() || version.is_empty() || !version.starts_with(|c: char| c.is_ascii_digit()) { + return None; + } + Some((canonicalize_pypi_name(name), version.to_string())) +} + +/// Pipfile.lock (pipfile-spec 6): every category other than `_meta` holds +/// `name: {"version": "==X", "hashes": ["sha256:", …], …}` entries. +/// Registry pins (`==` version) become entries whose integrity is the SET of +/// recorded digests — Pipenv lists every release file's hash without +/// filenames, so the pure-Python wheel is selected by digest at fetch time +/// ([`LockIntegrity::Sha256AnyOf`]). VCS / path / file / editable sources and +/// range pins are skipped (nothing registry-shaped to vendor over), as are +/// our own already-wired file references. An unparseable lock contributes +/// nothing, so the caller falls through to requirements.txt like an absent +/// lock would. +async fn inventory_pipfile_lock(project_root: &Path) -> Option> { + let text = read_regular_to_string(&project_root.join("Pipfile.lock")) + .await + .ok()?; + let value: serde_json::Value = + serde_json::from_str(text.trim_start_matches('\u{feff}')).ok()?; + let root = value.as_object()?; + // Digests are only fetchable through PyPI's JSON API when the lock + // resolves from PyPI: a lock whose `_meta.sources` name only private + // indexes must not leak its package names to pypi.org (and would not find + // its files there anyway) — its entries stay discovery-only. + let public_index = root + .get("_meta") + .and_then(|m| m.get("sources")) + .and_then(serde_json::Value::as_array) + .is_none_or(|sources| { + sources.is_empty() + || sources.iter().any(|source| { + source + .get("url") + .and_then(serde_json::Value::as_str) + .is_some_and(is_public_pypi_url) + }) + }); + let mut out = Vec::new(); + for (section, entries) in root { + if section == "_meta" { + continue; + } + let Some(entries) = entries.as_object() else { + continue; + }; + for (name, entry) in entries { + let Some(entry) = entry.as_object() else { + continue; + }; + // Socket's own references (a hosted `file` URL, a vendored + // `./.socket/vendor/pypi//` path) stay DISCOVERABLE + // as the package they replace, so a re-scan of an already + // redirected lock-only checkout still lists (and re-confirms / + // attests) it instead of reporting zero packages. + if let Some(reference) = entry + .get("file") + .or_else(|| entry.get("path")) + .and_then(serde_json::Value::as_str) + { + if let Some((n, v)) = socket_reference_coords(reference) { + if path_safety::is_safe_single_segment(&n) + && path_safety::is_safe_single_segment(&v) + { + out.push(LockfileEntry { + ecosystem: "pypi", + purl: format!("pkg:pypi/{n}@{v}"), + name: n, + version: v, + resolved: None, + integrity: LockIntegrity::None, + }); + } + } + continue; + } + if ["git", "hg", "svn", "bzr", "editable"] + .iter() + .any(|key| entry.contains_key(*key)) + { + continue; + } + let Some(version) = entry + .get("version") + .and_then(serde_json::Value::as_str) + .and_then(|v| v.strip_prefix("==")) + .map(str::trim) + .filter(|v| !v.is_empty()) + else { + continue; + }; + let n = canonicalize_pypi_name(name); + if !path_safety::is_safe_single_segment(&n) + || !path_safety::is_safe_single_segment(version) + { + continue; + } + let hashes: Vec = entry + .get("hashes") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + .filter_map(serde_json::Value::as_str) + .filter_map(|h| h.strip_prefix("sha256:")) + .filter(|h| is_hex_of_len(h, 64)) + .map(|h| h.to_ascii_lowercase()) + .collect(); + let integrity = if hashes.is_empty() || !public_index { + LockIntegrity::None + } else { + LockIntegrity::Sha256AnyOf(hashes) + }; + out.push(LockfileEntry { + ecosystem: "pypi", + purl: format!("pkg:pypi/{n}@{version}"), + name: n, + version: version.to_string(), + resolved: None, + integrity, + }); + } + } + Some(out) +} + +/// `pdm.lock`: `[[package]]` blocks with `name`/`version`, DISCOVERY-only. This +/// surfaces the project's PyPI coordinates so a hosted lock-only checkout (no +/// installed package) can be redirected — the hosted rewrite pins the API +/// grant's URL and does not need a lock-derived hash. It stays discovery-only +/// (`LockIntegrity::None`) so vendored keeps refusing a lock-only checkout +/// (`vendor_fetch_unverifiable`): vendoring rebuilds the wheel from the +/// INSTALLED package, and PDM installs into a `__pypackages__` tree the crawler +/// does not probe, so a lock-only vendored path would not survive a re-scan. +async fn inventory_pdm_lock(project_root: &Path) -> Option> { + let text = read_regular_to_string(&project_root.join("pdm.lock")) + .await + .ok()?; + let mut out = Vec::new(); + let mut in_package = false; + let mut name: Option = None; + for line in text.lines() { + let t = line.trim(); + if t == "[[package]]" { + in_package = true; + name = None; + continue; + } + if t.starts_with('[') && t != "[[package]]" { + in_package = false; + continue; + } + if !in_package { + continue; + } + if let Some(v) = t.strip_prefix("name = ") { + name = Some(canonicalize_pypi_name(v.trim_matches('"'))); + } else if let Some(v) = t.strip_prefix("version = ") { + if let Some(n) = name.take() { + let v = v.trim_matches('"').to_string(); + if path_safety::is_safe_single_segment(&n) + && path_safety::is_safe_single_segment(&v) + { + out.push(LockfileEntry { + ecosystem: "pypi", + purl: format!("pkg:pypi/{n}@{v}"), + name: n, + version: v, + resolved: None, + integrity: LockIntegrity::None, + }); + } + } + } + } + if out.is_empty() { + return None; + } + Some(dedup_prefer_integrity(out)) +} + +/// requirements.txt with exact `==` pins — discovery only. +async fn inventory_requirements_txt(project_root: &Path) -> Option> { + let text = read_regular_to_string(&project_root.join("requirements.txt")) + .await + .ok()?; + let mut out = Vec::new(); + for line in text.lines() { + let t = line.trim(); + if t.is_empty() || t.starts_with('#') || t.starts_with('-') { + continue; + } + // `name==version` (strip extras, env markers, hash continuations). + let spec = t.split(';').next().unwrap_or(t).trim(); + let spec = spec.split_whitespace().next().unwrap_or(spec); + let Some((raw_name, version)) = spec.split_once("==") else { + continue; + }; + let name = canonicalize_pypi_name(raw_name.split('[').next().unwrap_or(raw_name).trim()); + let version = version.trim().to_string(); + if name.is_empty() + || !path_safety::is_safe_single_segment(&name) + || !path_safety::is_safe_single_segment(&version) + || !version.chars().next().is_some_and(|c| c.is_ascii_digit()) + { + continue; + } + out.push(LockfileEntry { + ecosystem: "pypi", + purl: format!("pkg:pypi/{name}@{version}"), + name, + version, + resolved: None, + integrity: LockIntegrity::None, + }); + } + if out.is_empty() { + return None; + } + Some(dedup_prefer_integrity(out)) +} diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/python_lock_union_tests.rs b/crates/socket-patch-core/src/vendor/lock_inventory/python_lock_union_tests.rs new file mode 100644 index 00000000..1c2fab93 --- /dev/null +++ b/crates/socket-patch-core/src/vendor/lock_inventory/python_lock_union_tests.rs @@ -0,0 +1,164 @@ +use super::*; + +const WHEEL_SHA: &str = "abababababababababababababababababababababababababababababababab"; + +fn uv_style_lock(name: &str, version: &str) -> String { + format!( + "version = 1\n\n[[package]]\nname = \"{name}\"\nversion = \"{version}\"\nsource = {{ registry = \"https://pypi.org/simple\" }}\nwheels = [{{ url = \"https://files.pythonhosted.org/{name}-{version}-py3-none-any.whl\", hash = \"sha256:{WHEEL_SHA}\" }}]\n" + ) +} + +fn names(entries: &[LockfileEntry]) -> Vec<(String, String)> { + let mut pairs: Vec<_> = entries + .iter() + .map(|entry| (entry.name.clone(), entry.version.clone())) + .collect(); + pairs.sort(); + pairs +} + +/// A script lock is scoped to its script: it must ADD to the project's +/// requirements.txt / poetry.lock pins, not replace them (the base only +/// ever let uv.lock short-circuit the fallbacks). +#[tokio::test] +async fn script_lock_supplements_project_pins() { + let tmp = tempfile::tempdir().unwrap(); + tokio::fs::write(tmp.path().join("requirements.txt"), "requests==2.31.0\n") + .await + .unwrap(); + tokio::fs::write( + tmp.path().join("tool.py.lock"), + uv_style_lock("flask", "3.0.0"), + ) + .await + .unwrap(); + let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); + assert_eq!( + names(&entries), + vec![ + ("flask".to_string(), "3.0.0".to_string()), + ("requests".to_string(), "2.31.0".to_string()), + ] + ); +} + +/// uv.lock keeps its exclusive precedence over the fallbacks. +#[tokio::test] +async fn uv_lock_still_hides_requirements_pins() { + let tmp = tempfile::tempdir().unwrap(); + tokio::fs::write(tmp.path().join("requirements.txt"), "requests==2.31.0\n") + .await + .unwrap(); + tokio::fs::write(tmp.path().join("uv.lock"), uv_style_lock("flask", "3.0.0")) + .await + .unwrap(); + let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); + assert_eq!( + names(&entries), + vec![("flask".to_string(), "3.0.0".to_string())] + ); +} + +/// Without a uv.lock, poetry.lock is the project's tool lock: it hides +/// requirements.txt (the base's poetry → requirements ordering) while a +/// script lock still UNIONS with it — the standalone lock supplements +/// whichever tool lock the project has, never just uv.lock. +#[tokio::test] +async fn poetry_lock_unions_with_script_lock_and_hides_requirements() { + let tmp = tempfile::tempdir().unwrap(); + tokio::fs::write( + tmp.path().join("poetry.lock"), + "[[package]]\nname = \"requests\"\nversion = \"2.31.0\"\n\n[metadata]\nlock-version = \"2.0\"\n", + ) + .await + .unwrap(); + tokio::fs::write( + tmp.path().join("tool.py.lock"), + uv_style_lock("flask", "3.0.0"), + ) + .await + .unwrap(); + tokio::fs::write(tmp.path().join("requirements.txt"), "click==8.1.7\n") + .await + .unwrap(); + let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); + assert_eq!( + names(&entries), + vec![ + ("flask".to_string(), "3.0.0".to_string()), + ("requests".to_string(), "2.31.0".to_string()), + ] + ); +} + +/// Exclusivity is keyed on a uv.lock that PARSES, not on the file's +/// presence: garbage TOML contributes nothing and must not hide the +/// requirements.txt pins behind it (hosted skips the same file with +/// `redirect_uv_lock_unsupported`, so the inventories agree). +#[tokio::test] +async fn unparseable_uv_lock_falls_through_to_requirements() { + let tmp = tempfile::tempdir().unwrap(); + tokio::fs::write( + tmp.path().join("uv.lock"), + "version = 1\n[[package]\nname = \"flask\"\n= broken\n", + ) + .await + .unwrap(); + tokio::fs::write(tmp.path().join("requirements.txt"), "requests==2.31.0\n") + .await + .unwrap(); + let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); + assert_eq!( + names(&entries), + vec![("requests".to_string(), "2.31.0".to_string())] + ); +} + +/// Ledger recovery must match a purl spelled the project's way +/// (`PyYAML`) against the PEP 503 names the inventory records. +#[tokio::test] +async fn python_document_recovery_canonicalizes_the_purl_name() { + let tmp = tempfile::tempdir().unwrap(); + let lock = format!( + "lock-version = '1.0'\n[[packages]]\nname = 'pyyaml'\nversion = '6.0.1'\narchive = {{ url = 'https://pypi.org/PyYAML-6.0.1-py3-none-any.whl', hashes = {{ sha256 = '{WHEEL_SHA}' }} }}\n" + ); + let entry = crate::vendor::state::VendorEntry { + ecosystem: "pypi".into(), + base_purl: "pkg:pypi/PyYAML@6.0.1".into(), + uuid: "11111111-1111-4111-8111-111111111111".into(), + artifact: crate::vendor::state::VendorArtifact { + path: ".socket/vendor/pypi/11111111-1111-4111-8111-111111111111/PyYAML-6.0.1-py3-none-any.whl".into(), + sha256: String::new(), + size: None, + platform_locked: None, + file_inventory: None, + }, + wiring: vec![crate::vendor::state::WiringRecord { + file: "pylock.toml".into(), + kind: "python_lock_document".into(), + action: crate::vendor::state::WiringAction::Rewritten, + key: Some("pyyaml".into()), + original: Some(serde_json::Value::String(lock)), + new: None, + }], + lock: None, + took_over_go_patches: false, + detached: false, + record: None, + flavor: Some("python-lock".into()), + uv: None, + pnpm: None, + poetry: None, + pdm: None, + pipenv: None, + }; + let recovered = recover_lock_entry(tmp.path(), &entry).await.unwrap(); + assert_eq!( + recovered.resolved.as_deref(), + Some("https://pypi.org/PyYAML-6.0.1-py3-none-any.whl") + ); + assert_eq!( + recovered.integrity, + LockIntegrity::Sha256Hex(WHEEL_SHA.into()) + ); +} diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/recover.rs b/crates/socket-patch-core/src/vendor/lock_inventory/recover.rs new file mode 100644 index 00000000..a0b3e949 --- /dev/null +++ b/crates/socket-patch-core/src/vendor/lock_inventory/recover.rs @@ -0,0 +1,466 @@ +//! Registry-fragment recovery from the vendor ledger +//! ([`recover_lock_entry`]). + +use std::path::Path; + +use serde_json::Value; + +use crate::crawlers::python_crawler::canonicalize_pypi_name; +use crate::utils::purl::percent_decode_purl_component; + +use super::gem::gem_remotes; +use super::pypi::python_lock_inventory; +use super::{http_url, is_hex_of_len, LockIntegrity, LockfileEntry}; + +/// Recover the PRE-VENDOR registry resolution of a vendored package from its +/// ledger entry's wiring `original` fragments (and `entry.lock` for cargo), +/// as a fetchable [`LockfileEntry`]. +/// +/// This is the rebuild path for artifacts that are referenced by the rewired +/// lockfile but missing on disk: the live lockfile no longer carries the +/// registry resolution (it points at `.socket/vendor/...`), but `--revert`'s +/// restore data does. golang is deliberately absent — go.sum is never +/// rewired, so the standard [`inventory_project`]/[`lookup`] path covers it. +/// +/// SECURITY: state.json is committed and tamper-able. Recovered URLs go +/// through the same http(s)-only gate as inventoried ones, recovered hashes +/// are shape-validated here and verified against the fetched bytes +/// fail-closed by the fetch layer — a poisoned fragment can at worst make +/// the fetch fail, never land unverified content. +pub async fn recover_lock_entry( + project_root: &Path, + entry: &crate::vendor::state::VendorEntry, +) -> Result { + let (name, version) = parse_base_purl_coords(&entry.base_purl) + .ok_or_else(|| format!("unparseable base purl `{}`", entry.base_purl))?; + + match entry.ecosystem.as_str() { + "npm" => recover_npm_fragment(entry, &name, &version), + "cargo" => { + let checksum = entry + .lock + .as_ref() + .and_then(|l| l.checksum.clone()) + .filter(|c| is_hex_of_len(c, 64)) + .ok_or_else(|| { + "the ledger records no pre-vendor Cargo.lock checksum".to_string() + })?; + Ok(LockfileEntry { + ecosystem: "cargo", + purl: format!("pkg:cargo/{name}@{version}"), + name, + version, + resolved: None, + integrity: LockIntegrity::Sha256Hex(checksum.to_ascii_lowercase()), + }) + } + "composer" => { + let original = wiring_original(entry, &["composer_lock_package"]) + .ok_or_else(|| "no pre-vendor composer.lock fragment recorded".to_string())?; + let dist = original + .get("dist") + .ok_or_else(|| "the pre-vendor composer.lock fragment has no dist".to_string())?; + let url = dist + .get("url") + .and_then(serde_json::Value::as_str) + .and_then(http_url) + .ok_or_else(|| "the pre-vendor dist has no http(s) url".to_string())?; + let shasum = dist + .get("shasum") + .and_then(serde_json::Value::as_str) + .filter(|s| is_hex_of_len(s, 40)) + .ok_or_else(|| { + "the pre-vendor dist records no shasum; refusing an unverifiable fetch" + .to_string() + })?; + Ok(LockfileEntry { + ecosystem: "composer", + purl: format!("pkg:composer/{name}@{version}"), + name, + version, + resolved: Some(url), + integrity: LockIntegrity::Sha1Hex(shasum.to_ascii_lowercase()), + }) + } + "gem" => { + let line = wiring_original(entry, &["gemfile_lock_checksum"]) + .and_then(|v| v.as_str().map(str::to_string)) + .ok_or_else(|| "no pre-vendor Gemfile.lock checksum recorded".to_string())?; + let sha = line + .split("sha256=") + .nth(1) + .map(|rest| { + rest.trim_end_matches(',') + .trim() + .chars() + .take_while(|c| c.is_ascii_hexdigit()) + .collect::() + }) + .filter(|s| is_hex_of_len(s, 64)) + .ok_or_else(|| { + "the pre-vendor checksum line has no sha256; refusing an unverifiable fetch" + .to_string() + })?; + let base = match gem_remotes(project_root).await.as_slice() { + [] => "https://rubygems.org".to_string(), + [one] => http_url(one).ok_or_else(|| { + // A lone non-http remote (file:// gem repo): the registry + // conventions cannot reproduce its bytes, and defaulting + // to rubygems.org would leak the gem name off-site. + format!( + "the Gemfile.lock's GEM remote ({one}) is not an http(s) registry; \ + refusing to fetch from a guessed remote" + ) + })?, + several => { + // The vendored spec's own GEM section is gone (it moved + // into the PATH section), so with several sources its + // origin is genuinely ambiguous — a guessed remote + // would 404 at best and leak a private gem name to the + // public registry at worst. + return Err(format!( + "Gemfile.lock lists multiple GEM sources ({}); the vendored gem's \ + pre-vendor source is ambiguous — refusing to fetch from a guessed \ + remote", + several.join(", ") + )); + } + }; + Ok(LockfileEntry { + ecosystem: "gem", + purl: format!("pkg:gem/{name}@{version}"), + resolved: http_url(&format!("{base}/downloads/{name}-{version}.gem")), + name, + version, + integrity: LockIntegrity::Sha256Hex(sha.to_ascii_lowercase()), + }) + } + "pypi" => { + if entry.artifact.platform_locked == Some(true) { + return Err( + "the vendored wheel is platform-locked (compiled); it cannot be rebuilt from the registry" + .to_string(), + ); + } + // The inventory canonicalizes names (PEP 503); the purl may carry + // the project's own spelling (`PyYAML`, `typing_extensions`) — + // compare in normalized form like `lookup` does. + let canonical_name = canonicalize_pypi_name(&name); + for wiring in entry + .wiring + .iter() + .filter(|wiring| wiring.kind == "python_lock_document") + { + if let Some(text) = wiring.original.as_ref().and_then(Value::as_str) { + if let Some(entries) = python_lock_inventory(text) { + if let Some(resolution) = entries.into_iter().find(|candidate| { + candidate.name == canonical_name + && candidate.version == version + && candidate.resolved.is_some() + && candidate.integrity != LockIntegrity::None + }) { + return Ok(resolution); + } + } + } + } + if entry + .wiring + .iter() + .any(|wiring| wiring.kind == "python_lock_document") + { + return Err("the pre-vendor Python lock has no hash-pinned pure wheel for this package; reinstall it before repair".to_string()); + } + // Every pypi package manager records the pre-vendor resolution under + // its own wiring kind — uv writes `uv_lock_package`, pdm + // `pdm_lock_package`, poetry `poetry_lock_package`, pipenv + // `pipenv_lock_entry`, bare pip `requirements_line`. Accept them all + // so recovery is not blind to non-uv projects. + let fragment = wiring_original( + entry, + &[ + "uv_lock_package", + "python_lock_document", + "pdm_lock_package", + "poetry_lock_package", + "pipenv_lock_entry", + "requirements_line", + ], + ) + .ok_or_else(|| "no pre-vendor pypi lock fragment recorded".to_string())?; + // Only uv.lock and pdm's `static_urls` locks inline the wheel's + // registry URL (`url = "…", hash = "sha256:…"`), which is all a + // registry rebuild can fetch from. Default pdm/poetry (`file = …`), + // pipenv (`hashes` only) and pip (`--hash=`) record the hash but no + // fetchable URL — an honest, actionable message, not the false + // "not installed / no recoverable fragment". + const NO_URL: &str = "the pre-vendor pypi lock fragment records the wheel hash but \ + no fetchable registry URL (only uv.lock and pdm `static_urls` locks carry wheel \ + URLs); reinstall the package so repair can rebuild from the installed copy"; + // Pipenv's pre-vendor entry is a JSON object carrying every + // release file's sha256 (`"hashes": ["sha256:…", …]`): fetchable + // by digest through PyPI's JSON API like a fresh Pipfile.lock + // inventory entry, so a lock-only checkout of an already-vendored + // project re-scans green instead of `package_not_installed`. + if let Some(object) = fragment.as_object() { + let digests: Vec = object + .get("hashes") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + .filter_map(serde_json::Value::as_str) + .filter_map(|h| h.strip_prefix("sha256:")) + .filter(|h| is_hex_of_len(h, 64)) + .map(|h| h.to_ascii_lowercase()) + .collect(); + if digests.is_empty() { + return Err( + "the pre-vendor Pipfile.lock entry records no sha256 digests; reinstall the \ + package so repair can rebuild from the installed copy" + .to_string(), + ); + } + return Ok(LockfileEntry { + ecosystem: "pypi", + purl: format!("pkg:pypi/{name}@{version}"), + name, + version, + resolved: None, + integrity: LockIntegrity::Sha256AnyOf(digests), + }); + } + let unit = fragment.as_str().ok_or_else(|| NO_URL.to_string())?; + let (url, sha) = pure_wheel_from_uv_unit(unit).ok_or_else(|| NO_URL.to_string())?; + Ok(LockfileEntry { + ecosystem: "pypi", + purl: format!("pkg:pypi/{name}@{version}"), + name, + version, + resolved: Some(url), + integrity: LockIntegrity::Sha256Hex(sha), + }) + } + other => Err(format!( + "no ledger-based registry recovery for ecosystem `{other}`" + )), + } +} + +/// `pkg:/@` → (name, version). The name may itself +/// contain `/` (npm scopes, go modules); the version is after the LAST `@`. +/// Components percent-decode (`%40scope` → `@scope`): the ledger stores +/// `base_purl` verbatim as the manifest spelled it, while [`LockfileEntry`] +/// carries literal coordinates — the name feeds the registry URL and the +/// berry cache-zip recipe. +fn parse_base_purl_coords(base_purl: &str) -> Option<(String, String)> { + let rest = base_purl.strip_prefix("pkg:")?; + let (_, name_ver) = rest.split_once('/')?; + let (name, version) = name_ver.rsplit_once('@')?; + if name.is_empty() || version.is_empty() { + return None; + } + let name = name + .split('/') + .map(percent_decode_purl_component) + .collect::>() + .join("/"); + let version = percent_decode_purl_component(version).into_owned(); + Some((name, version)) +} + +/// First wiring record of one of `kinds` carrying an `original` payload. +fn wiring_original<'a>( + entry: &'a crate::vendor::state::VendorEntry, + kinds: &[&str], +) -> Option<&'a serde_json::Value> { + entry + .wiring + .iter() + .find(|r| kinds.contains(&r.kind.as_str()) && r.original.is_some()) + .and_then(|r| r.original.as_ref()) +} + +/// Per-flavor npm recovery: the wiring kinds disambiguate the lock flavor, +/// each fragment yields (resolved?, integrity). +fn recover_npm_fragment( + entry: &crate::vendor::state::VendorEntry, + name: &str, + version: &str, +) -> Result { + let mk = |resolved: Option, integrity: LockIntegrity| LockfileEntry { + ecosystem: "npm", + purl: format!("pkg:npm/{name}@{version}"), + name: name.to_string(), + version: version.to_string(), + resolved, + integrity, + }; + + // package-lock / shrinkwrap: the original is the full lock entry object. + if let Some(obj) = wiring_original(entry, &["npm_lock_entry", "npm_lock_legacy_entry"]) { + let resolved = obj + .get("resolved") + .and_then(serde_json::Value::as_str) + .and_then(http_url); + if let Some(sri) = obj + .get("integrity") + .and_then(serde_json::Value::as_str) + .filter(|s| looks_like_sri(s)) + { + return Ok(mk(resolved, LockIntegrity::Sri(sri.to_string()))); + } + } + // pnpm: the original is the packages block's lines; pull + // `resolution: {integrity: …, tarball: …}`. + if let Some(lines) = wiring_original(entry, &["pnpm_lock_package"]).and_then(lines_of) { + let mut sri = None; + let mut tarball = None; + for line in &lines { + if let Some(v) = inline_yaml_field(line, "integrity:") { + sri = sri.or(Some(v)); + } + if let Some(v) = inline_yaml_field(line, "tarball:") { + tarball = tarball.or(http_url(&v)); + } + } + if let Some(sri) = sri.filter(|s| looks_like_sri(s)) { + return Ok(mk(tarball, LockIntegrity::Sri(sri))); + } + } + // yarn classic: block lines carry `integrity ` (preferred) and/or + // `resolved "#"`. + if let Some(lines) = wiring_original(entry, &["yarn_lock_block"]).and_then(lines_of) { + let mut url = None; + let mut sha1 = None; + let mut sri = None; + for line in &lines { + let t = line.trim(); + if let Some(rest) = t.strip_prefix("integrity ") { + let v = rest.trim().trim_matches('"'); + if looks_like_sri(v) { + sri = Some(v.to_string()); + } + } + if let Some(rest) = t.strip_prefix("resolved ") { + let v = rest.trim().trim_matches('"'); + let (u, frag) = v.split_once('#').unwrap_or((v, "")); + url = http_url(u); + if is_hex_of_len(frag, 40) { + sha1 = Some(frag.to_ascii_lowercase()); + } + } + } + if let Some(sri) = sri { + return Ok(mk(url, LockIntegrity::Sri(sri))); + } + if let Some(sha1) = sha1 { + return Ok(mk(url, LockIntegrity::Sha1Hex(sha1))); + } + } + // yarn berry: block lines carry `checksum: /`. + if let Some(lines) = wiring_original(entry, &["yarn_berry_lock_entry"]).and_then(lines_of) { + for line in &lines { + if let Some(v) = inline_yaml_field(line, "checksum:") { + if v.split_once('/') + .is_some_and(|(k, b)| !k.is_empty() && !b.is_empty()) + { + return Ok(mk(None, LockIntegrity::BerryChecksum(v))); + } + } + } + } + // Binary Bun records carry semantic registry metadata alongside the + // opaque fields needed for lossless restoration. Recovery must verify + // the snapshot's coordinates before trusting its download and digest. + for wiring in &entry.wiring { + if wiring.kind != "bun_lockb_package" { + continue; + } + let Some(original) = wiring.original.as_ref() else { + continue; + }; + if original.get("name").and_then(Value::as_str) != Some(name) + || original.get("version").and_then(Value::as_str) != Some(version) + { + continue; + } + if let Some(sri) = original + .get("integrity") + .and_then(Value::as_str) + .filter(|s| looks_like_sri(s)) + { + let resolved = original + .get("resolution") + .and_then(Value::as_str) + .and_then(http_url); + return Ok(mk(resolved, LockIntegrity::Sri(sri.to_string()))); + } + } + // bun: the original is the raw tuple line; the integrity is its last + // quoted SRI string. + if let Some(line) = + wiring_original(entry, &["bun_lock_package"]).and_then(|v| v.as_str().map(str::to_string)) + { + if let Some(sri) = line + .split('"') + .rev() + .find(|tok| looks_like_sri(tok)) + .map(str::to_string) + { + return Ok(mk(None, LockIntegrity::Sri(sri))); + } + } + Err("no pre-vendor npm registry fragment with a verifiable integrity recorded".to_string()) +} + +pub(super) fn looks_like_sri(s: &str) -> bool { + ["sha512-", "sha384-", "sha256-", "sha1-"] + .iter() + .any(|p| s.starts_with(p) && s.len() > p.len()) +} + +/// A wiring `original` recorded as an array of text lines. +fn lines_of(v: &serde_json::Value) -> Option> { + v.as_array().map(|arr| { + arr.iter() + .filter_map(|l| l.as_str().map(str::to_string)) + .collect() + }) +} + +/// `… field: value` (optionally inside an inline `{…}` map) → value, with +/// trailing `,`/`}` and quotes stripped. +pub(super) fn inline_yaml_field(line: &str, field: &str) -> Option { + let idx = line.find(field)?; + let rest = &line[idx + field.len()..]; + let end = rest.find([',', '}']).unwrap_or(rest.len()); + let v = rest[..end].trim().trim_matches(['\'', '"']).to_string(); + (!v.is_empty()).then_some(v) +} + +/// First `{ url = "…", hash = "sha256:…" }` wheel in a uv.lock `[[package]]` +/// unit whose filename is a PURE wheel (`-none-any.whl`). +pub(super) fn pure_wheel_from_uv_unit(unit: &str) -> Option<(String, String)> { + let mut search = unit; + while let Some(uidx) = search.find("url = \"") { + let after = &search[uidx + 7..]; + let uend = after.find('"')?; + let url = &after[..uend]; + let rest = &after[uend..]; + let advance = uidx + 7 + uend; + if url.ends_with("-none-any.whl") { + if let Some(hidx) = rest.find("hash = \"sha256:") { + let hafter = &rest[hidx + 15..]; + let hend = hafter.find('"')?; + let sha = &hafter[..hend]; + if is_hex_of_len(sha, 64) { + if let Some(url) = http_url(url) { + return Some((url, sha.to_ascii_lowercase())); + } + } + } + } + search = &search[advance..]; + } + None +} diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/recover_tests.rs b/crates/socket-patch-core/src/vendor/lock_inventory/recover_tests.rs new file mode 100644 index 00000000..5e93e21d --- /dev/null +++ b/crates/socket-patch-core/src/vendor/lock_inventory/recover_tests.rs @@ -0,0 +1,621 @@ +use super::super::state::WiringAction; +use super::super::state::{CargoLockOriginal, VendorArtifact, VendorEntry, WiringRecord}; +use super::*; + +const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + +fn entry(eco: &str, base_purl: &str, wiring: Vec) -> VendorEntry { + VendorEntry { + ecosystem: eco.into(), + base_purl: base_purl.into(), + uuid: UUID.into(), + artifact: VendorArtifact { + path: format!(".socket/vendor/{eco}/{UUID}/x"), + sha256: String::new(), + size: None, + platform_locked: None, + file_inventory: None, + }, + wiring, + lock: None, + took_over_go_patches: false, + detached: false, + record: None, + flavor: None, + uv: None, + pnpm: None, + poetry: None, + pdm: None, + pipenv: None, + } +} + +fn rec(kind: &str, original: serde_json::Value) -> WiringRecord { + WiringRecord { + file: "lock".into(), + kind: kind.into(), + action: WiringAction::Rewritten, + key: Some("k".into()), + original: Some(original), + new: None, + } +} + +#[tokio::test] +async fn python_document_recovery_selects_the_requested_package() { + let tmp = tempfile::tempdir().unwrap(); + let sha = "c".repeat(64); + let lock = format!("lock-version='1.0'\n[[packages]]\nname='other'\nversion='1'\narchive={{url='https://pypi.org/other-1-py3-none-any.whl',hashes={{sha256='{}'}}}}\n[[packages]]\nname='target'\nversion='2'\narchive={{url='https://pypi.org/target-2-py3-none-any.whl',hashes={{sha256='{sha}'}}}}\n", "d".repeat(64)); + let record = rec("python_lock_document", serde_json::json!(lock)); + let ledger = entry("pypi", "pkg:pypi/target@2", vec![record.clone()]); + let recovered = recover_lock_entry(tmp.path(), &ledger).await.unwrap(); + assert_eq!( + recovered.resolved.as_deref(), + Some("https://pypi.org/target-2-py3-none-any.whl") + ); + assert_eq!(recovered.integrity, LockIntegrity::Sha256Hex(sha)); + let absent = entry("pypi", "pkg:pypi/target@3", vec![record]); + assert!(recover_lock_entry(tmp.path(), &absent).await.is_err()); +} + +#[tokio::test] +async fn npm_lock_entry_fragment_recovers_sri_and_url() { + let tmp = tempfile::tempdir().unwrap(); + let e = entry( + "npm", + "pkg:npm/@scope/x@1.2.3", + vec![rec( + "npm_lock_entry", + serde_json::json!({ + "resolved": "https://registry.npmjs.org/@scope/x/-/x-1.2.3.tgz", + "integrity": "sha512-AAAA", + }), + )], + ); + let got = recover_lock_entry(tmp.path(), &e).await.unwrap(); + assert_eq!(got.ecosystem, "npm"); + assert_eq!(got.name, "@scope/x"); + assert_eq!(got.version, "1.2.3"); + assert_eq!( + got.resolved.as_deref(), + Some("https://registry.npmjs.org/@scope/x/-/x-1.2.3.tgz") + ); + assert_eq!(got.integrity, LockIntegrity::Sri("sha512-AAAA".into())); +} + +#[tokio::test] +async fn bun_binary_snapshot_recovers_registry_metadata_and_checks_coordinates() { + let tmp = tempfile::tempdir().unwrap(); + let original = serde_json::json!({ + "name": "@scope/x", "version": "1.2.3", + "resolution": "https://registry.example/@scope/x/-/x-1.2.3.tgz", + "integrity": "sha512-AAAA", + }); + let good = entry( + "npm", + "pkg:npm/@scope/x@1.2.3", + vec![rec("bun_lockb_package", original.clone())], + ); + let recovered = recover_lock_entry(tmp.path(), &good).await.unwrap(); + assert_eq!( + recovered.resolved.as_deref(), + Some("https://registry.example/@scope/x/-/x-1.2.3.tgz") + ); + assert_eq!( + recovered.integrity, + LockIntegrity::Sri("sha512-AAAA".into()) + ); + let mismatched = entry( + "npm", + "pkg:npm/@scope/x@2.0.0", + vec![rec("bun_lockb_package", original)], + ); + assert!(recover_lock_entry(tmp.path(), &mismatched).await.is_err()); +} + +#[tokio::test] +async fn pnpm_package_lines_recover_integrity_and_tarball() { + let tmp = tempfile::tempdir().unwrap(); + let e = entry( + "npm", + "pkg:npm/left-pad@1.3.0", + vec![rec( + "pnpm_lock_package", + serde_json::json!([ + " left-pad@1.3.0:", + " resolution: {integrity: sha512-BBBB, tarball: https://npm.corp/left-pad-1.3.0.tgz}", + ]), + )], + ); + let got = recover_lock_entry(tmp.path(), &e).await.unwrap(); + assert_eq!(got.integrity, LockIntegrity::Sri("sha512-BBBB".into())); + assert_eq!( + got.resolved.as_deref(), + Some("https://npm.corp/left-pad-1.3.0.tgz") + ); +} + +#[tokio::test] +async fn yarn_classic_block_prefers_sri_else_sha1() { + let tmp = tempfile::tempdir().unwrap(); + let sha1 = "a".repeat(40); + let with_both = entry( + "npm", + "pkg:npm/x@1.0.0", + vec![rec( + "yarn_lock_block", + serde_json::json!([ + "x@^1.0.0:", + " version \"1.0.0\"", + format!(" resolved \"https://registry.yarnpkg.com/x/-/x-1.0.0.tgz#{sha1}\""), + " integrity sha512-CCCC", + ]), + )], + ); + let got = recover_lock_entry(tmp.path(), &with_both).await.unwrap(); + assert_eq!(got.integrity, LockIntegrity::Sri("sha512-CCCC".into())); + assert_eq!( + got.resolved.as_deref(), + Some("https://registry.yarnpkg.com/x/-/x-1.0.0.tgz") + ); + + let sha1_only = entry( + "npm", + "pkg:npm/x@1.0.0", + vec![rec( + "yarn_lock_block", + serde_json::json!([format!( + " resolved \"https://registry.yarnpkg.com/x/-/x-1.0.0.tgz#{sha1}\"" + )]), + )], + ); + let got = recover_lock_entry(tmp.path(), &sha1_only).await.unwrap(); + assert_eq!(got.integrity, LockIntegrity::Sha1Hex(sha1)); +} + +#[tokio::test] +async fn berry_checksum_and_bun_tuple_recover() { + let tmp = tempfile::tempdir().unwrap(); + let berry = entry( + "npm", + "pkg:npm/x@1.0.0", + vec![rec( + "yarn_berry_lock_entry", + serde_json::json!(["x@npm:1.0.0:", " checksum: 10c0/abcdef"]), + )], + ); + let got = recover_lock_entry(tmp.path(), &berry).await.unwrap(); + assert_eq!( + got.integrity, + LockIntegrity::BerryChecksum("10c0/abcdef".into()) + ); + assert_eq!(got.resolved, None); + + let bun = entry( + "npm", + "pkg:npm/x@1.0.0", + vec![rec( + "bun_lock_package", + serde_json::json!(" \"x\": [\"x@1.0.0\", \"\", {}, \"sha512-DDDD\"],"), + )], + ); + let got = recover_lock_entry(tmp.path(), &bun).await.unwrap(); + assert_eq!(got.integrity, LockIntegrity::Sri("sha512-DDDD".into())); +} + +#[tokio::test] +async fn cargo_recovers_from_entry_lock_checksum() { + let tmp = tempfile::tempdir().unwrap(); + let sha = "b".repeat(64); + let mut e = entry("cargo", "pkg:cargo/serde@1.0.0", vec![]); + e.lock = Some(CargoLockOriginal { + source: "registry+https://github.com/rust-lang/crates.io-index".into(), + checksum: Some(sha.clone()), + }); + let got = recover_lock_entry(tmp.path(), &e).await.unwrap(); + assert_eq!(got.ecosystem, "cargo"); + assert_eq!(got.integrity, LockIntegrity::Sha256Hex(sha)); + assert_eq!(got.resolved, None); + + // No checksum recorded → unrecoverable, never an unverified fetch. + let mut bare = entry("cargo", "pkg:cargo/serde@1.0.0", vec![]); + bare.lock = None; + assert!(recover_lock_entry(tmp.path(), &bare).await.is_err()); +} + +#[tokio::test] +async fn composer_gem_uv_fragments_recover() { + let tmp = tempfile::tempdir().unwrap(); + let sha1 = "c".repeat(40); + let composer = entry( + "composer", + "pkg:composer/monolog/monolog@2.9.1", + vec![rec( + "composer_lock_package", + serde_json::json!({ + "name": "monolog/monolog", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/abc", + "shasum": sha1, + }, + }), + )], + ); + let got = recover_lock_entry(tmp.path(), &composer).await.unwrap(); + assert_eq!(got.name, "monolog/monolog"); + assert_eq!(got.integrity, LockIntegrity::Sha1Hex(sha1)); + + // gem: checksum line + remote read from the unrewired Gemfile.lock. + let sha256 = "d".repeat(64); + tokio::fs::write( + tmp.path().join("Gemfile.lock"), + "GEM\n remote: https://rubygems.org/\n specs:\n rack (3.0.0)\n", + ) + .await + .unwrap(); + let gem = entry( + "gem", + "pkg:gem/rack@3.0.0", + vec![rec( + "gemfile_lock_checksum", + serde_json::json!(format!(" rack (3.0.0) sha256={sha256}")), + )], + ); + let got = recover_lock_entry(tmp.path(), &gem).await.unwrap(); + assert_eq!(got.integrity, LockIntegrity::Sha256Hex(sha256.clone())); + assert_eq!( + got.resolved.as_deref(), + Some("https://rubygems.org/downloads/rack-3.0.0.gem") + ); + + // uv: the original [[package]] unit lists wheels; only the PURE one + // is recoverable. + let wheel_sha = "e".repeat(64); + let unit = format!( + "[[package]]\nname = \"six\"\nversion = \"1.16.0\"\nwheels = [\n {{ url = \"https://files.pythonhosted.org/packages/six-1.16.0-cp39-cp39-linux_x86_64.whl\", hash = \"sha256:{}\" }},\n {{ url = \"https://files.pythonhosted.org/packages/six-1.16.0-py2.py3-none-any.whl\", hash = \"sha256:{wheel_sha}\" }},\n]\n", + "f".repeat(64) + ); + let uv = entry( + "pypi", + "pkg:pypi/six@1.16.0", + vec![rec("uv_lock_package", serde_json::json!(unit))], + ); + let got = recover_lock_entry(tmp.path(), &uv).await.unwrap(); + assert_eq!(got.integrity, LockIntegrity::Sha256Hex(wheel_sha)); + assert!(got.resolved.unwrap().ends_with("py2.py3-none-any.whl")); + + // platform-locked wheels are explicitly unrepairable from the registry. + let mut locked = entry("pypi", "pkg:pypi/six@1.16.0", vec![]); + locked.artifact.platform_locked = Some(true); + assert!(recover_lock_entry(tmp.path(), &locked).await.is_err()); +} + +// A pdm.lock produced with the `static_urls` strategy inlines the wheel +// URL exactly like uv.lock, but records it under the `pdm_lock_package` +// wiring kind. Recovery used to look only at `uv_lock_package`, so it was +// blind to pdm/poetry/pipenv projects; it now accepts every pypi kind. +#[tokio::test] +async fn recover_pypi_pdm_static_urls_recovers_pure_wheel() { + let tmp = tempfile::tempdir().unwrap(); + let wheel_sha = "a".repeat(64); + let unit = format!( + "[[package]]\nname = \"six\"\nversion = \"1.16.0\"\nfiles = [\n {{url = \"https://files.pythonhosted.org/packages/71/39/six-1.16.0.tar.gz\", hash = \"sha256:{}\"}},\n {{url = \"https://files.pythonhosted.org/packages/d9/5a/six-1.16.0-py2.py3-none-any.whl\", hash = \"sha256:{wheel_sha}\"}},\n]\n", + "b".repeat(64) + ); + let pdm = entry( + "pypi", + "pkg:pypi/six@1.16.0", + vec![rec("pdm_lock_package", serde_json::json!(unit))], + ); + let got = recover_lock_entry(tmp.path(), &pdm).await.unwrap(); + assert_eq!(got.ecosystem, "pypi"); + assert_eq!(got.name, "six"); + assert_eq!(got.integrity, LockIntegrity::Sha256Hex(wheel_sha)); + assert!(got.resolved.unwrap().ends_with("py2.py3-none-any.whl")); +} + +// Default pdm/poetry (`file = …`), pipenv (`hashes` only) and pip +// (`--hash=`) locks record the wheel hash but no fetchable URL. Recovery +// now RECOGNIZES those fragments (previously they fell through to the +// uv-specific "no uv.lock fragment recorded" error) and returns an +// accurate, actionable message instead of the false "not installed / no +// recoverable fragment". +#[tokio::test] +async fn recover_pypi_urlless_locks_report_no_fetchable_url() { + let tmp = tempfile::tempdir().unwrap(); + + // poetry / default-pdm shape: `files = [{file = …, hash = …}]`. + let poetry_unit = format!( + "[[package]]\nname = \"six\"\nversion = \"1.16.0\"\nfiles = [\n {{file = \"six-1.16.0-py2.py3-none-any.whl\", hash = \"sha256:{}\"}},\n]\n", + "a".repeat(64) + ); + for kind in ["poetry_lock_package", "pdm_lock_package"] { + let e = entry( + "pypi", + "pkg:pypi/six@1.16.0", + vec![rec(kind, serde_json::json!(poetry_unit))], + ); + let err = recover_lock_entry(tmp.path(), &e).await.unwrap_err(); + assert!(err.contains("no fetchable registry URL"), "{kind}: {err}"); + assert!(!err.contains("uv.lock fragment recorded"), "{kind}: {err}"); + } + + // pipenv records a JSON object (hashes + version), not a string unit: + // its digest set IS fetchable (PyPI JSON API lookup by digest), so a + // lock-only checkout of an already-vendored project recovers. + let pipenv = entry( + "pypi", + "pkg:pypi/six@1.16.0", + vec![rec( + "pipenv_lock_entry", + serde_json::json!({ + "hashes": [format!("sha256:{}", "a".repeat(64)), format!("sha256:{}", "B".repeat(64))], + "version": "==1.16.0", + }), + )], + ); + let recovered = recover_lock_entry(tmp.path(), &pipenv).await.unwrap(); + assert_eq!(recovered.purl, "pkg:pypi/six@1.16.0"); + assert_eq!(recovered.resolved, None); + assert_eq!( + recovered.integrity, + LockIntegrity::Sha256AnyOf(vec!["a".repeat(64), "b".repeat(64)]), + "lowercased digest set" + ); + // …but a pipenv fragment without digests has nothing to fetch by. + let digestless = entry( + "pypi", + "pkg:pypi/six@1.16.0", + vec![rec( + "pipenv_lock_entry", + serde_json::json!({"version": "==1.16.0"}), + )], + ); + let err = recover_lock_entry(tmp.path(), &digestless) + .await + .unwrap_err(); + assert!(err.contains("no sha256 digests"), "pipenv: {err}"); + + // A ledger with no pypi fragment at all is still a hard error. + let bare = entry("pypi", "pkg:pypi/six@1.16.0", vec![]); + assert!(recover_lock_entry(tmp.path(), &bare).await.is_err()); +} + +/// Ledger recovery cannot know which GEM section a vendored gem came +/// from (its spec moved into the PATH section), so a multi-source lock +/// makes the download origin ambiguous: refuse rather than guess (a +/// wrong remote 404s at best and leaks a private gem name at worst). +/// Sections that AGREE on one remote stay recoverable. +#[tokio::test] +async fn gem_recovery_refuses_ambiguous_multi_source_lock() { + let tmp = tempfile::tempdir().unwrap(); + let sha256 = "d".repeat(64); + let gem = entry( + "gem", + "pkg:gem/rack@3.0.0", + vec![rec( + "gemfile_lock_checksum", + serde_json::json!(format!(" rack (3.0.0) sha256={sha256}")), + )], + ); + + // Two GEM sections, two different remotes → ambiguous, fail closed. + tokio::fs::write( + tmp.path().join("Gemfile.lock"), + "GEM\n remote: https://gems.corp.example/\n specs:\n private-gem (1.0.0)\n\n\ + GEM\n remote: https://rubygems.org/\n specs:\n", + ) + .await + .unwrap(); + let err = recover_lock_entry(tmp.path(), &gem).await.unwrap_err(); + assert!( + err.contains("multiple GEM sources"), + "ambiguity must be named: {err}" + ); + + // Two GEM sections agreeing on ONE remote (dedup) → recoverable. + tokio::fs::write( + tmp.path().join("Gemfile.lock"), + "GEM\n remote: https://gems.corp.example/\n specs:\n other (1.0.0)\n\n\ + GEM\n remote: https://gems.corp.example/\n specs:\n", + ) + .await + .unwrap(); + let got = recover_lock_entry(tmp.path(), &gem).await.unwrap(); + assert_eq!( + got.resolved.as_deref(), + Some("https://gems.corp.example/downloads/rack-3.0.0.gem"), + "the agreed remote is used, not a rubygems.org guess" + ); + assert_eq!(got.integrity, LockIntegrity::Sha256Hex(sha256)); +} + +/// The ambiguity count must see NON-http remotes too (a `source +/// "file://…" do` block locks its own GEM section with a `file:///` +/// remote — real bundler 4.0.15 output). Filtering to http(s) first +/// would collapse a mixed http+file lock to one "agreed" remote and +/// send a possibly-file-sourced gem's name to the http registry — the +/// same leak class the multi-http refusal closes. A lock whose ONLY +/// remote is non-http must refuse too, never default to rubygems.org. +#[tokio::test] +async fn gem_recovery_counts_non_http_remotes_as_ambiguity() { + let tmp = tempfile::tempdir().unwrap(); + let gem = entry( + "gem", + "pkg:gem/rack@3.0.0", + vec![rec( + "gemfile_lock_checksum", + serde_json::json!(format!(" rack (3.0.0) sha256={}", "d".repeat(64))), + )], + ); + + // Mixed schemes: one file:// section + one https section → ambiguous. + tokio::fs::write( + tmp.path().join("Gemfile.lock"), + "GEM\n remote: file:///srv/gems/\n specs:\n private-gem (1.0.0)\n\n\ + GEM\n remote: https://rubygems.org/\n specs:\n rake (13.3.1)\n", + ) + .await + .unwrap(); + let err = recover_lock_entry(tmp.path(), &gem).await.unwrap_err(); + assert!( + err.contains("multiple GEM sources"), + "a file:// section must count toward the ambiguity refusal: {err}" + ); + + // A single file:// remote: not fetchable, and never a rubygems.org + // fallback (that would leak the private repo's gem name off-site). + tokio::fs::write( + tmp.path().join("Gemfile.lock"), + "GEM\n remote: file:///srv/gems/\n specs:\n private-gem (1.0.0)\n", + ) + .await + .unwrap(); + let err = recover_lock_entry(tmp.path(), &gem).await.unwrap_err(); + assert!( + err.contains("file:///srv/gems") && err.contains("not an http(s) registry"), + "a lone non-http remote must refuse, not guess: {err}" + ); +} + +#[tokio::test] +async fn recover_decodes_percent_encoded_base_purl() { + // The ledger stores base_purl verbatim as the manifest spelled it — + // often percent-encoded (`pkg:npm/%40scope/x@1.2.3`). The recovered + // entry must carry literal coordinates: the name feeds the registry + // tarball URL and the berry cache-zip recipe (which embeds it in + // member paths), so an encoded name fails every checksum rebuild. + let tmp = tempfile::tempdir().unwrap(); + let e = entry( + "npm", + "pkg:npm/%40scope/x@1.2.3", + vec![rec( + "yarn_berry_lock_entry", + serde_json::json!(["\"@scope/x@npm:1.2.3\":", " checksum: 10c0/abcdef"]), + )], + ); + let got = recover_lock_entry(tmp.path(), &e).await.unwrap(); + assert_eq!(got.name, "@scope/x"); + assert_eq!(got.purl, "pkg:npm/@scope/x@1.2.3"); + + // Version components decode too (`1.0.0%2Bbuild` → `1.0.0+build`). + let e = entry( + "npm", + "pkg:npm/x@1.0.0%2Bbuild", + vec![rec( + "npm_lock_entry", + serde_json::json!({ + "resolved": "https://registry.npmjs.org/x/-/x-1.0.0+build.tgz", + "integrity": "sha512-AAAA", + }), + )], + ); + let got = recover_lock_entry(tmp.path(), &e).await.unwrap(); + assert_eq!(got.version, "1.0.0+build"); +} + +#[tokio::test] +async fn unrecoverable_fragments_fail_closed() { + let tmp = tempfile::tempdir().unwrap(); + // No wiring at all. + let bare = entry("npm", "pkg:npm/x@1.0.0", vec![]); + assert!(recover_lock_entry(tmp.path(), &bare).await.is_err()); + // golang routes through go.sum, never the ledger. + let go = entry("golang", "pkg:golang/golang.org/x/text@v0.14.0", vec![]); + assert!(recover_lock_entry(tmp.path(), &go).await.is_err()); + // Poisoned integrity shapes are rejected. + let bad = entry( + "npm", + "pkg:npm/x@1.0.0", + vec![rec( + "npm_lock_entry", + serde_json::json!({"resolved": "https://x/", "integrity": "lol"}), + )], + ); + assert!(recover_lock_entry(tmp.path(), &bad).await.is_err()); +} + +/// composer dists FREQUENTLY record `shasum: ""` — that common recovery +/// outcome refuses the unverifiable fetch; the gem twin refuses when the +/// recorded checksum line carries no extractable 64-hex sha256. +#[tokio::test] +async fn recover_composer_empty_shasum_and_gem_hexless_checksum_fail_closed() { + let tmp = tempfile::tempdir().unwrap(); + let composer = entry( + "composer", + "pkg:composer/monolog/monolog@2.9.1", + vec![rec( + "composer_lock_package", + serde_json::json!({ + "dist": { "type": "zip", "url": "https://example.com/a.zip", "shasum": "" }, + }), + )], + ); + let err = recover_lock_entry(tmp.path(), &composer).await.unwrap_err(); + assert!( + err.contains("records no shasum"), + "an empty shasum must refuse the fetch: {err}" + ); + + // The sha256 check precedes gem_remotes, so no Gemfile.lock needed. + let gem = entry( + "gem", + "pkg:gem/rake@13.0.6", + vec![rec( + "gemfile_lock_checksum", + serde_json::json!(" rake (13.0.6) sha256=zz"), + )], + ); + let err = recover_lock_entry(tmp.path(), &gem).await.unwrap_err(); + assert!( + err.contains("has no sha256"), + "a hex-less checksum line must refuse the fetch: {err}" + ); +} + +/// Fragments PRESENT but invalid (non-SRI pnpm integrity, a yarn block +/// with neither SRI nor 40-hex fragment, a malformed berry checksum, a +/// bun tuple without an SRI token) must all fall through to the final +/// fail-closed error — only the no-fragment-at-all path was tested. An +/// empty-name base purl is unparseable outright. +#[tokio::test] +async fn recover_present_but_invalid_npm_fragments_fail_closed() { + let tmp = tempfile::tempdir().unwrap(); + let nameless = entry("npm", "pkg:npm/@1.0.0", vec![]); + let err = recover_lock_entry(tmp.path(), &nameless).await.unwrap_err(); + assert!( + err.contains("unparseable base purl"), + "an empty-name purl must be rejected: {err}" + ); + + let all_invalid = entry( + "npm", + "pkg:npm/x@1.0.0", + vec![ + rec( + "pnpm_lock_package", + serde_json::json!([" resolution: {integrity: garbage}"]), + ), + rec( + "yarn_lock_block", + serde_json::json!([" resolved \"https://x/y.tgz\""]), + ), + rec( + "yarn_berry_lock_entry", + serde_json::json!([" checksum: malformed"]), + ), + rec( + "bun_lock_package", + serde_json::json!(" \"x\": [\"x@1.0.0\", \"\", {}, \"notsri\"],"), + ), + ], + ); + let err = recover_lock_entry(tmp.path(), &all_invalid) + .await + .unwrap_err(); + assert!( + err.contains("no pre-vendor npm registry fragment"), + "every invalid fragment must fall through to the fail-closed error: {err}" + ); +} diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs b/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs new file mode 100644 index 00000000..6e95aa44 --- /dev/null +++ b/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs @@ -0,0 +1,2229 @@ +use super::*; + +async fn write(root: &Path, name: &str, content: &str) { + tokio::fs::write(root.join(name), content).await.unwrap(); +} + +fn entry<'a>(entries: &'a [LockfileEntry], name: &str) -> &'a LockfileEntry { + entries + .iter() + .find(|e| e.name == name) + .unwrap_or_else(|| panic!("no entry for {name}: {entries:?}")) +} + +// ── package-lock ────────────────────────────────────────────────────── + +const PACKAGE_LOCK: &str = r#"{ + "name": "fixture", + "version": "1.0.0", + "lockfileVersion": 3, + "packages": { + "": { "name": "fixture", "version": "1.0.0" }, + "packages/member": { "name": "member", "version": "0.0.1" }, + "node_modules/member": { "resolved": "packages/member", "link": true }, + "node_modules/left-pad": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "integrity": "sha512-XI5MPz==" + }, + "node_modules/@scope/pkg": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@scope/pkg/-/pkg-2.0.0.tgz", + "integrity": "sha512-scoped==" + }, + "node_modules/bundled-dep": { + "version": "1.0.0", + "inBundle": true + }, + "node_modules/git-dep": { + "version": "0.5.0", + "resolved": "git+ssh://git@github.com/x/git-dep.git#abc" + }, + "node_modules/vendored": { + "version": "3.0.0", + "resolved": "file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/vendored-3.0.0.tgz", + "integrity": "sha512-ours==" + }, + "node_modules/evil": { + "version": "../../escape", + "resolved": "https://registry.npmjs.org/evil/-/evil-1.0.0.tgz", + "integrity": "sha512-evil==" + }, + "node_modules/no-version": { + "resolved": "https://registry.npmjs.org/no-version/-/no-version-1.0.0.tgz" + } + } +} +"#; + +#[tokio::test] +async fn package_lock_inventories_registry_entries() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "package-lock.json", PACKAGE_LOCK).await; + + let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); + assert_eq!(flavor, NpmLockFlavor::PackageLock); + + let lp = entry(&entries, "left-pad"); + assert_eq!(lp.version, "1.3.0"); + assert_eq!(lp.purl, "pkg:npm/left-pad@1.3.0"); + assert_eq!( + lp.resolved.as_deref(), + Some("https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz") + ); + assert_eq!(lp.integrity, LockIntegrity::Sri("sha512-XI5MPz==".into())); + + let scoped = entry(&entries, "@scope/pkg"); + assert_eq!(scoped.purl, "pkg:npm/@scope/pkg@2.0.0"); + + // git deps stay listed (discovery) but carry no fetchable URL. + let git = entry(&entries, "git-dep"); + assert_eq!(git.resolved, None); + assert_eq!(git.integrity, LockIntegrity::None); + + // Workspace members, links, bundled deps, our vendored spec, the + // unsafe-version entry, and the version-less node are all absent. + for absent in [ + "member", + "fixture", + "bundled-dep", + "vendored", + "evil", + "no-version", + ] { + assert!( + !entries.iter().any(|e| e.name == absent), + "{absent} must not be inventoried: {entries:?}" + ); + } +} + +#[tokio::test] +async fn shrinkwrap_wins_over_package_lock() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "package-lock.json", PACKAGE_LOCK).await; + write( + tmp.path(), + "npm-shrinkwrap.json", + r#"{ "lockfileVersion": 3, "packages": { + "node_modules/only-in-shrinkwrap": { "version": "9.9.9" } } }"#, + ) + .await; + + let (_, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); + assert!(entries.iter().any(|e| e.name == "only-in-shrinkwrap")); + assert!(!entries.iter().any(|e| e.name == "left-pad")); +} + +#[tokio::test] +async fn legacy_v1_lock_without_packages_map_yields_none() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "package-lock.json", + r#"{ "lockfileVersion": 1, "dependencies": { "left-pad": { "version": "1.3.0" } } }"#, + ) + .await; + assert!(inventory_npm_lock(tmp.path()).await.unwrap().is_none()); +} + +// ── pnpm ────────────────────────────────────────────────────────────── + +const PNPM_LOCK: &str = "lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + +importers: + + .: + dependencies: + left-pad: + specifier: 1.3.0 + version: 1.3.0 + +packages: + + left-pad@1.3.0: + resolution: {integrity: sha512-XI5MPz==} + + '@scope/pkg@2.0.0': + resolution: {integrity: sha512-scoped==} + + peer-user@4.0.0(left-pad@1.3.0): + resolution: {integrity: sha512-peer==} + + local-thing@file:packages/local: + resolution: {directory: packages/local, type: directory} + + vendored@file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/vendored-3.0.0.tgz: + resolution: {integrity: sha512-ours==, tarball: file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/vendored-3.0.0.tgz} + +snapshots: + + left-pad@1.3.0: {} +"; + +#[tokio::test] +async fn pnpm_v9_keys_parse_with_peer_suffix_and_scoped_quoting() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK).await; + + let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); + assert_eq!(flavor, NpmLockFlavor::Pnpm); + + assert_eq!( + entry(&entries, "left-pad").integrity, + LockIntegrity::Sri("sha512-XI5MPz==".into()) + ); + assert_eq!(entry(&entries, "@scope/pkg").version, "2.0.0"); + assert_eq!(entry(&entries, "peer-user").version, "4.0.0"); + // registry entries carry no URL in v9 — constructed at fetch time. + assert_eq!(entry(&entries, "left-pad").resolved, None); + // Exact set: the legacy v5/v6 grammars must not add or reshape v9 + // entries (local-thing and vendored stay skipped). + assert_eq!( + sorted_pairs(&entries), + vec![ + ("@scope/pkg".into(), "2.0.0".into()), + ("left-pad".into(), "1.3.0".into()), + ("peer-user".into(), "4.0.0".into()), + ] + ); +} + +fn sorted_pairs(entries: &[LockfileEntry]) -> Vec<(String, String)> { + let mut pairs: Vec<(String, String)> = entries + .iter() + .map(|e| (e.name.clone(), e.version.clone())) + .collect(); + pairs.sort(); + pairs +} + +// Real pnpm 7 shapes (lockfileVersion 5.4, captured from a pnpm 7.33.5 +// install: slash-separated `/name/version` keys, no `@` at all), plus +// synthetic keys in the same grammar: scoped, `_peer@x`-suffixed, +// `_`-suffixed, and a non-default-registry key (no leading `/`) +// that must stay out fail-closed. +const PNPM_LOCK_V5: &str = "lockfileVersion: 5.4 + +specifiers: + mkdirp: 0.5.5 + +dependencies: + mkdirp: 0.5.5 + +packages: + + /minimist/1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + dev: false + + /mkdirp/0.5.5: + resolution: {integrity: sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==} + hasBin: true + dependencies: + minimist: 1.2.8 + dev: false + + /@scope/pkg/2.0.0: + resolution: {integrity: sha512-scoped==} + dev: false + + /styled-thing/5.3.3_react@17.0.2: + resolution: {integrity: sha512-peered==} + dev: false + + /hashed-thing/1.0.0_abc123deadbeef: + resolution: {integrity: sha512-hashed==} + dev: false + + example.com/private-pkg/1.0.0: + resolution: {integrity: sha512-registry==} + dev: false +"; + +#[tokio::test] +async fn pnpm_v5_slash_keys_inventory_with_peer_and_hash_suffixes() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK_V5).await; + + let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); + // The legacy grammars route to the PnpmLegacy wiring flavor now + // (they used to reach here through the version-refusal fallback); + // the inventory content is identical either way. + assert_eq!(flavor, NpmLockFlavor::PnpmLegacy); + assert_eq!( + sorted_pairs(&entries), + vec![ + ("@scope/pkg".into(), "2.0.0".into()), + ("hashed-thing".into(), "1.0.0".into()), + ("minimist".into(), "1.2.8".into()), + ("mkdirp".into(), "0.5.5".into()), + ("styled-thing".into(), "5.3.3".into()), + ] + ); + assert_eq!( + entry(&entries, "minimist").integrity, + LockIntegrity::Sri( + "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==" + .into() + ) + ); + assert_eq!(entry(&entries, "minimist").purl, "pkg:npm/minimist@1.2.8"); +} + +// Real pnpm 8 shapes (lockfileVersion 6.0, captured from a pnpm 8.15.9 +// install: v9's `name@version` behind a leading `/`), plus synthetic +// scoped and peer-parenthesized keys in the same grammar. +const PNPM_LOCK_V6: &str = "lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +dependencies: + mkdirp: + specifier: 0.5.5 + version: 0.5.5 + +packages: + + /minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + dev: false + + /mkdirp@0.5.5: + resolution: {integrity: sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==} + hasBin: true + dependencies: + minimist: 1.2.8 + dev: false + + /@scope/pkg@2.0.0: + resolution: {integrity: sha512-scoped==} + dev: false + + /peer-user@4.0.0(left-pad@1.3.0): + resolution: {integrity: sha512-peer==} + dev: false +"; + +#[tokio::test] +async fn pnpm_v6_leading_slash_keys_inventory_with_peer_parens() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK_V6).await; + + let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); + // The legacy grammars route to the PnpmLegacy wiring flavor now + // (they used to reach here through the version-refusal fallback); + // the inventory content is identical either way. + assert_eq!(flavor, NpmLockFlavor::PnpmLegacy); + assert_eq!( + sorted_pairs(&entries), + vec![ + ("@scope/pkg".into(), "2.0.0".into()), + ("minimist".into(), "1.2.8".into()), + ("mkdirp".into(), "0.5.5".into()), + ("peer-user".into(), "4.0.0".into()), + ] + ); + assert_eq!( + entry(&entries, "mkdirp").integrity, + LockIntegrity::Sri( + "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==" + .into() + ) + ); + assert_eq!( + entry(&entries, "@scope/pkg").purl, + "pkg:npm/@scope/pkg@2.0.0" + ); +} + +/// A pnpm→yarn-berry migration leaves a stale root pnpm-lock.yaml behind +/// a `.pnp.cjs` loader. The probe's refusal there is a yarn refusal, not +/// a pnpm one — the legacy-lock fallback must NOT inventory the stale +/// lock as the live dependency set; the yarn-PnP diagnosis propagates +/// instead. +#[tokio::test] +async fn stale_pnpm_lock_behind_yarn_berry_pnp_marker_is_not_inventoried() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK).await; + write(tmp.path(), ".pnp.cjs", "/* yarn berry PnP loader */").await; + let diag = inventory_npm_lock(tmp.path()).await.unwrap_err(); + assert_eq!( + diag.code, "vendor_yarn_berry_unsupported", + "a stale pnpm-lock.yaml behind a yarn-berry PnP marker must not be inventoried" + ); +} + +/// A malformed binary lock fails closed with format context, including +/// beside a different package manager's lock. A text Bun lock wins. +#[tokio::test] +async fn malformed_bun_lockb_yields_a_diagnosis_without_inventorying_siblings() { + for sibling in [ + None, + Some(("pnpm-lock.yaml", PNPM_LOCK)), + Some(("yarn.lock", YARN_CLASSIC)), + Some(("package-lock.json", PACKAGE_LOCK)), + ] { + let tmp = tempfile::tempdir().unwrap(); + if let Some((name, content)) = sibling { + write(tmp.path(), name, content).await; + } + write(tmp.path(), "bun.lockb", "\0binary").await; + let diag = inventory_npm_lock(tmp.path()).await.unwrap_err(); + assert_eq!(diag.code, "bun_lockb_invalid"); + assert!(diag.detail.contains("bun.lockb"), "{}", diag.detail); + let (entries, unsupported) = inventory_project_diagnosed(tmp.path()).await; + assert!(entries.is_empty(), "{entries:?}"); + assert_eq!(unsupported, vec![diag]); + + write(tmp.path(), "bun.lock", BUN_LOCK).await; + let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); + assert_eq!(flavor, NpmLockFlavor::Bun); + assert_eq!(entry(&entries, "left-pad").version, "1.3.0"); + assert!(inventory_project_diagnosed(tmp.path()).await.1.is_empty()); + } +} + +#[tokio::test] +async fn bun_binary_inventory_works_without_an_install_or_runtime() { + let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bun-lockb"); + for version in [ + "0.1.1", "0.6.7", "0.6.8", "0.8.1", "1.0.0", "1.0.36", "1.1.0", "1.1.38", "1.1.45", + ] { + let tmp = tempfile::tempdir().unwrap(); + let bytes = std::fs::read(fixtures.join(version).join("bun.lockb")).unwrap(); + tokio::fs::write(tmp.path().join("bun.lockb"), &bytes) + .await + .unwrap(); + let (entries, diagnoses) = inventory_project_diagnosed(tmp.path()).await; + assert!(diagnoses.is_empty(), "Bun {version}: {diagnoses:?}"); + assert_eq!( + sorted_pairs(&entries), + vec![ + ("is-number".into(), "7.0.0".into()), + ("minimist".into(), "1.2.2".into()) + ], + "Bun {version}" + ); + let minimist = entry(&entries, "minimist"); + assert!( + minimist + .resolved + .as_deref() + .is_some_and(|url| url.ends_with("minimist-1.2.2.tgz")), + "Bun {version}: {minimist:?}" + ); + assert!( + super::super::bun_lock::preflight_vendor(tmp.path()) + .await + .is_ok(), + "Bun {version}" + ); + assert_eq!( + tokio::fs::read(tmp.path().join("bun.lockb")).await.unwrap(), + bytes, + "discovery/preflight must preserve Bun {version} bytes" + ); + assert!(!tmp.path().join("bun.lock").exists()); + assert!(!tmp.path().join("node_modules").exists()); + } +} + +#[tokio::test] +async fn bun_binary_vendor_integrity_follows_live_package_records() { + let bytes = include_bytes!("../../../tests/fixtures/bun-lockb/1.1.45/bun.lockb"); + let mut lock = super::super::bun_lockb::BunLockb::parse(bytes).unwrap(); + let package = lock + .packages() + .unwrap() + .into_iter() + .find(|package| package.name == "minimist") + .unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let rel = ".socket/vendor/npm/11111111-1111-4111-8111-111111111111/minimist-1.2.2.tgz"; + let first = format!("sha512-{}", "A".repeat(86) + "=="); + lock.set_package(package.id, rel, &first).unwrap(); + tokio::fs::write(tmp.path().join("bun.lockb"), lock.bytes()) + .await + .unwrap(); + assert_eq!( + wired_vendor_integrity(tmp.path(), rel).await, + Some(LockIntegrity::Sri(first)) + ); + let next = rel.replace( + "11111111-1111-4111-8111-111111111111", + "22222222-2222-4222-8222-222222222222", + ); + lock.set_package( + package.id, + &next, + &format!("sha512-{}", "A".repeat(86) + "=="), + ) + .unwrap(); + tokio::fs::write(tmp.path().join("bun.lockb"), lock.bytes()) + .await + .unwrap(); + assert_eq!( + wired_vendor_integrity(tmp.path(), rel).await, + None, + "retired strings are not active resolutions" + ); + assert!(wired_vendor_integrity(tmp.path(), &next).await.is_some()); + write(tmp.path(), "bun.lock", BUN_LOCK).await; + assert_eq!( + wired_vendor_integrity(tmp.path(), &next).await, + None, + "text lock takes precedence" + ); +} + +/// A pnpm-lock.yaml whose lockfileVersion the probe refuses — pnpm 6 +/// wrote 5.3; only 5.4/6.0/9.0 route to a backend. This is the shape +/// that reaches the version-refusal discovery fallback, where a live +/// sibling lock may be sitting beside it after a migration. +const PNPM_LOCK_V53_STALE: &str = "lockfileVersion: 5.3 + +packages: + + /dead-pnpm-dep/1.0.0: + resolution: {integrity: sha512-dead==} +"; + +/// A pnpm→yarn migration leaves a version-refused pnpm-lock.yaml beside +/// the live yarn.lock. The probe checks pnpm-lock.yaml BEFORE yarn.lock, +/// so its refusal says nothing about the sibling — the fallback must +/// surface the LIVE yarn resolutions, not the dead pnpm ones. +#[tokio::test] +async fn stale_pnpm_lock_beside_live_yarn_classic_yields_yarn_entries() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK_V53_STALE).await; + write(tmp.path(), "yarn.lock", YARN_CLASSIC).await; + + let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); + assert_eq!(flavor, NpmLockFlavor::YarnClassic); + assert_eq!(entry(&entries, "left-pad").version, "1.3.0"); + assert!( + !entries.iter().any(|e| e.name == "dead-pnpm-dep"), + "dead pnpm resolutions must not pose as the live set: {entries:?}" + ); +} + +/// Same migration hazard toward yarn berry (node-modules linker: no PnP +/// marker, so the pnpm version refusal is what fires). +#[tokio::test] +async fn stale_pnpm_lock_beside_live_yarn_berry_yields_berry_entries() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK_V53_STALE).await; + write(tmp.path(), "yarn.lock", YARN_BERRY).await; + + let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); + assert_eq!(flavor, NpmLockFlavor::YarnBerry); + assert_eq!(entry(&entries, "left-pad").version, "1.3.0"); + assert!( + !entries.iter().any(|e| e.name == "dead-pnpm-dep"), + "dead pnpm resolutions must not pose as the live set: {entries:?}" + ); +} + +/// Same migration hazard toward npm: the live package-lock.json wins +/// over the version-refused pnpm lock. +#[tokio::test] +async fn stale_pnpm_lock_beside_live_package_lock_yields_npm_entries() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK_V53_STALE).await; + write(tmp.path(), "package-lock.json", PACKAGE_LOCK).await; + + let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); + assert_eq!(flavor, NpmLockFlavor::PackageLock); + assert_eq!(entry(&entries, "left-pad").version, "1.3.0"); + assert!( + !entries.iter().any(|e| e.name == "dead-pnpm-dep"), + "dead pnpm resolutions must not pose as the live set: {entries:?}" + ); +} + +/// A version-refused pnpm lock ALONE is a genuine old-pnpm project (no +/// migration happened) — the discovery fallback must still read it. +#[tokio::test] +async fn unsupported_pnpm_lock_alone_is_still_inventoried() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK_V53_STALE).await; + + let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); + assert_eq!(flavor, NpmLockFlavor::Pnpm); + assert_eq!(entry(&entries, "dead-pnpm-dep").version, "1.0.0"); +} + +/// pnpm→bun migration with the TEXT bun.lock: the router routes Bun at +/// its bun step, which runs BEFORE the pnpm sniff, so no refusal (and no +/// fallback) ever fires — bun's entries are the inventory. Pinned here +/// because it is the router-precedence twin of the sibling checks above. +#[tokio::test] +async fn stale_pnpm_lock_beside_bun_lock_routes_to_bun() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK_V53_STALE).await; + write(tmp.path(), "bun.lock", BUN_LOCK).await; + + let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); + assert_eq!(flavor, NpmLockFlavor::Bun); + assert_eq!(entry(&entries, "left-pad").version, "1.3.0"); + assert!( + !entries.iter().any(|e| e.name == "dead-pnpm-dep"), + "dead pnpm resolutions must not pose as the live set: {entries:?}" + ); +} + +/// A live sibling lock FILE that yields no entries (here: an empty +/// package-lock, as a fresh dep-less `npm install` writes) still proves +/// the migration happened — the dead pnpm resolutions must stay out even +/// though there is nothing live to return. +#[tokio::test] +async fn stale_pnpm_lock_beside_empty_live_lock_yields_none() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK_V53_STALE).await; + write( + tmp.path(), + "package-lock.json", + r#"{ "lockfileVersion": 3, "packages": { "": {} } }"#, + ) + .await; + assert!( + inventory_npm_lock(tmp.path()).await.unwrap().is_none(), + "an empty live sibling must not resurrect the dead pnpm resolutions" + ); +} + +// ── shrinkwrap.yaml (pnpm 1/2) ────────────────────────────────────────── + +/// The exact grammar the 2026-08-18 legacy matrix captured from a real +/// pnpm 2 install (shrinkwrapVersion 3): v5-style `/name/version` keys, +/// BLOCK-mapped `resolution:` (integrity nested on its own line — every +/// pnpm-lock.yaml generation writes the inline `{…}` flow map instead), +/// quoted top-level `registry:`, and a transitive dep (`minimist`) +/// listed only under `packages:`. +const SHRINKWRAP_YAML: &str = "dependencies: + left-pad: 1.3.0 + mkdirp: 0.5.5 +packages: + /left-pad/1.3.0: + deprecated: use String.prototype.padStart() + dev: false + resolution: + integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA== + /minimist/1.2.8: + dev: false + resolution: + integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + /mkdirp/0.5.5: + dependencies: + minimist: 1.2.8 + dev: false + hasBin: true + resolution: + integrity: sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ== +registry: 'https://registry.npmjs.org/' +shrinkwrapMinorVersion: 9 +shrinkwrapVersion: 3 +specifiers: + left-pad: 1.3.0 + mkdirp: 0.5.5 +"; + +/// A pnpm <=2 project (shrinkwrap.yaml, no pnpm-lock.yaml, no other +/// lock) must be inventoried through the shrinkwrap fallback: same v5 +/// key grammar, integrity read from the BLOCK-mapped resolution — +/// without it such projects report lockfileOnlyPackages=0 despite the +/// lock listing everything. +#[tokio::test] +async fn shrinkwrap_yaml_inventories_pnpm_legacy_project() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "shrinkwrap.yaml", SHRINKWRAP_YAML).await; + + let (flavor, entries) = inventory_npm_lock(tmp.path()) + .await + .unwrap() + .expect("shrinkwrap.yaml must be inventoried"); + assert_eq!(flavor, NpmLockFlavor::PnpmLegacy); + assert_eq!(entries.len(), 3, "all three packages entries: {entries:?}"); + + let lp = entry(&entries, "left-pad"); + assert_eq!(lp.version, "1.3.0"); + assert_eq!(lp.purl, "pkg:npm/left-pad@1.3.0"); + assert_eq!( + lp.integrity, + LockIntegrity::Sri( + "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/\ + aVx2HrNcqQGsdot8ghrjyrvMCoEA==" + .into() + ), + "block-mapped resolution integrity must be captured" + ); + assert_eq!(lp.resolved, None, "no tarball recorded → registry URL"); + + // The transitive dep (a dependencies: child inside mkdirp's entry + // must not shadow it) and the binary-carrying dep both inventory. + assert_eq!(entry(&entries, "minimist").version, "1.2.8"); + assert_eq!(entry(&entries, "mkdirp").version, "0.5.5"); +} + +/// A root pnpm-lock.yaml wins over shrinkwrap.yaml: the flavor probe +/// recognizes the modern lock, so the legacy fallback never runs — a +/// leftover shrinkwrap.yaml from a long-ago pnpm upgrade must not +/// inject dead resolutions. +#[tokio::test] +async fn pnpm_lock_wins_over_stale_shrinkwrap_yaml() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK).await; + write(tmp.path(), "shrinkwrap.yaml", SHRINKWRAP_YAML).await; + + let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); + assert_eq!(flavor, NpmLockFlavor::Pnpm); + assert!( + !entries.iter().any(|e| e.name == "mkdirp"), + "shrinkwrap-only entries must not leak in: {entries:?}" + ); +} + +/// Same stale-lock hazard as the pnpm-lock fallbacks: a shrinkwrap.yaml +/// behind another family's marker (yarn-berry PnP here — the probe +/// refuses with a NON-missing code) is migration debris, not the live +/// dependency set; the yarn-PnP diagnosis propagates instead. +#[tokio::test] +async fn stale_shrinkwrap_behind_yarn_berry_pnp_marker_is_not_inventoried() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "shrinkwrap.yaml", SHRINKWRAP_YAML).await; + write(tmp.path(), ".pnp.cjs", "/* yarn berry PnP loader */").await; + let diag = inventory_npm_lock(tmp.path()).await.unwrap_err(); + assert_eq!( + diag.code, "vendor_yarn_berry_unsupported", + "a shrinkwrap.yaml behind a yarn-berry PnP marker must not be inventoried" + ); +} + +/// pnpm's own `node-linker=pnp` layout (`.pnp.cjs` + pnpm store + lock, +/// no yarn.lock) refuses with the pnpm-specific PnP code, which +/// PROPAGATES as the layout diagnosis rather than falling back to the +/// lock read — under PnP the installed-tree crawl is also structurally +/// empty, so the honest answer is the refusal, not a lock-only +/// inventory posing as a served project (see +/// `pnp_layouts_propagate_the_diagnosis_instead_of_yielding_none`). +#[tokio::test] +async fn pnpm_pnp_layout_propagates_the_diagnosis() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK).await; + write(tmp.path(), ".pnp.cjs", "/* pnpm node-linker=pnp loader */").await; + write_nested(tmp.path(), "node_modules/.modules.yaml", "").await; + + let diag = inventory_npm_lock(tmp.path()).await.unwrap_err(); + assert_eq!(diag.code, "vendor_pnpm_pnp_unsupported"); +} + +// ── Rush monorepo ─────────────────────────────────────────────────────── + +/// Write `content` to `rel` under `root`, creating parent dirs. +async fn write_nested(root: &Path, rel: &str, content: &str) { + let path = root.join(rel); + tokio::fs::create_dir_all(path.parent().unwrap()) + .await + .unwrap(); + tokio::fs::write(path, content).await.unwrap(); +} + +#[tokio::test] +async fn rush_monorepo_inventories_common_and_subspace_locks() { + // No root package.json/lock — only rush.json plus the generated + // source-of-truth lock under common/config and one subspace lock. + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "rush.json", r#"{"rushVersion":"5.0.0"}"#).await; + write_nested(tmp.path(), "common/config/rush/pnpm-lock.yaml", PNPM_LOCK).await; + write_nested( + tmp.path(), + "common/config/subspaces/frontend/pnpm-lock.yaml", + "lockfileVersion: '9.0' + +packages: + + only-in-subspace@9.9.9: + resolution: {integrity: sha512-sub==} +", + ) + .await; + + let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); + assert_eq!(flavor, NpmLockFlavor::Pnpm); + // Union across the common lock and the subspace lock. + assert_eq!(entry(&entries, "left-pad").version, "1.3.0"); + assert_eq!(entry(&entries, "only-in-subspace").version, "9.9.9"); +} + +#[tokio::test] +async fn rush_json_without_any_lock_yields_none() { + // rush.json but no common/subspace lock at all: nothing to inventory. + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "rush.json", r#"{"rushVersion":"5.0.0"}"#).await; + assert!(inventory_npm_lock(tmp.path()).await.unwrap().is_none()); +} + +#[tokio::test] +async fn root_pnpm_lock_wins_over_rush_fallback() { + // A plain pnpm project that also happens to carry a stray rush.json + // must route through the normal root-lock path, never the fallback. + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "rush.json", r#"{"rushVersion":"5.0.0"}"#).await; + write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK).await; + write_nested( + tmp.path(), + "common/config/rush/pnpm-lock.yaml", + "lockfileVersion: '9.0' + +packages: + + only-in-common@1.0.0: + resolution: {integrity: sha512-common==} +", + ) + .await; + + let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); + assert_eq!(flavor, NpmLockFlavor::Pnpm); + assert!(entries.iter().any(|e| e.name == "left-pad")); + assert!( + !entries.iter().any(|e| e.name == "only-in-common"), + "the root lock must win; the rush fallback must not run: {entries:?}" + ); +} + +// ── yarn classic ────────────────────────────────────────────────────── + +const YARN_CLASSIC: &str = "# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +\"@scope/pkg@^2.0.0\": + version \"2.0.0\" + resolved \"https://registry.yarnpkg.com/@scope/pkg/-/pkg-2.0.0.tgz#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\" + integrity sha512-scoped== + +left-pad@1.3.0, left-pad@^1.3.0: + version \"1.3.0\" + resolved \"https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\" + integrity sha512-XI5MPz== + +old-school@0.1.0: + version \"0.1.0\" + resolved \"https://registry.yarnpkg.com/old-school/-/old-school-0.1.0.tgz#cccccccccccccccccccccccccccccccccccccccc\" + +aliased@npm:real-name@^3.0.0: + version \"3.0.0\" + resolved \"https://registry.yarnpkg.com/real-name/-/real-name-3.0.0.tgz#dddddddddddddddddddddddddddddddddddddddd\" + integrity sha512-alias== +"; + +#[tokio::test] +async fn yarn_classic_blocks_yield_resolved_sha1_and_integrity() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "yarn.lock", YARN_CLASSIC).await; + + let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); + assert_eq!(flavor, NpmLockFlavor::YarnClassic); + + let lp = entry(&entries, "left-pad"); + assert_eq!( + lp.resolved.as_deref(), + Some("https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz"), + "the #sha1 fragment is split off the URL" + ); + assert_eq!(lp.integrity, LockIntegrity::Sri("sha512-XI5MPz==".into())); + + // Integrity-less old locks fall back to the sha1 fragment. + assert_eq!( + entry(&entries, "old-school").integrity, + LockIntegrity::Sha1Hex("c".repeat(40)) + ); + + // `alias@npm:real@range` resolves to the real name. + assert!(entries.iter().any(|e| e.name == "real-name")); + assert_eq!(entry(&entries, "@scope/pkg").version, "2.0.0"); +} + +// ── yarn berry ──────────────────────────────────────────────────────── + +const YARN_BERRY: &str = "# This file is generated by running \"yarn install\" inside your project. +# Manifest files (package.json) are also used. + +__metadata: + version: 8 + cacheKey: 10c0 + +\"fixture@workspace:.\": + version: 0.0.0-use.local + resolution: \"fixture@workspace:.\" + languageName: unknown + linkType: soft + +\"left-pad@npm:1.3.0\": + version: 1.3.0 + resolution: \"left-pad@npm:1.3.0\" + checksum: 10c0/deadbeefcafe== + languageName: node + linkType: hard + +\"@scope/pkg@npm:^2.0.0\": + version: 2.0.0 + resolution: \"@scope/pkg@npm:2.0.0\" + checksum: 10c0/scopedchecksum== + languageName: node + linkType: hard +"; + +#[tokio::test] +async fn yarn_berry_registry_resolutions_inventory_with_checksums() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "yarn.lock", YARN_BERRY).await; + + let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); + assert_eq!(flavor, NpmLockFlavor::YarnBerry); + + let lp = entry(&entries, "left-pad"); + assert_eq!(lp.version, "1.3.0"); + assert_eq!( + lp.integrity, + LockIntegrity::BerryChecksum("10c0/deadbeefcafe==".into()) + ); + assert_eq!(entry(&entries, "@scope/pkg").version, "2.0.0"); + // The workspace root is not a registry package. + assert!(!entries.iter().any(|e| e.name == "fixture"), "{entries:?}"); +} + +// ── bun ─────────────────────────────────────────────────────────────── + +const BUN_LOCK: &str = r#"{ + "lockfileVersion": 1, + "workspaces": { + "": { "name": "fixture", "dependencies": { "left-pad": "1.3.0" } }, + }, + "packages": { + "left-pad": ["left-pad@1.3.0", "", {}, "sha512-XI5MPz=="], + "@scope/pkg": ["@scope/pkg@2.0.0", "", {}, "sha512-scoped=="], + "vendored": ["vendored@file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/vendored-3.0.0.tgz", {}], + "linked": ["linked@workspace:packages/linked", {}], + "consumer": ["consumer@workspace:packages/consumer", { "dependencies": { "left-pad": "1.3.0" } }], + } +} +"#; + +#[tokio::test] +async fn bun_registry_tuples_parse_and_locals_are_skipped() { + // lockfileVersion 0 (bun 1.1.39–1.1.45 text opt-in), 1 (bun 1.2/1.3) + // and 2 (bun 1.4) share one registry-tuple grammar, so inventory + // must read all three identically. The workspace entries carry the + // real v0 spelling — a 2-tuple with the member's deps object + // (`{}` when dep-less) — and are skipped like every non-registry + // shape. + for version in [0u64, 1, 2] { + let lock = BUN_LOCK.replace( + "\"lockfileVersion\": 1,", + &format!("\"lockfileVersion\": {version},"), + ); + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "bun.lock", &lock).await; + + let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); + assert_eq!(flavor, NpmLockFlavor::Bun); + + assert_eq!( + entry(&entries, "left-pad").integrity, + LockIntegrity::Sri("sha512-XI5MPz==".into()), + "lockfileVersion {version}" + ); + assert_eq!(entry(&entries, "left-pad").resolved, None); + assert_eq!(entry(&entries, "@scope/pkg").version, "2.0.0"); + for absent in ["vendored", "linked", "consumer"] { + assert!( + !entries.iter().any(|e| e.name == absent), + "lockfileVersion {version}: `{absent}` must be skipped: {entries:?}" + ); + } + assert_eq!(entries.len(), 2, "lockfileVersion {version}: {entries:?}"); + } + + // An unsupported lockfileVersion (a future 3) yields no inventory at + // all — fail closed, same posture as the vendor/redirect gates. + let lock = BUN_LOCK.replace("\"lockfileVersion\": 1,", "\"lockfileVersion\": 3,"); + assert_ne!(lock, BUN_LOCK, "replacement must hit"); + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "bun.lock", &lock).await; + assert!( + inventory_npm_lock(tmp.path()).await.unwrap().is_none(), + "a lockfileVersion-3 bun.lock must not be inventoried" + ); +} + +// ── shared semantics ────────────────────────────────────────────────── + +#[tokio::test] +async fn lookup_bridges_percent_encoded_purls() { + let entries = vec![ + LockfileEntry::npm("@scope/pkg", "2.0.0", None, LockIntegrity::None), + LockfileEntry::npm("left-pad", "1.3.0", None, LockIntegrity::None), + ]; + assert!(lookup(&entries, "pkg:npm/%40scope/pkg@2.0.0").is_some()); + assert!(lookup(&entries, "pkg:npm/@scope/pkg@2.0.0").is_some()); + assert!(lookup(&entries, "pkg:npm/left-pad@1.3.0?artifact_id=x").is_some()); + assert!(lookup(&entries, "pkg:npm/left-pad@9.9.9").is_none()); + assert!(lookup(&entries, "pkg:pypi/left-pad@1.3.0").is_none()); +} + +#[tokio::test] +async fn dedup_prefers_integrity_bearing_instance() { + let raw = vec![ + LockfileEntry::npm("dup", "1.0.0", None, LockIntegrity::None), + LockfileEntry::npm( + "dup", + "1.0.0", + None, + LockIntegrity::Sri("sha512-x==".into()), + ), + LockfileEntry::npm("dup", "1.0.0", None, LockIntegrity::None), + ]; + let out = finalize_npm(raw); + assert_eq!(out.len(), 1); + assert_eq!(out[0].integrity, LockIntegrity::Sri("sha512-x==".into())); +} + +#[tokio::test] +async fn cargo_lock_inventories_crates_io_entries() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "Cargo.lock", + r#"# This file is automatically @generated by Cargo. +version = 4 + +[[package]] +name = "fixture" +version = "0.1.0" + +[[package]] +name = "serde" +version = "1.0.200" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc6f9cc94d67c0e21aaf7eda3a010fd3af78ebf6e096aa6e2e13c79749cce4f" + +[[package]] +name = "git-dep" +version = "0.5.0" +source = "git+https://github.com/x/git-dep?rev=abc#abc" + +[[package]] +name = "sparse-crate" +version = "2.0.0" +source = "sparse+https://index.crates.io/" +checksum = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +"#, + ) + .await; + + let entries = inventory_cargo_lock(tmp.path()).await.unwrap(); + let serde_entry = entry(&entries, "serde"); + assert_eq!(serde_entry.version, "1.0.200"); + assert_eq!(serde_entry.purl, "pkg:cargo/serde@1.0.200"); + assert_eq!( + serde_entry.integrity, + LockIntegrity::Sha256Hex( + "ddc6f9cc94d67c0e21aaf7eda3a010fd3af78ebf6e096aa6e2e13c79749cce4f".into() + ) + ); + assert!(matches!( + entry(&entries, "sparse-crate").integrity, + LockIntegrity::Sha256Hex(_) + )); + // Workspace member (no source) excluded; git source unverifiable. + assert!(!entries.iter().any(|e| e.name == "fixture")); + assert_eq!(entry(&entries, "git-dep").integrity, LockIntegrity::None); +} + +#[tokio::test] +async fn go_sum_inventories_module_zip_lines() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "go.sum", + "github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=\n\ + github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=\n\ + golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=\n", + ) + .await; + + let entries = inventory_go_sum(tmp.path()).await.unwrap(); + assert_eq!(entries.len(), 2, "the /go.mod line is skipped: {entries:?}"); + let gin = entry(&entries, "github.com/gin-gonic/gin"); + assert_eq!(gin.version, "v1.9.1"); + assert_eq!(gin.purl, "pkg:golang/github.com/gin-gonic/gin@v1.9.1"); + assert_eq!( + gin.integrity, + LockIntegrity::GoH1("h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=".into()) + ); +} + +#[tokio::test] +async fn lookup_matches_cargo_and_golang_purls() { + let entries = vec![ + LockfileEntry { + ecosystem: "cargo", + name: "serde".into(), + version: "1.0.200".into(), + purl: "pkg:cargo/serde@1.0.200".into(), + resolved: None, + integrity: LockIntegrity::None, + }, + LockfileEntry { + ecosystem: "golang", + name: "github.com/x/y".into(), + version: "v1.0.0".into(), + purl: "pkg:golang/github.com/x/y@v1.0.0".into(), + resolved: None, + integrity: LockIntegrity::None, + }, + ]; + assert!(lookup(&entries, "pkg:cargo/serde@1.0.200").is_some()); + assert!(lookup(&entries, "pkg:golang/github.com/x/y@v1.0.0").is_some()); + assert!(lookup(&entries, "pkg:cargo/serde@9.9.9").is_none()); + assert!( + lookup(&entries, "pkg:npm/serde@1.0.200").is_none(), + "ecosystem tags must match, not just name@version" + ); +} + +#[tokio::test] +async fn composer_lock_inventories_dist_entries() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "composer.lock", + r#"{ + "packages": [ + { + "name": "Monolog/Monolog", + "version": "v3.5.0", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/abc", + "shasum": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + } + }, + { + "name": "vendored/pkg", + "version": "1.0.0", + "dist": { "type": "path", "url": ".socket/vendor/composer/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/vendored/pkg@1.0.0" } + } + ], + "packages-dev": [ + { + "name": "symfony/console", + "version": "v6.4.1", + "dist": { "type": "zip", "url": "https://example.com/console.zip", "shasum": "" } + } + ] +}"#, + ) + .await; + + let entries = inventory_composer_lock(tmp.path()).await.unwrap(); + let monolog = entry(&entries, "monolog/monolog"); + assert_eq!( + monolog.version, "3.5.0", + "leading v dropped, name lowercased" + ); + assert_eq!(monolog.purl, "pkg:composer/monolog/monolog@3.5.0"); + assert!(matches!(monolog.integrity, LockIntegrity::Sha1Hex(_))); + assert!(monolog.resolved.as_deref().unwrap().contains("zipball")); + // Empty shasum → discovery-only; path dist (ours) excluded. + assert_eq!( + entry(&entries, "symfony/console").integrity, + LockIntegrity::None + ); + assert!(!entries.iter().any(|e| e.name == "vendored/pkg")); +} + +#[tokio::test] +async fn gemfile_lock_inventories_specs_and_checksums() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "Gemfile.lock", + "GEM\n remote: https://rubygems.org/\n specs:\n rails (7.1.0)\n \ + actionpack (= 7.1.0)\n rack (3.0.8)\n nokogiri (1.16.5-arm64-darwin)\n\n\ + PLATFORMS\n ruby\n\nDEPENDENCIES\n rails\n\nCHECKSUMS\n \ + rails (7.1.0) sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n\n\ + BUNDLED WITH\n 2.6.0\n", + ) + .await; + + let entries = inventory_gemfile_lock(tmp.path()).await.unwrap(); + let rails = entry(&entries, "rails"); + assert_eq!(rails.version, "7.1.0"); + assert_eq!(rails.purl, "pkg:gem/rails@7.1.0"); + assert!(matches!(rails.integrity, LockIntegrity::Sha256Hex(_))); + assert_eq!( + rails.resolved.as_deref(), + Some("https://rubygems.org/downloads/rails-7.1.0.gem") + ); + // No CHECKSUMS entry → discovery-only; platform gem skipped; + // dependency range lines never parse as specs. + assert_eq!(entry(&entries, "rack").integrity, LockIntegrity::None); + assert!(!entries.iter().any(|e| e.name == "nokogiri")); + assert!(!entries.iter().any(|e| e.name == "actionpack")); +} + +/// Multi-source lock (two GEM sections, the exact shape bundler 4.0.15 +/// writes for a Gemfile `source … do` block — fixture mirrors a real +/// `bundle lock --add-checksums` run): each spec must resolve against +/// its OWN section's remote, never the first remote in the file. +#[tokio::test] +async fn gemfile_lock_multi_source_resolves_each_spec_against_its_own_remote() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "Gemfile.lock", + &format!( + "GEM\n remote: https://gems.corp.example/\n specs:\n private-gem (1.0.0)\n\n\ + GEM\n remote: https://rubygems.org/\n specs:\n rack (3.2.6)\n\n\ + PLATFORMS\n ruby\n\nDEPENDENCIES\n private-gem (= 1.0.0)!\n rack (= 3.2.6)\n\n\ + CHECKSUMS\n private-gem (1.0.0) sha256={}\n rack (3.2.6) sha256={}\n\n\ + BUNDLED WITH\n 4.0.15\n", + "a".repeat(64), + "b".repeat(64), + ), + ) + .await; + + let entries = inventory_gemfile_lock(tmp.path()).await.unwrap(); + assert_eq!( + entry(&entries, "private-gem").resolved.as_deref(), + Some("https://gems.corp.example/downloads/private-gem-1.0.0.gem"), + "first section's spec resolves against its own remote" + ); + assert_eq!( + entry(&entries, "rack").resolved.as_deref(), + Some("https://rubygems.org/downloads/rack-3.2.6.gem"), + "second section's spec must NOT inherit the first section's remote" + ); + // Both keep their CHECKSUMS integrity. + assert_eq!( + entry(&entries, "rack").integrity, + LockIntegrity::Sha256Hex("b".repeat(64)) + ); +} + +/// A GEM section with SEVERAL `remote:` lines is a legacy bundler 1.x +/// multisource lock (bundler ≥ 2 hard-errors on multiple global +/// sources — verified against 4.0.15): per-spec origin is ambiguous, +/// so its specs stay discovery-only — no guessed download URL, which +/// would leak private gem names to the public registry. +#[tokio::test] +async fn gemfile_lock_legacy_multi_remote_section_is_discovery_only() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "Gemfile.lock", + &format!( + "GEM\n remote: https://rubygems.org/\n remote: https://gems.corp.example/\n \ + specs:\n rack (3.0.8)\n\nPLATFORMS\n ruby\n\nDEPENDENCIES\n rack\n\n\ + CHECKSUMS\n rack (3.0.8) sha256={}\n", + "c".repeat(64), + ), + ) + .await; + + let entries = inventory_gemfile_lock(tmp.path()).await.unwrap(); + let rack = entry(&entries, "rack"); + assert_eq!( + rack.resolved, None, + "ambiguous origin must never guess a remote: {rack:?}" + ); + // Discovery + integrity survive; only the URL is withheld. + assert_eq!(rack.purl, "pkg:gem/rack@3.0.8"); + assert_eq!(rack.integrity, LockIntegrity::Sha256Hex("c".repeat(64))); +} + +#[tokio::test] +async fn inventories_script_and_pylock_files_without_installed_packages() { + let tmp = tempfile::tempdir().unwrap(); + let sha = "a".repeat(64); + write(tmp.path(), "example.py.lock", &format!("version=1\n[[package]]\nname='alpha'\nversion='1'\nsource={{registry='https://pypi.org/simple'}}\nwheels=[{{url='https://pypi.org/alpha-1-py3-none-any.whl',hash='sha256:{sha}'}}]\n")).await; + write(tmp.path(), "pylock.dev.toml", &format!("lock-version='1.0'\n[[packages]]\nname='bravo'\nversion='2'\narchive={{url='https://pypi.org/bravo-2-py3-none-any.whl',hashes={{sha256='{sha}'}}}}\n[[packages]]\nname='local'\nversion='1'\narchive={{path='.socket/vendor/pypi/uuid/local-1-py3-none-any.whl',hashes={{sha256='{sha}'}}}}\n")).await; + let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!( + entry(&entries, "alpha").integrity, + LockIntegrity::Sha256Hex(sha.clone()) + ); + assert_eq!( + entry(&entries, "bravo").integrity, + LockIntegrity::Sha256Hex(sha) + ); + assert!(!entries.iter().any(|entry| entry.name == "local")); +} + +#[tokio::test] +async fn pylock_repair_uses_the_exact_artifact_hash_and_refuses_conflicts() { + let tmp = tempfile::tempdir().unwrap(); + let path = ".socket/vendor/pypi/uuid/alpha-1-py3-none-any.whl"; + let sha = "a".repeat(64); + let pylock = format!("lock-version='1.0'\n[[packages]]\nname='alpha'\nversion='1'\narchive={{path='{path}',hashes={{sha256='{sha}'}}}}\n"); + write(tmp.path(), "pylock.toml", &pylock).await; + assert_eq!( + wired_vendor_integrity(tmp.path(), path).await, + Some(LockIntegrity::Sha256Hex(sha.clone())) + ); + assert_eq!( + wired_vendor_integrity(tmp.path(), &format!("{path}.other")).await, + None + ); + write(tmp.path(), "example.py.lock", &format!("version=1\n[[package]]\nname='alpha'\nversion='1'\nsource={{path='{path}'}}\nwheels=[{{filename='alpha-1-py3-none-any.whl',hash='sha256:{sha}'}}]\n")).await; + assert_eq!( + wired_vendor_integrity(tmp.path(), path).await, + Some(LockIntegrity::Sha256Hex(sha.clone())) + ); + write( + tmp.path(), + "pylock.toml", + &pylock.replace(&sha, &"b".repeat(64)), + ) + .await; + assert_eq!(wired_vendor_integrity(tmp.path(), path).await, None); +} + +#[test] +fn legacy_and_pep751_archive_hashes_stay_with_their_own_wheels() { + let sha = "b".repeat(64); + let legacy = format!("version=1\n[[distribution]]\nname='alpha'\nversion='1'\nsource='registry+https://pypi.org/simple'\n[[distribution.wheel]]\nurl='https://pypi.org/alpha-1-py3-none-any.whl'\nhash='sha256:{sha}'\n"); + assert_eq!( + python_lock_inventory(&legacy).unwrap()[0].integrity, + LockIntegrity::Sha256Hex(sha.clone()) + ); + let lock = format!("lock-version='1.0'\n[[packages]]\nname='alpha'\nversion='1'\nwheels=[{{url='https://pypi.org/alpha-1-py3-none-any.whl'}},{{url='https://pypi.org/alpha-1-cp312-cp312-macosx.whl',hashes={{sha256='{sha}'}}}}]\n"); + let entries = python_lock_inventory(&lock).unwrap(); + assert_eq!(entries[0].integrity, LockIntegrity::None); + assert_eq!(entries[0].resolved, None); + assert!(python_lock_inventory("version=2\n[[package]]\nname='x'\nversion='1'").is_none()); +} + +#[tokio::test] +async fn uv_lock_inventories_pure_wheels() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "uv.lock", + r#"version = 1 + +[[package]] +name = "Requests" +version = "2.28.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/requests-2.28.0-py3-none-any.whl", hash = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, +] + +[[package]] +name = "native-only" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/native_only-1.0.0-cp312-macosx.whl", hash = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" }, +] + +[[package]] +name = "local-proj" +version = "0.0.1" +source = { editable = "." } +"#, + ) + .await; + + let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); + let requests = entry(&entries, "requests"); + assert_eq!(requests.purl, "pkg:pypi/requests@2.28.0", "PEP 503 name"); + assert!(matches!(requests.integrity, LockIntegrity::Sha256Hex(_))); + assert!(requests + .resolved + .as_deref() + .unwrap() + .ends_with("py3-none-any.whl")); + // Platform-only wheels → discovery-only; editable sources excluded. + assert_eq!( + entry(&entries, "native-only").integrity, + LockIntegrity::None + ); + assert!(!entries.iter().any(|e| e.name == "local-proj")); +} + +#[tokio::test] +async fn uv_lock_one_line_wheels_array_pairs_the_pure_wheel_with_its_own_hash() { + // A one-line `wheels = […]` array (valid TOML — hand-maintained or + // formatter-collapsed locks) listing a platform wheel BEFORE the + // pure one: the entry must carry the pure wheel's url+hash, never + // the first url/hash on the line. + let tmp = tempfile::tempdir().unwrap(); + let platform_sha = "a".repeat(64); + let pure_sha = "b".repeat(64); + write( + tmp.path(), + "uv.lock", + &format!( + "version = 1\n\n[[package]]\nname = \"six\"\nversion = \"1.16.0\"\n\ + source = {{ registry = \"https://pypi.org/simple\" }}\n\ + wheels = [{{ url = \"https://files.pythonhosted.org/packages/aa/six-1.16.0-cp312-cp312-macosx_11_0_arm64.whl\", hash = \"sha256:{platform_sha}\" }}, {{ url = \"https://files.pythonhosted.org/packages/bb/six-1.16.0-py2.py3-none-any.whl\", hash = \"sha256:{pure_sha}\" }}]\n" + ), + ) + .await; + + let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); + let six = entry(&entries, "six"); + assert!( + six.resolved.as_deref().unwrap().ends_with("-none-any.whl"), + "the platform wheel must never be resolved as pure: {six:?}" + ); + assert_eq!(six.integrity, LockIntegrity::Sha256Hex(pure_sha)); +} + +#[tokio::test] +async fn poetry_and_requirements_are_discovery_only() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "poetry.lock", + "[[package]]\nname = \"Flask_Login\"\nversion = \"0.6.3\"\n\n[metadata]\nlock-version = \"2.0\"\n", + ) + .await; + let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); + let fl = entry(&entries, "flask-login"); + assert_eq!(fl.purl, "pkg:pypi/flask-login@0.6.3"); + assert_eq!(fl.integrity, LockIntegrity::None); + + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "requirements.txt", + "# pinned\nrequests[security]==2.28.0 --hash=sha256:abc \\\n --hash=sha256:def\nflask>=2.0\n-e .\n", + ) + .await; + let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); + assert_eq!(entries.len(), 1, "{entries:?}"); + assert_eq!(entries[0].purl, "pkg:pypi/requests@2.28.0"); +} + +/// Pipfile.lock: every category is read, registry pins carry the lock's +/// digest SET (lowercased), non-registry sources / range pins / a user's +/// file references are skipped while our own vendored reference stays +/// discoverable, the same package in two categories yields one entry, +/// requirements.txt is read alongside it, and a parseable uv.lock +/// outranks both. +#[tokio::test] +async fn pipfile_lock_inventory_reads_every_category_with_its_digest_set() { + let wheel = "a".repeat(64); + let sdist = "B".repeat(64); + let lock = format!( + r#"{{ + "_meta": {{"hash": {{"sha256": "x"}}, "pipfile-spec": 6, "requires": {{}}, "sources": []}}, + "default": {{ + "URLlib3": {{"hashes": ["sha256:{wheel}", "sha256:{sdist}"], "index": "pypi", "version": "==1.26.18", "markers": "python_version < '4'"}}, + "requests": {{"git": "https://example.org/requests", "ref": "abc", "version": "==2.31.0"}}, + "loose": {{"version": "*"}}, + "wired": {{"file": "./.socket/vendor/pypi/00000000-0000-4000-8000-000000000000/wired-1.0-py3-none-any.whl", "hashes": ["sha256:{wheel}"]}} + }}, + "develop": {{ + "Six": {{"hashes": ["sha256:{sdist}"], "version": "==1.16.0"}} + }}, + "tests": {{ + "urllib3": {{"hashes": ["sha256:{wheel}"], "version": "==1.26.18"}} + }} +}} +"# + ); + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "Pipfile.lock", &lock).await; + write(tmp.path(), "requirements.txt", "flask==3.0.0\n").await; + let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); + let mut names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect(); + names.sort_unstable(); + // requirements.txt is read alongside the Pipfile.lock, not hidden by + // it; our own vendored reference stays discoverable (discovery-only). + assert_eq!( + names, + vec!["flask", "six", "urllib3", "wired"], + "{entries:?}" + ); + assert_eq!(entry(&entries, "wired").integrity, LockIntegrity::None); + assert_eq!(entry(&entries, "wired").purl, "pkg:pypi/wired@1.0"); + let urllib3 = entry(&entries, "urllib3"); + assert_eq!(urllib3.purl, "pkg:pypi/urllib3@1.26.18"); + assert_eq!(urllib3.resolved, None); + assert_eq!( + urllib3.integrity, + LockIntegrity::Sha256AnyOf(vec![wheel.clone(), sdist.to_ascii_lowercase()]), + "every recorded digest, lowercased, first category wins" + ); + assert_eq!( + entry(&entries, "six").integrity, + LockIntegrity::Sha256AnyOf(vec![sdist.to_ascii_lowercase()]) + ); + + // A parseable uv.lock stays the exclusive inventory. + write( + tmp.path(), + "uv.lock", + "version = 1\n\n[[package]]\nname = \"other\"\nversion = \"1.0.0\"\nsource = { registry = \"https://pypi.org/simple\" }\n", + ) + .await; + let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); + assert!(entries.iter().all(|e| e.name == "other"), "{entries:?}"); + + // Unparseable lock → nothing from it, requirements.txt read instead. + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "Pipfile.lock", "{ not json").await; + write(tmp.path(), "requirements.txt", "flask==3.0.0\n").await; + let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].name, "flask"); + + // No hashes at all → discovery-only entry. + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "Pipfile.lock", + r#"{"_meta": {"pipfile-spec": 6}, "default": {"urllib3": {"version": "==1.26.18"}}}"#, + ) + .await; + let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); + assert_eq!(entry(&entries, "urllib3").integrity, LockIntegrity::None); +} + +/// Socket's own references in a Pipfile.lock (a hosted URL, a vendored +/// path) keep the package discoverable on a lock-only re-scan; a lock +/// whose sources are private indexes only never carries a fetchable +/// digest set (no pypi.org lookups for it). +#[tokio::test] +async fn pipfile_lock_inventory_keeps_socket_references_discoverable_and_respects_private_indexes() +{ + let hosted = r#"{"_meta": {"pipfile-spec": 6, "sources": [{"name": "pypi", "url": "https://pypi.org/simple", "verify_ssl": true}]}, +"default": { + "urllib3": {"file": "https://patch.socket.dev/patch/pypi/urllib3/1.26.18/grant/e828efa5-5c6d-43f3-9909-03f5ac232b98/urllib3-1.26.18-py2.py3-none-any.whl#sha256=cc", "hashes": ["sha256:cc"], "markers": "x"}, + "Six": {"file": "./.socket/vendor/pypi/00000000-0000-4000-8000-000000000000/six-1.16.0-py2.py3-none-any.whl", "hashes": ["sha256:dd"]}, + "fork": {"file": "./forks/fork-1.0-py3-none-any.whl"}, + "requests": {"version": "==2.31.0", "hashes": ["sha256:%s"]} +}}"#.replace("%s", &"a".repeat(64)); + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "Pipfile.lock", &hosted).await; + let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); + let mut names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect(); + names.sort_unstable(); + assert_eq!(names, vec!["requests", "six", "urllib3"], "{entries:?}"); + assert_eq!(entry(&entries, "urllib3").purl, "pkg:pypi/urllib3@1.26.18"); + assert_eq!( + entry(&entries, "urllib3").integrity, + LockIntegrity::None, + "a hosted reference is discovery-only" + ); + assert_eq!(entry(&entries, "six").purl, "pkg:pypi/six@1.16.0"); + assert_eq!(entry(&entries, "six").integrity, LockIntegrity::None); + assert!(matches!( + entry(&entries, "requests").integrity, + LockIntegrity::Sha256AnyOf(_) + )); + + // Private index only → the registry pin is discovery-only. + let private = hosted.replace( + "https://pypi.org/simple", + "https://pypi.internal.example/simple", + ); + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "Pipfile.lock", &private).await; + let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); + assert_eq!( + entry(&entries, "requests").integrity, + LockIntegrity::None, + "no pypi.org lookup for a private-index lock" + ); + // A mirror listed next to PyPI keeps the digest set. + let mixed = hosted.replace(r#"[{"name": "pypi", "url": "https://pypi.org/simple", "verify_ssl": true}]"#, r#"[{"name": "mirror", "url": "https://mirror.example/simple", "verify_ssl": true}, {"name": "pypi", "url": "https://pypi.org/simple", "verify_ssl": true}]"#); + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "Pipfile.lock", &mixed).await; + let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); + assert!(matches!( + entry(&entries, "requests").integrity, + LockIntegrity::Sha256AnyOf(_) + )); + assert!(is_public_pypi_url("https://user:tok@pypi.org/simple")); + assert!(!is_public_pypi_url("https://pypi.org.evil.example/simple")); + assert_eq!( + socket_reference_coords("./forks/fork-1.0-py3-none-any.whl"), + None + ); + assert_eq!( + socket_reference_coords("https://example.org/patch/pypi/a/1/g/u/a-1-py3-none-any.whl"), + Some(("a".into(), "1".into())) + ); +} + +/// A lock that lists a pure-Python wheel carries its sha256 (lock 2.x +/// `files`, lock 1.x `[metadata.files]`), so a lock-only checkout can +/// vendor like uv does; platform wheels only, or 0.12's bare +/// `[metadata.hashes]`, stay discovery-only. +#[tokio::test] +async fn poetry_lock_carries_the_pure_wheel_sha256_when_listed() { + let sha = "34b97092d7e0a3a8cf7cd10e386f401b3737364026c45e622aa02903dffe0f07"; + let lock2 = format!( + "[[package]]\nname = \"urllib3\"\nversion = \"1.26.18\"\nfiles = [\n {{file = \"urllib3-1.26.18.tar.gz\", hash = \"sha256:{}\"}},\n {{file = \"urllib3-1.26.18-py2.py3-none-any.whl\", hash = \"sha256:{sha}\"}},\n]\n\n[[package]]\nname = \"numpy\"\nversion = \"2.0.0\"\nfiles = [\n {{file = \"numpy-2.0.0-cp312-cp312-macosx_11_0_arm64.whl\", hash = \"sha256:{}\"}},\n]\n\n[metadata]\nlock-version = \"2.1\"\n", + "f".repeat(64), + "e".repeat(64) + ); + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "poetry.lock", &lock2).await; + let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); + assert_eq!( + entry(&entries, "urllib3").integrity, + LockIntegrity::Sha256Hex(sha.into()) + ); + assert_eq!(entry(&entries, "urllib3").resolved, None); + assert_eq!(entry(&entries, "numpy").integrity, LockIntegrity::None); + + let lock1 = format!( + "[[package]]\nname = \"urllib3\"\nversion = \"1.26.18\"\n\n[metadata]\nlock-version = \"1.1\"\n\n[metadata.files]\nurllib3 = [\n {{file = \"urllib3-1.26.18-py2.py3-none-any.whl\", hash = \"sha256:{}\"}},\n]\n", + sha.to_uppercase() + ); + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "poetry.lock", &lock1).await; + let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); + assert_eq!( + entry(&entries, "urllib3").integrity, + LockIntegrity::Sha256Hex(sha.into()), + "lowercased" + ); + + let lock0 = format!( + "[[package]]\nname = \"urllib3\"\nversion = \"1.26.18\"\n\n[metadata]\ncontent-hash = \"x\"\n\n[metadata.hashes]\nurllib3 = [\"{sha}\"]\n" + ); + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "poetry.lock", &lock0).await; + let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); + assert_eq!( + entry(&entries, "urllib3").integrity, + LockIntegrity::None, + "bare digests name no wheel" + ); +} + +#[tokio::test] +async fn pnp_layouts_propagate_the_diagnosis_instead_of_yielding_none() { + // PnP marker wins over any lockfile — and the diagnosis must + // PROPAGATE, not collapse into the calm no-lockfile `None`. Under + // yarn PnP the installed-tree crawl is also structurally empty, so + // swallowing this here made `scan` a silent success-0 no-op in + // every mode (the P0 this pins). + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), ".pnp.cjs", "/* pnp */").await; + write(tmp.path(), "package-lock.json", PACKAGE_LOCK).await; + let diag = inventory_npm_lock(tmp.path()).await.unwrap_err(); + assert_eq!(diag.code, "vendor_yarn_berry_unsupported"); + assert!(diag.detail.contains("Plug'n'Play"), "{}", diag.detail); + assert!(diag.detail.contains("yarn patch"), "{}", diag.detail); + + // pnpm's own `node-linker=pnp` twin (same loader, pnpm store): + // same channel, pnpm diagnosis. + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), ".pnp.cjs", "/* pnp */").await; + write(tmp.path(), "pnpm-lock.yaml", "lockfileVersion: '9.0'\n").await; + tokio::fs::create_dir_all(tmp.path().join("node_modules/.pnpm")) + .await + .unwrap(); + write(&tmp.path().join("node_modules"), ".modules.yaml", "").await; + let diag = inventory_npm_lock(tmp.path()).await.unwrap_err(); + assert_eq!(diag.code, "vendor_pnpm_pnp_unsupported"); + assert!(diag.detail.contains("node-linker=pnp"), "{}", diag.detail); + + // And the project-level union surfaces the same diagnosis while + // still serving the OTHER ecosystems' lockfiles. + let (entries, unsupported) = inventory_project_diagnosed(tmp.path()).await; + assert!(entries.is_empty(), "{entries:?}"); + assert_eq!(unsupported.len(), 1, "{unsupported:?}"); + assert_eq!(unsupported[0].code, "vendor_pnpm_pnp_unsupported"); +} + +#[cfg(unix)] +fn mkfifo(path: &Path) { + use std::os::unix::ffi::OsStrExt; + let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).expect("fifo path has no NUL"); + let rc = unsafe { libc::mkfifo(c_path.as_ptr(), 0o644) }; + assert_eq!( + rc, + 0, + "mkfifo(2) failed: {}", + std::io::Error::last_os_error() + ); +} + +/// A FIFO planted as any inventoried lockfile must fail fast instead of +/// wedging every consumer — scan's lockfile supplement, vendor's +/// auto-fetch, and repair's no-ledger reconstruction all read these +/// files — forever in an `open(2)` that waits for a writer that never +/// comes. Same `open_regular_file` guard class as the vendor siblings +/// (cargo_lock.rs, composer_lock.rs, gem.rs, common.rs). Inventories +/// stay fail-soft: a non-regular lockfile reads as absent. +#[cfg(unix)] +#[tokio::test] +async fn fifo_lockfiles_fail_fast_instead_of_wedging() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().to_path_buf(); + // Every filename this module opens: the per-ecosystem inventories, + // the npm-family readers (reached without the flavor probe touching + // the same file via the shrinkwrap/sibling/rush fallbacks), and + // wired_vendor_integrity (no probe at all). + let names = [ + "Cargo.lock", + "go.sum", + "composer.lock", + "Gemfile.lock", + "uv.lock", + "poetry.lock", + "requirements.txt", + "npm-shrinkwrap.json", + "package-lock.json", + "pnpm-lock.yaml", + "yarn.lock", + "bun.lock", + "shrinkwrap.yaml", + ]; + for name in names { + mkfifo(&root.join(name)); + } + + // On timeout the open is wedged in a `spawn_blocking` thread that + // the runtime waits for on shutdown; connect a non-blocking writer + // to release it so the test can FAIL instead of hanging the suite. + let deadline = std::time::Duration::from_secs(5); + let all = async { + ( + inventory_cargo_lock(&root).await, + inventory_go_sum(&root).await, + inventory_composer_lock(&root).await, + inventory_gemfile_lock(&root).await, + inventory_pypi_locks(&root).await, + inventory_package_lock(&root).await, + inventory_pnpm_lock(&root).await, + inventory_yarn_classic(&root).await, + inventory_yarn_berry(&root).await, + inventory_bun(&root).await, + inventory_pnpm_lock_at(&root.join("shrinkwrap.yaml")).await, + gem_remotes(&root).await, + wired_vendor_integrity(&root, ".socket/vendor/npm/x/x.tgz").await, + ) + }; + let Ok(results) = tokio::time::timeout(deadline, all).await else { + for name in names { + use std::os::unix::fs::OpenOptionsExt; + let _ = std::fs::OpenOptions::new() + .write(true) + .custom_flags(libc::O_NONBLOCK) + .open(root.join(name)); + } + panic!("lockfile inventories must fail fast on FIFO lockfiles"); + }; + let (cargo, go, composer, gem, pypi, npm, pnpm, yarn_c, yarn_b, bun, legacy, remotes, wired) = + results; + for (label, opt) in [ + ("cargo", cargo), + ("go", go), + ("composer", composer), + ("gem", gem), + ("pypi", pypi), + ("npm", npm), + ("pnpm", pnpm), + ("yarn classic", yarn_c), + ("yarn berry", yarn_b), + ("bun", bun), + ("pnpm legacy", legacy), + ] { + assert!( + opt.is_none(), + "{label}: a FIFO lockfile must read as absent" + ); + } + assert!(remotes.is_empty(), "{remotes:?}"); + assert!(wired.is_none(), "{wired:?}"); +} + +#[tokio::test] +async fn unsupported_flavors_yield_none() { + // pnpm v6.0: the probe passes it (the 6.0 grammar has a wiring + // backend), and a dep-less lock inventories to nothing — the calm + // None, not a refusal. + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "pnpm-lock.yaml", "lockfileVersion: '6.0'\n").await; + assert!(inventory_npm_lock(tmp.path()).await.unwrap().is_none()); + + // No lockfile at all. + let tmp = tempfile::tempdir().unwrap(); + assert!(inventory_npm_lock(tmp.path()).await.unwrap().is_none()); + let (entries, unsupported) = inventory_project_diagnosed(tmp.path()).await; + assert!(entries.is_empty()); + assert!(unsupported.is_empty(), "{unsupported:?}"); +} + +/// A version-refused pnpm lock (pnpm 6 wrote 5.3) with NO live sibling +/// and NO dependencies at all: the direct read yields nothing, and the +/// fall-through past it must land on the calm `Ok(None)` via the rush +/// check — never a phantom inventory and never an error. +#[tokio::test] +async fn version_refused_depless_pnpm_lock_yields_calm_none() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "pnpm-lock.yaml", "lockfileVersion: 5.3\n").await; + assert!( + inventory_npm_lock(tmp.path()).await.unwrap().is_none(), + "a dep-less version-refused pnpm lock must inventory to the calm None" + ); +} + +/// The union entrypoint reads EVERY ecosystem's lock out of one polyglot +/// root — each per-ecosystem reader is unit-covered, but the union arms +/// (go.sum, pypi, …) only execute here. `lookup` bridges one purl per +/// ecosystem, and an unknown purl type (nuget has no lock inventory) +/// yields None instead of a cross-ecosystem false match. +#[tokio::test] +async fn inventory_project_unions_every_ecosystem_lock() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "package-lock.json", PACKAGE_LOCK).await; + write( + tmp.path(), + "Cargo.lock", + "[[package]]\nname = \"serde\"\nversion = \"1.0.200\"\n\ + source = \"registry+https://github.com/rust-lang/crates.io-index\"\n\ + checksum = \"ddc6f9cc94d67c0e21aaf7eda3a010fd3af78ebf6e096aa6e2e13c79749cce4f\"\n", + ) + .await; + write( + tmp.path(), + "go.sum", + "github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=\n", + ) + .await; + write( + tmp.path(), + "composer.lock", + r#"{ "packages": [ { "name": "Monolog/Monolog", "version": "v3.5.0", + "dist": { "type": "zip", "url": "https://example.com/monolog.zip", + "shasum": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" } } ] }"#, + ) + .await; + write( + tmp.path(), + "Gemfile.lock", + "GEM\n remote: https://rubygems.org/\n specs:\n rails (7.1.0)\n", + ) + .await; + write(tmp.path(), "requirements.txt", "requests==2.31.0\n").await; + + let (entries, unsupported) = inventory_project_diagnosed(tmp.path()).await; + assert!(unsupported.is_empty(), "{unsupported:?}"); + for purl in [ + "pkg:npm/left-pad@1.3.0", + "pkg:cargo/serde@1.0.200", + "pkg:golang/github.com/gin-gonic/gin@v1.9.1", + "pkg:composer/monolog/monolog@3.5.0", + "pkg:gem/rails@7.1.0", + "pkg:pypi/requests@2.31.0", + ] { + assert!( + lookup(&entries, purl).is_some(), + "the union must serve {purl}: {entries:?}" + ); + } + // Unknown purl type: no inventory ever answers for nuget. + assert!( + lookup(&entries, "pkg:nuget/Newtonsoft.Json@13.0.1").is_none(), + "an unrecognized purl type must never match: {entries:?}" + ); +} + +/// The `[metadata]` tail of a v1-era Cargo.lock flushes the in-flight +/// block (its key=value lines must not bleed a foreign checksum into the +/// LAST package), and an unsafe name is dropped fail-closed — the +/// lockfile is committed, tamperable input feeding paths/URLs. +#[tokio::test] +async fn cargo_lock_metadata_section_flushes_and_unsafe_name_drops() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "Cargo.lock", + &format!( + "version = 3\n\n\ + [[package]]\nname = \"../evil\"\nversion = \"1.0.0\"\n\ + source = \"registry+https://github.com/rust-lang/crates.io-index\"\n\ + checksum = \"{}\"\n\n\ + [[package]]\nname = \"serde\"\nversion = \"1.0.200\"\n\ + source = \"registry+https://github.com/rust-lang/crates.io-index\"\n\ + checksum = \"{}\"\n\n\ + [metadata]\n\ + \"checksum foo 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)\" = \"{}\"\n", + "a".repeat(64), + "d".repeat(64), + "b".repeat(64), + ), + ) + .await; + + let entries = inventory_cargo_lock(tmp.path()).await.unwrap(); + assert_eq!( + entries.len(), + 1, + "only the safe crates.io package inventories: {entries:?}" + ); + let serde_entry = entry(&entries, "serde"); + assert_eq!( + serde_entry.integrity, + LockIntegrity::Sha256Hex("d".repeat(64)), + "the [metadata] line's checksum must not bleed into the last block" + ); + assert!( + !entries.iter().any(|e| e.name.contains("..")), + "{entries:?}" + ); + assert!(!entries.iter().any(|e| e.name == "foo"), "{entries:?}"); +} + +/// go.sum lines with fewer than 3 fields are skipped, and unsafe module +/// paths / versions are dropped fail-closed (SECURITY: both feed +/// filesystem paths and download URLs). +#[tokio::test] +async fn go_sum_skips_short_and_unsafe_lines() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "go.sum", + "lonely\n\ + example.com/../up v1.0.0 h1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n\ + example.com/mod v1.0.0/../x h1:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\n\ + golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=\n", + ) + .await; + + let entries = inventory_go_sum(tmp.path()).await.unwrap(); + assert_eq!( + entries.len(), + 1, + "short and unsafe lines must be skipped: {entries:?}" + ); + assert_eq!(entries[0].name, "golang.org/x/text"); + assert!( + !entries + .iter() + .any(|e| e.name.contains("..") || e.version.contains("..")), + "{entries:?}" + ); +} + +/// shrinkwrap.yaml BLOCK-mapped `resolution:` with a `tarball:` child +/// AND a following shallower-indented field: the mapping must terminate +/// at the shallower line (the SHRINKWRAP_YAML fixture happens to put +/// resolution last in every entry, so the terminator never ran) and the +/// tarball child must be captured as the resolved URL. +#[tokio::test] +async fn shrinkwrap_block_mapped_tarball_reads_and_stops_at_shallower_indent() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "shrinkwrap.yaml", + "dependencies: + left-pad: 1.3.0 +packages: + /left-pad/1.3.0: + resolution: + integrity: sha512-blockmapped== + tarball: https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz + dev: false +registry: 'https://registry.npmjs.org/' +shrinkwrapVersion: 3 +", + ) + .await; + + let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); + assert_eq!(flavor, NpmLockFlavor::PnpmLegacy); + let lp = entry(&entries, "left-pad"); + assert_eq!( + lp.resolved.as_deref(), + Some("https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz"), + "the block-mapped tarball child must be captured" + ); + assert_eq!( + lp.integrity, + LockIntegrity::Sri("sha512-blockmapped==".into()), + "the shallower `dev:` line must terminate the mapping without eating fields" + ); +} + +/// A registry-shaped pnpm key (digit version) whose resolution tarball +/// points into `.socket/vendor/` is OUR OWN vendored artifact, not a +/// registry dependency — self-exclusion fail-closed. (Rewired v9 locks +/// are keyed `name@file:…` and die at the digit check instead, but a +/// crafted or v5.4-era lock can present exactly this shape.) +#[tokio::test] +async fn pnpm_registry_keyed_entry_with_vendored_tarball_is_skipped() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "pnpm-lock.yaml", + "lockfileVersion: '6.0' + +packages: + + /left-pad@1.3.0: + resolution: {integrity: sha512-x==, tarball: file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz} + + /other@2.0.0: + resolution: {integrity: sha512-y==} +", + ) + .await; + + let (_, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); + assert!( + !entries.iter().any(|e| e.name == "left-pad"), + "a vendored tarball must self-exclude even behind a registry key: {entries:?}" + ); + assert_eq!( + entry(&entries, "other").integrity, + LockIntegrity::Sri("sha512-y==".into()) + ); +} + +/// Real classic-lock degenerations: a `resolved` URL without the legacy +/// `#sha1` fragment (registries that strip fragments) and a block with +/// no `resolved` at all (offline-pruned locks). Both stay listed for +/// discovery with no verifier — never dropped, never guessed. +#[tokio::test] +async fn yarn_classic_fragmentless_and_resolvedless_blocks_stay_discovery_only() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "yarn.lock", + "# yarn lockfile v1\n\n\ + no-fragment@^1.0.0:\n version \"1.0.0\"\n \ + resolved \"https://registry.npmjs.org/no-fragment/-/no-fragment-1.0.0.tgz\"\n\n\ + no-resolved@^2.0.0:\n version \"2.0.0\"\n", + ) + .await; + + let entries = inventory_yarn_classic(tmp.path()).await.unwrap(); + let nf = entry(&entries, "no-fragment"); + assert_eq!( + nf.resolved.as_deref(), + Some("https://registry.npmjs.org/no-fragment/-/no-fragment-1.0.0.tgz"), + "a fragmentless URL is still a usable artifact URL" + ); + assert_eq!(nf.integrity, LockIntegrity::None); + let nr = entry(&entries, "no-resolved"); + assert_eq!(nr.resolved, None); + assert_eq!(nr.integrity, LockIntegrity::None); +} + +/// bun.lock is attacker-shaped committed input; each malformed 4-tuple +/// (undecodable spec/registry/integrity elements, unsplittable spec, +/// non-registry version) is skipped fail-soft — the well-formed entry +/// still inventories and no malformed one leaks through. +#[tokio::test] +async fn bun_malformed_tuples_are_skipped() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "bun.lock", + r#"{ + "lockfileVersion": 1, + "workspaces": { + "": { "name": "fixture", "dependencies": { "left-pad": "1.3.0" } }, + }, + "packages": { + "left-pad": ["left-pad@1.3.0", "", {}, "sha512-XI5MPz=="], + "bad-elem0": [123, "", {}, "sha512-a=="], + "noat": ["noatsign", "", {}, "sha512-b=="], + "wsdep": ["wsdep@workspace:*", "", {}, "sha512-c=="], + "badreg": ["badreg@1.0.0", 42, {}, "sha512-d=="], + "badint": ["badint@1.0.0", "", {}, 99], + } +} +"#, + ) + .await; + + let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); + assert_eq!(flavor, NpmLockFlavor::Bun); + assert_eq!( + sorted_pairs(&entries), + vec![("left-pad".into(), "1.3.0".into())], + "every malformed tuple must be skipped, the good one kept" + ); +} + +/// composer.lock packages missing a name or version are skipped, and +/// names that are unsafe or not `vendor/pkg`-shaped are dropped +/// fail-closed (SECURITY: they feed paths and download URLs). +#[tokio::test] +async fn composer_lock_drops_nameless_versionless_and_unsafe_packages() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "composer.lock", + r#"{ + "packages": [ + { "version": "1.0.0" }, + { "name": "nameless/partner" }, + { "name": "singleseg", "version": "1.0.0" }, + { "name": "a/../b", "version": "1.0.0" }, + { "name": "good/pkg", "version": "1.0.0" } + ] +}"#, + ) + .await; + + let entries = inventory_composer_lock(tmp.path()).await.unwrap(); + assert_eq!( + entries.len(), + 1, + "only the well-formed safe package inventories: {entries:?}" + ); + assert_eq!(entries[0].purl, "pkg:composer/good/pkg@1.0.0"); +} + +/// The pre-multisource Gemfile.lock shape: ONE remote-less GEM section +/// defaults to rubygems.org. Rides along: an unsafe spec name is dropped +/// fail-closed, and a CHECKSUMS value that is not 64-hex is ignored (the +/// entry stays discovery-fetchable but unverified — LockIntegrity::None). +#[tokio::test] +async fn gemfile_lock_remoteless_single_section_defaults_to_rubygems() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "Gemfile.lock", + "GEM\n specs:\n rake (13.0.6)\n ../evil (1.0.0)\n\n\ + CHECKSUMS\n rake (13.0.6) sha256=zznothexzznothexzznothexzznothex\n", + ) + .await; + + let entries = inventory_gemfile_lock(tmp.path()).await.unwrap(); + assert_eq!( + entries.len(), + 1, + "the unsafe spec name must be dropped: {entries:?}" + ); + let rake = entry(&entries, "rake"); + assert_eq!( + rake.resolved.as_deref(), + Some("https://rubygems.org/downloads/rake-13.0.6.gem"), + "a lone remote-less GEM section defaults to rubygems.org" + ); + assert_eq!( + rake.integrity, + LockIntegrity::None, + "a non-64-hex CHECKSUMS value must be ignored" + ); +} + +/// A poetry.lock with zero `[[package]]` blocks yields None, which +/// routes `inventory_pypi_locks` onward to requirements.txt — where a +/// non-digit-version pin is guard-dropped; and a requirements.txt with +/// no `==` pin at all yields None. +#[tokio::test] +async fn depless_poetry_lock_falls_through_to_requirements() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "poetry.lock", + "[metadata]\nlock-version = \"2.0\"\n", + ) + .await; + write( + tmp.path(), + "requirements.txt", + "requests==2.31.0\nbad==vNaN\n", + ) + .await; + let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); + assert_eq!( + sorted_pairs(&entries), + vec![("requests".into(), "2.31.0".into())], + "a package-less poetry.lock must route onward; the vNaN pin is dropped" + ); + + // No exact pin anywhere: the calm None, not an empty inventory. + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "requirements.txt", + "# comment\n-r other.txt\nflask>=2.0\n", + ) + .await; + assert!(inventory_pypi_locks(tmp.path()).await.is_none()); +} + +/// `pure_wheel_from_uv_unit` rejection fall-throughs: a pure wheel whose +/// hash is not 64-hex, one with no hash at all, and one whose URL is not +/// http(s) all yield None — fail-closed, never a guessed pairing. +#[tokio::test] +async fn pure_wheel_rejects_short_hash_missing_hash_and_non_http_url() { + let short = "wheels = [{ url = \"https://h/x-1.0-py3-none-any.whl\", hash = \"sha256:abcd\" }]"; + assert_eq!(pure_wheel_from_uv_unit(short), None, "short hash"); + + let hashless = "wheels = [{ url = \"https://h/x-1.0-py3-none-any.whl\" }]"; + assert_eq!(pure_wheel_from_uv_unit(hashless), None, "no hash"); + + let ftp = format!( + "wheels = [{{ url = \"ftp://h/x-1.0-py3-none-any.whl\", hash = \"sha256:{}\" }}]", + "a".repeat(64) + ); + assert_eq!(pure_wheel_from_uv_unit(&ftp), None, "non-http url"); +} + +/// The yarn-classic `integrity ` branch of `wired_vendor_integrity` +/// — the trust anchor for repair's no-ledger reconstruction on +/// yarn-classic projects (rewired classic locks carry exactly this +/// line). Rides along fail-soft: an unparseable JSON lock and a v1 lock +/// without a `packages` map are both skipped, not fatal. +#[tokio::test] +async fn wired_vendor_integrity_reads_rewired_yarn_classic_and_skips_bad_json_locks() { + let tmp = tempfile::tempdir().unwrap(); + let rel = ".socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz"; + // Unparseable JSON lock: skipped fail-soft. + write(tmp.path(), "npm-shrinkwrap.json", "not json").await; + // v1 lock without a packages map: skipped fail-soft. + write( + tmp.path(), + "package-lock.json", + r#"{"lockfileVersion":1,"dependencies":{}}"#, + ) + .await; + // The rewired classic block, exactly as yarn_classic_lock rewires it. + write( + tmp.path(), + "yarn.lock", + &format!( + "# yarn lockfile v1\n\n\ + \"left-pad@file:./{rel}\":\n \ + version \"1.3.0\"\n \ + resolved \"file:./{rel}#0000000000000000000000000000000000000000\"\n \ + integrity sha512-ours==\n" + ), + ) + .await; + + assert_eq!( + wired_vendor_integrity(tmp.path(), rel).await, + Some(LockIntegrity::Sri("sha512-ours==".into())), + "the classic `integrity ` line is the wired trust anchor" + ); +} diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/wired.rs b/crates/socket-patch-core/src/vendor/lock_inventory/wired.rs new file mode 100644 index 00000000..7c55691c --- /dev/null +++ b/crates/socket-patch-core/src/vendor/lock_inventory/wired.rs @@ -0,0 +1,192 @@ +//! The integrity a rewired lockfile records for a vendored artifact +//! ([`wired_vendor_integrity`]). + +use std::path::Path; + +use toml_edit::{DocumentMut, Item, Value as TomlValue}; + +use crate::utils::fs::{read_regular_to_bytes, read_regular_to_string}; + +use super::recover::{inline_yaml_field, looks_like_sri}; +use super::{is_hex_of_len, LockIntegrity}; + +/// The integrity the REWIRED npm-family lockfile records for a vendored +/// artifact at `artifact_rel` (forward-slashed, no `./` prefix). This is +/// the integrity of OUR deterministically packed tarball — the trust +/// anchor for repair's no-ledger reconstruction: a rebuilt tarball that +/// matches it is exactly what the package manager would have installed. +/// +/// package-lock/shrinkwrap are parsed as JSON; the text formats (pnpm, +/// yarn classic/berry, bun) are scanned with a bounded forward window from +/// each reference line. +pub async fn wired_vendor_integrity( + project_root: &Path, + artifact_rel: &str, +) -> Option { + let rel = artifact_rel.trim_start_matches("./"); + + if rel.starts_with(".socket/vendor/pypi/") { + let mut pinned = None; + for path in crate::utils::python_lock::python_lock_paths(project_root).ok()? { + let Ok(text) = read_regular_to_string(&project_root.join(path)).await else { + continue; + }; + let Ok(document) = text.parse::() else { + continue; + }; + let collection = if document.contains_key("lock-version") { + "packages" + } else { + "package" + }; + let Some(packages) = document.get(collection).and_then(Item::as_array_of_tables) else { + continue; + }; + for package in packages.iter() { + let archive = package.get("archive").and_then(Item::as_table_like); + let source = + archive.or_else(|| package.get("source").and_then(Item::as_table_like)); + if source + .and_then(|source| source.get("path")) + .and_then(Item::as_str) + .is_none_or(|path| path.trim_start_matches("./") != rel) + { + continue; + } + let sha = if let Some(archive) = archive { + archive + .get("hashes") + .and_then(Item::as_table_like) + .and_then(|hashes| hashes.get("sha256")) + .and_then(Item::as_str) + } else { + package + .get("wheels") + .and_then(Item::as_array) + .and_then(|wheels| { + wheels + .iter() + .filter_map(TomlValue::as_inline_table) + .find_map(|wheel| { + if wheel.get("filename").and_then(TomlValue::as_str) + != rel.rsplit('/').next() + { + return None; + } + wheel + .get("hash") + .and_then(TomlValue::as_str) + .and_then(|value| value.strip_prefix("sha256:")) + }) + }) + }; + let sha = sha + .filter(|sha| is_hex_of_len(sha, 64)) + .map(str::to_ascii_lowercase)?; + if pinned.as_ref().is_some_and(|previous| previous != &sha) { + return None; + } + pinned = Some(sha); + } + } + return pinned.map(LockIntegrity::Sha256Hex); + } + + // Read active binary resolution records, never the append-only string + // pool: it can retain paths and digests from earlier patch generations. + if tokio::fs::symlink_metadata(project_root.join("bun.lock")) + .await + .is_err() + { + if let Ok(bytes) = read_regular_to_bytes(&project_root.join("bun.lockb")).await { + if let Ok(lock) = crate::vendor::bun_lockb::BunLockb::parse(&bytes) { + if let Ok(packages) = lock.packages() { + let mut pinned: Option = None; + for package in packages { + if package + .resolution + .trim_start_matches("file:") + .trim_start_matches("./") + != rel + { + continue; + } + let sri = package.integrity.filter(|sri| looks_like_sri(sri))?; + if pinned.as_ref().is_some_and(|previous| previous != &sri) { + return None; + } + pinned = Some(sri); + } + if let Some(sri) = pinned { + return Some(LockIntegrity::Sri(sri)); + } + } + } + } + } + + // JSON locks: resolved == "file:" (npm writes exactly this form). + for lock in ["npm-shrinkwrap.json", "package-lock.json"] { + let Ok(bytes) = read_regular_to_bytes(&project_root.join(lock)).await else { + continue; + }; + let Ok(v) = serde_json::from_slice::(&bytes) else { + continue; + }; + if let Some(pkgs) = v.get("packages").and_then(serde_json::Value::as_object) { + for entry in pkgs.values() { + let resolved = entry.get("resolved").and_then(serde_json::Value::as_str); + if resolved.is_some_and(|r| r.trim_start_matches("file:") == rel) { + if let Some(sri) = entry + .get("integrity") + .and_then(serde_json::Value::as_str) + .filter(|s| looks_like_sri(s)) + { + return Some(LockIntegrity::Sri(sri.to_string())); + } + } + } + } + } + + // Text locks: any line referencing the artifact path, integrity within + // a short forward window (the same block). + for lock in ["pnpm-lock.yaml", "yarn.lock", "bun.lock"] { + let Ok(text) = read_regular_to_string(&project_root.join(lock)).await else { + continue; + }; + let lines: Vec<&str> = text.lines().collect(); + for (i, line) in lines.iter().enumerate() { + if !line.contains(rel) { + continue; + } + for probe in lines.iter().take((i + 6).min(lines.len())).skip(i) { + // pnpm `resolution: {integrity: …}` / classic `integrity …` + // / bun tuple `"sha512-…"`. + if let Some(v) = inline_yaml_field(probe, "integrity:") { + if looks_like_sri(&v) { + return Some(LockIntegrity::Sri(v)); + } + } + if let Some(rest) = probe.trim().strip_prefix("integrity ") { + let v = rest.trim().trim_matches('"'); + if looks_like_sri(v) { + return Some(LockIntegrity::Sri(v.to_string())); + } + } + if let Some(sri) = probe.split('"').rev().find(|tok| looks_like_sri(tok)) { + return Some(LockIntegrity::Sri(sri.to_string())); + } + // yarn berry: `checksum: 10c0/…`. + if let Some(v) = inline_yaml_field(probe, "checksum:") { + if v.split_once('/') + .is_some_and(|(k, b)| !k.is_empty() && !b.is_empty()) + { + return Some(LockIntegrity::BerryChecksum(v)); + } + } + } + } + } + None +} diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/yarn.rs b/crates/socket-patch-core/src/vendor/lock_inventory/yarn.rs new file mode 100644 index 00000000..f4f479f0 --- /dev/null +++ b/crates/socket-patch-core/src/vendor/lock_inventory/yarn.rs @@ -0,0 +1,80 @@ +//! `yarn.lock`, classic and berry: the registry views. + +use std::path::Path; + +use crate::utils::fs::read_regular_to_string; +use crate::vendor::{yarn_berry_lock, yarn_classic_lock}; + +use super::{http_url, is_hex_of_len, LockIntegrity, LockfileEntry}; + +pub(super) async fn inventory_yarn_classic(root: &Path) -> Option> { + let text = read_regular_to_string(&root.join("yarn.lock")).await.ok()?; + let mut out = Vec::new(); + for block in yarn_classic_lock::scan_blocks(&text) { + // Our own vendored block: not a registry dependency. + if yarn_classic_lock::block_points_into_vendor(&block.lines) { + continue; + } + let patterns = yarn_classic_lock::split_key_patterns(&block.key); + let Some(name) = patterns + .first() + .and_then(|p| yarn_classic_lock::pattern_real_name(p)) + else { + continue; + }; + let Some(version) = yarn_classic_lock::classic_field(&block.lines, "version") else { + continue; + }; + let resolved_raw = yarn_classic_lock::classic_field(&block.lines, "resolved"); + // `resolved "url#sha1hex"` — the fragment is the legacy verifier. + let (resolved, sha1_hex) = match resolved_raw { + Some(raw) => match raw.split_once('#') { + Some((url, frag)) => ( + http_url(url), + is_hex_of_len(frag, 40).then(|| frag.to_ascii_lowercase()), + ), + None => (http_url(raw), None), + }, + None => (None, None), + }; + let integrity = yarn_classic_lock::classic_field(&block.lines, "integrity") + .map(|i| LockIntegrity::Sri(i.to_string())) + .or(sha1_hex.map(LockIntegrity::Sha1Hex)) + .unwrap_or(LockIntegrity::None); + out.push(LockfileEntry::npm(name, version, resolved, integrity)); + } + Some(out) +} + +pub(super) async fn inventory_yarn_berry(root: &Path) -> Option> { + let text = read_regular_to_string(&root.join("yarn.lock")).await.ok()?; + let mut out = Vec::new(); + // Berry reuses classic's block grammar (same scanner the berry backend + // imports); `__metadata` and workspace/patch/file resolutions are not + // registry packages. + for block in yarn_classic_lock::scan_blocks(&text) { + if block.key.starts_with("__metadata") { + continue; + } + let Some(resolution) = yarn_berry_lock::berry_field(&block.lines, "resolution") else { + continue; + }; + // Registry resolutions are `name@npm:` (a `::binding` + // suffix may follow). Anything else (workspace:/patch:/file:/link:) + // is skipped — including our own vendored file: resolutions. + let Some((name, reference)) = yarn_classic_lock::split_pattern(resolution) else { + continue; + }; + let Some(reference) = reference.strip_prefix("npm:") else { + continue; + }; + let version_from_res = reference.split("::").next().unwrap_or(reference); + let version = + yarn_berry_lock::berry_field(&block.lines, "version").unwrap_or(version_from_res); + let integrity = yarn_berry_lock::berry_field(&block.lines, "checksum") + .map(|c| LockIntegrity::BerryChecksum(c.to_string())) + .unwrap_or(LockIntegrity::None); + out.push(LockfileEntry::npm(name, version, None, integrity)); + } + Some(out) +} From 52e71ee4362c9113e7d34170eb93b10daeaa94fd Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 23 Sep 2026 14:38:27 -0400 Subject: [PATCH 4/9] feat(core/vex): discover hosted and vendored patch references from lockfiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New read-only module `vex::discover`: `discover_patched_refs(_with)` reads every supported ROOT lockfile / package-manager config and returns the Socket patch wiring it finds — `Discovery { refs, diagnostics, recognized, unlocked_pins, elsewhere }`, one `PatchedRef { purl, uuid, mode (Hosted | Vendored), source_file, artifact_rel, locked_integrity, integrity_required, url }` per live reference. It never touches the network, never writes, and never fails the run: a malformed file is a diagnostic (`lockfile_unreadable`, `lockfile_unparseable`, `patched_ref_invalid`, `patched_ref_unattributable`). One extractor per package-manager family: npm (package-lock / shrinkwrap, pnpm every lock generation + Rush locks), yarn classic and berry, bun (`bun.lock` / `bun.lockb`), cargo, golang (go.mod / go.work + sums), pypi locks (uv, PEP 723 script locks, pylock, poetry, pdm), pypi other (Pipfile.lock, requirements + `-r`, Hatch / PEP 621 direct refs), gem, composer, maven, nuget; deno is explicitly empty. Every file present is read — no precedence chain — because the hosted rewriter edits every candidate it finds. Every value is committed, tamperable data and is validated fail-closed: - hosted: `hosted_patch_uuid` accepts only `https://patch.socket.dev` or a configured `--patch-server-url` origin, no userinfo, and takes the LAST canonical-uuid path segment (grant tokens may be uuid-shaped); percent-encoding, `\/` escapes and fragments are handled; - vendored: root-anchored `.socket/vendor///…` paths whose leaf names the entry's own artifact (`vendor_ref` takes the path literally; only yarn / URL-form pip strip their `#…` / `::…` decorations); - pins, not definitions: a registry / index / source definition alone (cargo `[registries]`, nuget ``, pom ``, uv index tables, `.npmrc`) never makes a reference; - contested locks: a lock that resolves the same name@version from a non-Socket source drops the ref (`patched_ref_unattributable`); - lockless cargo pins / exclusive nuget mappings are recorded as `UnlockedPin`s that can only keep a ledger record live, never create a ref; `recognized` lists every uuid a read file mentions, so a rejected mention keeps nothing alive downstream. Supporting core changes: `patch::redirect::{SOCKET_PATCH_SERVER_HOST, hosted_patch_uuid, hosted_patch_url_uuids}` (the pipenv owner check uses the shared host constant); the pnpm lock grammar and `hosted_url_version` exported crate-wide so readers parse exactly what the writers write; `utils::digest` (the SRI pin rule and the hex digest shapes — one copy for the inventory, discovery, ledger recovery and the rewriters, each call site keeping its case policy); and `utils::purl`'s validating purl builders, which discovery and the inventory's registry views share. A few writer helpers become `pub(crate)` so the extractors' tests derive their fixtures from the writers themselves. One lockfile traversal layer: discovery and `vendor::lock_inventory` (the scan / get / vendor / repair inventory) read each format through ONE reader that yields every entry, Socket-owned ones included — the inventory's registry views drop those, the extractors classify them: - `lock_inventory` becomes a directory module, one file per format, each laid out as a pure entry model, a stat-only file-selection section and the registry view (an architecture test enforces the layering). Entry models: `npm_lock_nodes`; `pnpm_packages` over the hosted rewriter's pnpm grammar (every key generation, CRLF included); the yarn `classic_entries` / `berry_entries` models with one berry locator, cache-key and checksum rule; `BunLockb::parse_packages`; and `composer_lock_packages`, whose array index the composer writer's lock walks use too. - Every other format reads through the reader its writer owns: `cargo_lock::locked_packages` and `cargo_config`'s `[patch]` / `[registries]` walks; `go_mod_edit` / `go_sum_edit` read helpers; a new `vendor::gemfile_lock` model; the `utils::python_lock` / `poetry_lock` / `hatch` readers (uv source fields, script-lock pairing, the pyproject / Hatch declaration walk); a new `utils::requirements` lexer lifted out of the vendored requirements planner; and two new XML readers, `vendor::maven_pom` and `vendor::nuget_config`, with `nuget_feed::nuget_lock_entries` shared by discovery and the feed writer. - `DiscoverCtx::locate` classifies a lock location once (vendored path, hosted uuid, decorated leaf) for every extractor. The npm-family extractors iterate the entry models only, never the grammar primitives, and every extractor reads content only through the recognizing ctx (rule 11) — both enforced by architecture tests. Ledger liveness is one rule too: `Discovery::{wires_package, vendor_entry_live, redirect_record_live}`, held per call site by `LedgerLiveness` (the sorted redirect-ledger files, the lock inventory loaded lazily at most once), which the CLI's vex and scan both use. Cargo crates.io provenance is explicit (`LockfileEntry::source_kind`), not inferred from the checksum variant. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-core/src/constants.rs | 15 + .../src/crawlers/maven_crawler.rs | 2 +- .../src/patch/redirect/bun_binary.rs | 11 +- .../src/patch/redirect/mod.rs | 449 +- .../src/patch/redirect/pipenv.rs | 7 +- .../src/patch/redirect/pnpm.rs | 85 +- .../src/patch/redirect/requirements.rs | 16 +- .../src/patch/redirect/takeover.rs | 19 +- crates/socket-patch-core/src/utils/digest.rs | 69 + crates/socket-patch-core/src/utils/hatch.rs | 208 +- crates/socket-patch-core/src/utils/mod.rs | 2 + .../src/utils/poetry_lock.rs | 28 +- crates/socket-patch-core/src/utils/purl.rs | 189 +- .../src/utils/python_lock.rs | 189 +- .../src/utils/python_script.rs | 83 +- .../src/utils/requirements.rs | 263 ++ .../socket-patch-core/src/vendor/bun_lock.rs | 62 +- .../socket-patch-core/src/vendor/bun_lockb.rs | 7 + .../src/vendor/cargo_config.rs | 148 +- .../src/vendor/cargo_lock.rs | 134 +- crates/socket-patch-core/src/vendor/common.rs | 94 +- .../src/vendor/composer_lock.rs | 111 +- crates/socket-patch-core/src/vendor/gem.rs | 120 +- .../src/vendor/gemfile_lock.rs | 417 ++ .../src/vendor/go_mod_edit.rs | 126 +- .../src/vendor/go_sum_edit.rs | 52 + .../src/vendor/lock_inventory/bun.rs | 43 +- .../src/vendor/lock_inventory/cargo.rs | 112 +- .../src/vendor/lock_inventory/composer.rs | 179 +- .../src/vendor/lock_inventory/gem.rs | 175 +- .../src/vendor/lock_inventory/golang.rs | 41 +- .../src/vendor/lock_inventory/mod.rs | 306 +- .../src/vendor/lock_inventory/npm.rs | 137 +- .../src/vendor/lock_inventory/npm_family.rs | 63 +- .../src/vendor/lock_inventory/pnpm.rs | 288 +- .../src/vendor/lock_inventory/pypi.rs | 669 +-- .../src/vendor/lock_inventory/recover.rs | 52 +- .../src/vendor/lock_inventory/tests.rs | 127 + .../src/vendor/lock_inventory/wired.rs | 192 +- .../src/vendor/lock_inventory/yarn.rs | 166 +- .../socket-patch-core/src/vendor/maven_pom.rs | 336 ++ .../src/vendor/maven_repo.rs | 56 +- crates/socket-patch-core/src/vendor/mod.rs | 8 +- .../src/vendor/npm_common.rs | 12 +- .../src/vendor/npm_flavor.rs | 38 +- .../socket-patch-core/src/vendor/npm_lock.rs | 5 +- .../src/vendor/nuget_config.rs | 254 ++ .../src/vendor/nuget_feed.rs | 115 +- crates/socket-patch-core/src/vendor/path.rs | 7 +- .../socket-patch-core/src/vendor/pnpm_lock.rs | 2 +- .../src/vendor/pnpm_lock_legacy.rs | 2 +- crates/socket-patch-core/src/vendor/pypi.rs | 2 +- .../socket-patch-core/src/vendor/pypi_lock.rs | 9 +- .../socket-patch-core/src/vendor/pypi_pdm.rs | 20 +- .../src/vendor/pypi_requirements.rs | 193 +- .../socket-patch-core/src/vendor/pypi_uv.rs | 33 +- .../src/vendor/registry_fetch.rs | 22 +- .../src/vendor/yarn_berry_lock.rs | 128 +- .../src/vendor/yarn_classic_lock.rs | 60 +- .../socket-patch-core/src/vex/discover/bun.rs | 1142 +++++ .../src/vex/discover/cargo.rs | 1562 +++++++ .../src/vex/discover/composer.rs | 762 ++++ .../src/vex/discover/deno.rs | 35 + .../socket-patch-core/src/vex/discover/gem.rs | 1210 ++++++ .../src/vex/discover/golang.rs | 910 ++++ .../src/vex/discover/maven.rs | 1387 ++++++ .../socket-patch-core/src/vex/discover/mod.rs | 3794 +++++++++++++++++ .../socket-patch-core/src/vex/discover/npm.rs | 1849 ++++++++ .../src/vex/discover/nuget.rs | 1565 +++++++ .../src/vex/discover/pypi_locks.rs | 1795 ++++++++ .../src/vex/discover/pypi_other.rs | 1363 ++++++ .../src/vex/discover/yarn.rs | 1437 +++++++ crates/socket-patch-core/src/vex/mod.rs | 7 + 73 files changed, 23945 insertions(+), 1631 deletions(-) create mode 100644 crates/socket-patch-core/src/utils/digest.rs create mode 100644 crates/socket-patch-core/src/utils/requirements.rs create mode 100644 crates/socket-patch-core/src/vendor/gemfile_lock.rs create mode 100644 crates/socket-patch-core/src/vendor/maven_pom.rs create mode 100644 crates/socket-patch-core/src/vendor/nuget_config.rs create mode 100644 crates/socket-patch-core/src/vex/discover/bun.rs create mode 100644 crates/socket-patch-core/src/vex/discover/cargo.rs create mode 100644 crates/socket-patch-core/src/vex/discover/composer.rs create mode 100644 crates/socket-patch-core/src/vex/discover/deno.rs create mode 100644 crates/socket-patch-core/src/vex/discover/gem.rs create mode 100644 crates/socket-patch-core/src/vex/discover/golang.rs create mode 100644 crates/socket-patch-core/src/vex/discover/maven.rs create mode 100644 crates/socket-patch-core/src/vex/discover/mod.rs create mode 100644 crates/socket-patch-core/src/vex/discover/npm.rs create mode 100644 crates/socket-patch-core/src/vex/discover/nuget.rs create mode 100644 crates/socket-patch-core/src/vex/discover/pypi_locks.rs create mode 100644 crates/socket-patch-core/src/vex/discover/pypi_other.rs create mode 100644 crates/socket-patch-core/src/vex/discover/yarn.rs diff --git a/crates/socket-patch-core/src/constants.rs b/crates/socket-patch-core/src/constants.rs index a2c96397..c9009e80 100644 --- a/crates/socket-patch-core/src/constants.rs +++ b/crates/socket-patch-core/src/constants.rs @@ -179,4 +179,19 @@ pub mod npm_family { /// Rush monorepos keep the single pnpm source-of-truth lock here, /// relative to the repo root (no root package.json/lock pair). pub const RUSH_COMMON_LOCK_REL: &str = "common/config/rush/pnpm-lock.yaml"; + + /// The npm locks, in npm's own preference order (the shrinkwrap wins + /// for npm <= 11). + pub const NPM_LOCKS: [&str; 2] = ["npm-shrinkwrap.json", "package-lock.json"]; + /// The root pnpm lock (pnpm >= 3, every `lockfileVersion`). + pub const PNPM_LOCK: &str = "pnpm-lock.yaml"; + /// pnpm 1 / 2's lock (`shrinkwrapVersion: 3`), renamed by pnpm 3. + pub const PNPM_SHRINKWRAP_LEGACY: &str = "shrinkwrap.yaml"; + /// Rush's per-subspace lock dir + /// (`common/config/subspaces//pnpm-lock.yaml`). + pub const RUSH_SUBSPACES_DIR: &str = "common/config/subspaces"; + /// Bun's text lock (bun >= 1.1.39's default). + pub const BUN_LOCK: &str = "bun.lock"; + /// Bun's binary lock. + pub const BUN_LOCKB: &str = "bun.lockb"; } diff --git a/crates/socket-patch-core/src/crawlers/maven_crawler.rs b/crates/socket-patch-core/src/crawlers/maven_crawler.rs index e8a4a2cf..232da792 100644 --- a/crates/socket-patch-core/src/crawlers/maven_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/maven_crawler.rs @@ -313,7 +313,7 @@ fn parse_path_coordinates( /// The delegation also rejects `:` everywhere — a Windows drive-relative /// coordinate (`C:evil`) joins as an absolute path. Mirrors the `go_crawler` /// / `deno_crawler` coordinate guards. -fn is_safe_maven_coordinate(group_id: &str, artifact_id: &str, version: &str) -> bool { +pub(crate) fn is_safe_maven_coordinate(group_id: &str, artifact_id: &str, version: &str) -> bool { group_id.split('.').all(path_safety::is_safe_single_segment) && path_safety::is_safe_single_segment(artifact_id) && path_safety::is_safe_single_segment(version) diff --git a/crates/socket-patch-core/src/patch/redirect/bun_binary.rs b/crates/socket-patch-core/src/patch/redirect/bun_binary.rs index 16083595..55163ad9 100644 --- a/crates/socket-patch-core/src/patch/redirect/bun_binary.rs +++ b/crates/socket-patch-core/src/patch/redirect/bun_binary.rs @@ -140,16 +140,7 @@ pub(crate) fn names(edit: &FileEdit, name: &str, version: &str) -> Result = [original, new] .into_iter() diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index f236dca8..fb01aa04 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -23,6 +23,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use crate::crawlers::composer_crawler::normalize_version; +use crate::utils::digest::is_hex64_lower; use crate::vendor::yarn_berry_lock::yarnrc_compression_level; mod bun_binary; @@ -31,7 +32,9 @@ pub mod golang_local; pub mod npmrc; mod pdm; mod pipenv; -mod pnpm; +// pub(crate): manifest-less VEX discovery (`vex::discover::npm`) reads +// hosted pnpm locks with the SAME grammar this rewriter writes them in. +pub(crate) mod pnpm; mod poetry; mod replay; mod requirements; @@ -43,6 +46,9 @@ pub use state::{ drop_superseded_purl, load_redirect_state, persist_redirect_state, save_redirect_state, CorruptRedirectState, RedirectState, REDIRECT_STATE_REL, }; +/// Hosted-artifact leaf ownership rule, shared with `vex`'s bun lockfile +/// discovery (which recovers a URL tuple's version from that leaf). +pub(crate) use takeover::hosted_url_version; pub use takeover::{ redirect_revert_supported, revert_cargo_redirect_purl, revert_npm_redirect_purl, revert_redirect_purl, RedirectRevert, @@ -430,7 +436,7 @@ fn rewrite_npm_lock( // install from — a silent FALSE SUCCESS. Rewrite EVERY present npm lock so // a fresh `npm install`/`npm ci` from EITHER is redirected (shrinkwrap-only // repos on npm <= 6 keep working: only that one file is present). - let present: Vec<&str> = ["npm-shrinkwrap.json", "package-lock.json"] + let present: Vec<&str> = crate::constants::npm_family::NPM_LOCKS .into_iter() .filter(|f| files.contains_key(*f)) .collect(); @@ -948,22 +954,27 @@ fn is_valid_gem_index_url(url: &str) -> bool { && !url.chars().any(|c| c.is_control() || c == ' ') } -/// The exact shape `hex::encode(sha256)` / the TS `Buffer.toString('hex')` -/// produce: 64 lowercase hex chars. Anything else written as a Cargo.lock -/// `checksum` breaks the next fetch. -fn is_hex64_lower(s: &str) -> bool { - s.len() == 64 - && s.bytes() - .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +/// The uuid of a Socket-owned registry / repository / source NAME in its +/// EXACT grammar: `socket-patch-`, or with `vendored` +/// `socket-patch-vendor-` (maven's vendored repository id). +/// No trimming: the rewriter must never treat a user's padded pin as its +/// own, while lockfile discovery trims at its call site +/// (`vex::discover::socket_patch_name_uuid`). +pub(crate) fn socket_patch_name_uuid_exact(name: &str, vendored: bool) -> Option<&str> { + let prefix = if vendored { + "socket-patch-vendor-" + } else { + "socket-patch-" + }; + name.strip_prefix(prefix) + .filter(|uuid| crate::patch::path_safety::is_canonical_uuid(uuid)) } /// A registry name THIS rewriter owns: `socket-patch-`. An /// existing pin matching this grammar was written by a previous run and may be /// superseded in place; any other registry pin is the user's and is refused. fn is_socket_patch_registry_name(value: &str) -> bool { - value - .strip_prefix("socket-patch-") - .is_some_and(crate::patch::path_safety::is_canonical_uuid) + socket_patch_name_uuid_exact(value, false).is_some() } /// Split a TOML table-header path into dot segments, respecting quoted @@ -2241,9 +2252,10 @@ fn rewrite_yarn_classic( const YARN_BERRY_SUPPORTED_CACHE_KEY: &str = "10c0"; /// A yarn.lock is berry (v2+) when it carries the `__metadata:` header block; -/// anything else is a classic v1 lock. Shared by both yarn rewriters so the -/// ownership split cannot drift. -fn is_berry_lock(content: &str) -> bool { +/// anything else is a classic v1 lock. Shared by both yarn rewriters and +/// lockfile discovery (`vex::discover::yarn`) so the grammar split cannot +/// drift. +pub(crate) fn is_berry_lock(content: &str) -> bool { content.lines().any(|line| line.starts_with("__metadata:")) } @@ -2263,43 +2275,13 @@ fn berry_cache_key(content: &str) -> Option { None } -/// Split `name@npm:...` at the `@` past a leading `@scope/` marker. -fn split_berry_descriptor(pattern: &str) -> Option<(&str, &str)> { - let from = usize::from(pattern.starts_with('@')); - let at = pattern[from..].find('@')? + from; - let (name, range) = (&pattern[..at], &pattern[at + 1..]); - if name.is_empty() || range.is_empty() { - return None; - } - Some((name, range)) -} - -/// Split a berry lock key into its comma-joined descriptor patterns. yarn -/// wraps a multi-descriptor key in ONE outer quote pair (`"a@npm:^1, -/// a@npm:^2"`), so strip a single wrapping pair first, THEN split on `, ` — -/// that surfaces every descriptor (letting a genuinely mixed-name key be -/// detected as ambiguous) while a single quoted descriptor stays intact. -/// Twin of the TS `splitKeyPatterns`. -fn split_berry_key_patterns(key: &str) -> Vec { - let trimmed = key.trim(); - let inner = if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') { - &trimmed[1..trimmed.len() - 1] - } else { - trimmed - }; - inner - .split(", ") - .map(str::trim) - .filter(|p| !p.is_empty()) - .map(str::to_string) - .collect() -} - fn rewrite_yarn_berry( files: &BTreeMap, overrides: &[DepOverride], result: &mut RewriteResult, ) { + // Descriptors split with the classic grammar's `name@range` rule. + use crate::vendor::yarn_classic_lock::{split_berry_key_patterns, split_pattern}; let npm: Vec<&DepOverride> = overrides.iter().filter(|o| o.ecosystem == "npm").collect(); if npm.is_empty() || !files.contains_key("yarn.lock") { return; @@ -2401,7 +2383,7 @@ fn rewrite_yarn_berry( } let patterns = split_berry_key_patterns(raw_key); let parsed: Vec> = - patterns.iter().map(|p| split_berry_descriptor(p)).collect(); + patterns.iter().map(|p| split_pattern(p)).collect(); // Every comma-joined pattern must parse as a descriptor. if parsed.iter().any(Option::is_none) { continue; @@ -2424,7 +2406,7 @@ fn rewrite_yarn_berry( p.expect("every pattern parsed — None-bearing keys are skipped above") .1 .strip_prefix("npm:") - .and_then(split_berry_descriptor) + .and_then(split_pattern) .is_some_and(|(real, _)| real == fname) }) { @@ -2906,17 +2888,18 @@ fn plan_python_metadata( dep: &DepOverride, result: &RewriteResult, ) -> Result<(Option, Option), RewriteWarning> { - use crate::utils::python_lock::{check_python_lock_source_scope, ArtifactSource}; + use crate::utils::python_lock::{ + check_python_lock_source_scope, is_script_lock_name, paired_metadata_rel, ArtifactSource, + }; use crate::utils::python_script::{rewrite_project_metadata, rewrite_script_metadata}; - let script = path.ends_with(".py.lock"); - let metadata_path = if script { - path.strip_suffix(".lock") - .expect("script lock suffix") - .to_string() - } else if path == "uv.lock" && files.contains_key("pyproject.toml") { - "pyproject.toml".to_string() - } else { + // A script lock always needs its script; uv.lock is edited alone in a + // lock-only checkout. + let script = is_script_lock_name(path); + let Some(metadata_path) = paired_metadata_rel(path) + .filter(|metadata| script || files.contains_key(*metadata)) + .map(str::to_string) + else { return Ok((None, None)); }; let Some(original) = result @@ -3848,6 +3831,97 @@ pub fn grant_token_path_segment(url: &str, patch_uuid: &str) -> Option { (!token.is_empty()).then(|| token.to_string()) } +/// Public host of Socket's patch server: the origin every production hosted +/// artifact / registry URL is served from (`https://patch.socket.dev/patch/…`, +/// `…/patch-registry/…`), and the root of the Go module namespace +/// [`crate::vendor::go_mod_edit::HOSTED_GO_MODULE_PREFIX`]. +pub const SOCKET_PATCH_SERVER_HOST: &str = "patch.socket.dev"; + +/// The Socket patch uuid a lockfile-recorded HOSTED reference names, or +/// `None` when `url` is not a Socket-hosted patch URL — the inverse of the +/// rewriters, used by `vex`'s manifest-less lockfile discovery. +/// +/// Recognition is deliberately strict, because the answer decides whether a +/// committed (tamper-able) lockfile line becomes an attestation input: +/// +/// * the ORIGIN must be Socket's patch server (`https://` + +/// [`SOCKET_PATCH_SERVER_HOST`]) or one of `extra_origins` — the +/// operator's `--patch-server-url` deployment, compared on scheme + host + +/// port. A uuid inside any other host's URL is a user's own dependency +/// source, never a patch reference; +/// * no userinfo — a Socket-written URL never carries credentials; +/// * the uuid is the LAST path segment passing the canonical-uuid grammar: +/// hosted URLs carry the grant token in the level before the uuid +/// (`…/patch/npm/////`, +/// `…/patch-registry////…`), and grant tokens may +/// themselves be uuid-shaped, so "the first uuid" would elect the token. +/// +/// Every spelling the lock formats record the same URL in is accepted: a +/// `#fragment` (yarn classic `#`, pip `#sha256=`) and a `?query` are +/// ignored; `\/`-escaped slashes (older composer locks) are unescaped; a +/// wholly percent-encoded URL (yarn berry's `__archiveUrl=` binding) is +/// decoded; a cargo source-kind prefix (`sparse+`, `registry+`) is dropped. +/// Path segments are percent-decoded AFTER splitting, so an encoded `/` +/// can never manufacture a segment. +pub fn hosted_patch_uuid(url: &str, extra_origins: &[String]) -> Option { + hosted_patch_url_uuids(url, extra_origins)?.pop() +} + +/// EVERY canonical-uuid path segment of a Socket-HOSTED url, in path order +/// (the grant token first when it is uuid-shaped, the patch uuid last), or +/// `None` when `url` is not on an accepted origin — [`hosted_patch_uuid`]'s +/// exact acceptance rules, without electing one segment. `vex` discovery +/// uses it to RECOGNIZE every Socket identity a lockfile mentions, including +/// the malformed or rejected shapes whose "last uuid" is not a patch. +pub fn hosted_patch_url_uuids(url: &str, extra_origins: &[String]) -> Option> { + use crate::patch::path_safety::is_canonical_uuid; + use crate::utils::purl::percent_decode_purl_component; + + let unescaped = url.trim().replace("\\/", "/"); + let lower = unescaped.to_ascii_lowercase(); + let decoded = if lower.starts_with("https%3a%2f%2f") || lower.starts_with("http%3a%2f%2f") { + percent_decode_purl_component(&unescaped).into_owned() + } else { + unescaped + }; + // `sparse+https://…` / `registry+https://…` (Cargo.lock `source`). + let (scheme, _) = decoded.split_once("://")?; + let text = match scheme.rsplit_once('+') { + Some((kind, _)) if !kind.is_empty() && kind.bytes().all(|b| b.is_ascii_alphabetic()) => { + &decoded[kind.len() + 1..] + } + _ => decoded.as_str(), + }; + let parsed = reqwest::Url::parse(text).ok()?; + if !matches!(parsed.scheme(), "https" | "http") + || !parsed.username().is_empty() + || parsed.password().is_some() + { + return None; + } + let socket_host = parsed.scheme() == "https" + && parsed.host_str() == Some(SOCKET_PATCH_SERVER_HOST) + && parsed.port_or_known_default() == Some(443); + let configured = extra_origins.iter().any(|origin| { + reqwest::Url::parse(origin.trim()).is_ok_and(|o| { + o.scheme() == parsed.scheme() + && o.host_str().is_some() + && o.host_str() == parsed.host_str() + && o.port_or_known_default() == parsed.port_or_known_default() + }) + }); + if !socket_host && !configured { + return None; + } + Some( + parsed + .path_segments()? + .map(|segment| percent_decode_purl_component(segment).into_owned()) + .filter(|segment| is_canonical_uuid(segment)) + .collect(), + ) +} + /// A dep's Socket index URL as a regex source with the per-request rotating /// segments (grant token, patch uuid) wildcarded — an exact-URL pattern /// misses the URL a previous run wrote under an older grant. The grant token @@ -4690,7 +4764,7 @@ const GRADLE_FILES: &[&str] = &[ /// still resolves (only a MISMATCH fails); origin-unaware so one checksum /// matches the artifact from any repository. const MVN_CONFIG_ARGS: &[&str] = &[ - "-Daether.artifactResolver.postProcessor.trustedChecksums=true", + TRUSTED_CHECKSUMS_ON, "-Daether.artifactResolver.postProcessor.trustedChecksums.checksumAlgorithms=SHA-256", "-Daether.artifactResolver.postProcessor.trustedChecksums.failIfMissing=false", "-Daether.trustedChecksumsSource.summaryFile=true", @@ -4698,8 +4772,13 @@ const MVN_CONFIG_ARGS: &[&str] = &[ "-Daether.trustedChecksumsSource.summaryFile.originAware=false", ]; -const MVN_CONFIG: &str = ".mvn/maven.config"; -const MVN_CHECKSUMS: &str = ".mvn/checksums/checksums.sha256"; +/// The resolver switch (the first [`MVN_CONFIG_ARGS`] line) that makes the +/// checksums file an enforced pin; without it the file is inert. +pub(crate) const TRUSTED_CHECKSUMS_ON: &str = + "-Daether.artifactResolver.postProcessor.trustedChecksums=true"; + +pub(crate) const MVN_CONFIG: &str = ".mvn/maven.config"; +pub(crate) const MVN_CHECKSUMS: &str = ".mvn/checksums/checksums.sha256"; /// Strip any `sha256-`/`sha256:` SRI-style prefix off a stored hash, leaving the /// bare lowercase hex Maven's trusted-checksums summary file expects (twin of @@ -5264,7 +5343,12 @@ fn merge_checksums(existing: &str, entries: &[(String, String)]) -> String { /// The local-repository-relative artifact path Maven derives for a coordinate: /// `///-.`. -fn local_repo_artifact_path(group_id: &str, artifact_id: &str, version: &str, ext: &str) -> String { +pub(crate) fn local_repo_artifact_path( + group_id: &str, + artifact_id: &str, + version: &str, + ext: &str, +) -> String { format!( "{}/{artifact_id}/{version}/{artifact_id}-{version}.{ext}", group_id.replace('.', "/") @@ -5305,17 +5389,6 @@ fn go_token_safe(s: &str) -> bool { !s.is_empty() && !s.chars().any(|c| c.is_whitespace() || c.is_control()) } -/// Strict `h1:` dirhash shape: exactly `h1:` + the 44-char standard-base64 of -/// a sha256. Anything else (wrong algorithm, embedded whitespace, truncation) -/// must not reach go.sum — a malformed line poisons the whole file. -fn go_h1_shape(s: &str) -> bool { - s.strip_prefix("h1:").is_some_and(|b| { - b.len() == 44 - && b.chars() - .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=') - }) -} - // The committable shape (validated empirically — `docs/design/golang-hosted.md`): // // go.mod: replace => patch.socket.dev/gopatch/ @@ -5428,7 +5501,7 @@ fn rewrite_golang( }); continue; }; - if !go_h1_shape(zip_h1) || !go_h1_shape(gomod_h1) { + if !go_sum_edit::is_h1_dirhash(zip_h1) || !go_sum_edit::is_h1_dirhash(gomod_h1) { result.warnings.push(RewriteWarning { code: "redirect_golang_missing_integrity".into(), detail: format!( @@ -14732,6 +14805,111 @@ mod python_lock_warning_tests { } } +/// Which metadata file `plan_python_metadata` pairs a native Python lock +/// with, and what it does when that file is missing — pinned at the lib +/// level (the `uv_hosted` integration suite is not part of the lib run). +#[cfg(test)] +mod python_metadata_pairing_tests { + use super::*; + + fn dep() -> DepOverride { + DepOverride { + ecosystem: "pypi".into(), + name: "click".into(), + namespace: None, + version: "8.1.7".into(), + token: "11111111-1111-4111-8111-111111111111".into(), + patch_uuid: "22222222-2222-4222-8222-222222222222".into(), + artifact_url: "https://patch.socket.dev/click-8.1.7-py3-none-any.whl".into(), + berry_zip_url: None, + registry_override: None, + integrity: Integrity::default(), + } + } + + const LOCK: &str = "version = 1\n"; + const PYPROJECT: &str = + "[project]\nname = \"app\"\nversion = \"0.1.0\"\ndependencies = [\"click==8.1.7\"]\n"; + const SCRIPT: &str = "# /// script\n# dependencies = [\"click==8.1.7\"]\n# ///\nimport click\n"; + + fn plan( + path: &str, + files: &[(&str, &str)], + planned: &[(&str, &str)], + ) -> Result<(Option, Option), RewriteWarning> { + let files: BTreeMap = files + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + let mut result = RewriteResult::default(); + for (k, v) in planned { + result.files.insert(k.to_string(), v.to_string()); + } + plan_python_metadata(path, LOCK, &files, &dep(), &result) + } + + #[test] + fn uv_lock_pairs_with_pyproject_only_when_present() { + let (edit, project) = plan("uv.lock", &[("pyproject.toml", PYPROJECT)], &[]) + .unwrap_or_else(|w| panic!("{}: {}", w.code, w.detail)); + let edit = edit.expect("pyproject rewritten"); + assert_eq!(edit.path, "pyproject.toml"); + assert!(!edit.script); + assert_eq!(edit.original, PYPROJECT); + assert_eq!(project.as_deref(), Some(edit.rewritten.as_str())); + + // No pyproject: the lock is edited alone. + assert!(matches!(plan("uv.lock", &[], &[]), Ok((None, None)))); + // Other native locks have no paired metadata at all. + for lock in ["pylock.toml", "pylock.dev.toml", "tool.lock", ".py.lockx"] { + assert!( + matches!( + plan(lock, &[("pyproject.toml", PYPROJECT)], &[]), + Ok((None, None)) + ), + "{lock}" + ); + } + } + + #[test] + fn script_lock_pairs_with_its_script_and_requires_it() { + let (edit, project) = plan("tool.py.lock", &[("tool.py", SCRIPT)], &[]) + .unwrap_or_else(|w| panic!("{}: {}", w.code, w.detail)); + let edit = edit.expect("script rewritten"); + assert_eq!(edit.path, "tool.py"); + assert!(edit.script); + assert_eq!(project, None); + + // An earlier dependency's planned rewrite of the script wins over the + // file on disk. + let (edit, _) = plan( + "tool.py.lock", + &[("tool.py", "not a script")], + &[("tool.py", SCRIPT)], + ) + .unwrap_or_else(|w| panic!("{}: {}", w.code, w.detail)); + assert_eq!(edit.expect("script rewritten").original, SCRIPT); + + let Err(missing) = plan("tool.py.lock", &[("pyproject.toml", PYPROJECT)], &[]) else { + panic!("a script lock without its script must refuse"); + }; + assert_eq!(missing.code, "redirect_uv_script_missing"); + assert_eq!(missing.detail, "tool.py.lock requires its paired tool.py"); + + let Err(bad) = plan("tool.py.lock", &[("tool.py", "print(1)\n")], &[]) else { + panic!("a script without PEP 723 metadata must refuse"); + }; + assert_eq!(bad.code, "redirect_uv_script_unsupported"); + assert!(bad.detail.starts_with("tool.py: "), "{}", bad.detail); + + let Err(bad) = plan("uv.lock", &[("pyproject.toml", "[tool]\n")], &[]) else { + panic!("a pyproject without [project] must refuse"); + }; + assert_eq!(bad.code, "redirect_uv_project_unsupported"); + } +} + #[cfg(test)] mod hatch_tests { use super::*; @@ -14851,3 +15029,126 @@ mod hatch_tests { assert!(second.files.is_empty()); } } + +#[cfg(test)] +mod hosted_patch_uuid_tests { + //! `hosted_patch_uuid` is the trust gate between a committed lockfile + //! line and a VEX attestation input: pin the accepted spellings AND the + //! rejections (foreign hosts, credentials, non-canonical tokens). + use super::*; + + const UUID: &str = "7c8d9e0f-1a2b-4a1b-8c2d-3e4f5a6b7c8d"; + /// A uuid-SHAPED grant token: the fixtures use them, and production + /// tokens are not guaranteed otherwise — the LAST uuid segment must win. + const TOKEN: &str = "11111111-2222-4333-8444-555555555555"; + + fn none() -> Vec { + Vec::new() + } + + #[test] + fn artifact_and_registry_shapes_yield_the_patch_uuid_not_the_token() { + for url in [ + format!("https://patch.socket.dev/patch/npm/left-pad/1.3.0/{TOKEN}/{UUID}/left-pad-1.3.0.tgz"), + format!("https://patch.socket.dev/patch/npm/{TOKEN}/{UUID}/left-pad-1.3.0.tgz"), + format!("https://patch.socket.dev/patch-registry/gem/{TOKEN}/{UUID}/"), + format!("https://patch.socket.dev/patch-registry/gem/{TOKEN}/{UUID}/gems/rack-2.2.3.gem"), + format!("https://patch.socket.dev/patch-registry/maven/{TOKEN}/{UUID}/maven2"), + format!("https://patch.socket.dev/patch-registry/nuget/{TOKEN}/{UUID}/index.json"), + format!("sparse+https://patch.socket.dev/patch-registry/cargo/{TOKEN}/{UUID}/index/"), + format!("registry+https://patch.socket.dev/patch-registry/cargo/{TOKEN}/{UUID}/index/"), + ] { + assert_eq!( + hosted_patch_uuid(&url, &none()).as_deref(), + Some(UUID), + "{url}" + ); + } + } + + #[test] + fn lock_format_spellings_are_normalized() { + let url = format!("https://patch.socket.dev/patch/npm/{TOKEN}/{UUID}/left-pad-1.3.0.tgz"); + // yarn classic `#`, pip/hatch `#sha256=`, a stray query. + for spelled in [ + format!("{url}#0123456789abcdef0123456789abcdef01234567"), + format!("{url}#sha256=abc"), + format!("{url}?x=1"), + // composer's `\/`-escaped slashes. + url.replace('/', "\\/"), + // yarn berry's percent-encoded `__archiveUrl=` binding value. + url.replace(':', "%3A").replace('/', "%2F"), + format!(" {url} "), + ] { + assert_eq!( + hosted_patch_uuid(&spelled, &none()).as_deref(), + Some(UUID), + "{spelled}" + ); + } + } + + #[test] + fn foreign_hosts_credentials_and_plain_http_are_refused() { + for url in [ + format!("https://registry.npmjs.org/{TOKEN}/{UUID}/x.tgz"), + format!("https://patch.socket.dev.evil.example/{TOKEN}/{UUID}/x.tgz"), + format!("https://evil.example/patch.socket.dev/{TOKEN}/{UUID}/x.tgz"), + format!("http://patch.socket.dev/patch/npm/{TOKEN}/{UUID}/x.tgz"), + format!("https://patch.socket.dev:8443/patch/npm/{TOKEN}/{UUID}/x.tgz"), + format!("https://user:pw@patch.socket.dev/patch/npm/{TOKEN}/{UUID}/x.tgz"), + format!("git+ssh://patch.socket.dev/{UUID}"), + format!("file:.socket/vendor/npm/{UUID}/x.tgz"), + format!("patch.socket.dev/gopatch/{UUID}"), + ] { + assert_eq!(hosted_patch_uuid(&url, &none()), None, "{url}"); + } + } + + #[test] + fn non_canonical_segments_are_not_patch_uuids() { + for url in [ + // Placeholder tokens (fixtures use `uuid`, `tok`, `some-uuid`). + "https://patch.socket.dev/patch/npm/tok/uuid/x.tgz".to_string(), + // Uppercase is not the canonical grammar. + format!( + "https://patch.socket.dev/patch/npm/tok/{}/x.tgz", + UUID.to_ascii_uppercase() + ), + // A uuid only in the query / fragment is not a path level. + format!("https://patch.socket.dev/patch/npm/x.tgz?u={UUID}"), + format!("https://patch.socket.dev/patch/npm/x.tgz#{UUID}"), + // An encoded `/` cannot split a segment into a uuid. + format!("https://patch.socket.dev/patch/npm/tok%2F{UUID}/x.tgz"), + ] { + assert_eq!(hosted_patch_uuid(&url, &none()), None, "{url}"); + } + } + + #[test] + fn configured_patch_server_origin_is_accepted_exactly() { + let origins = vec!["http://127.0.0.1:4545/some/base".to_string()]; + let url = format!("http://127.0.0.1:4545/patch/npm/{TOKEN}/{UUID}/x.tgz"); + assert_eq!(hosted_patch_uuid(&url, &origins).as_deref(), Some(UUID)); + // Same host, other port / scheme: a different origin. + for other in [ + format!("http://127.0.0.1:4546/patch/npm/{TOKEN}/{UUID}/x.tgz"), + format!("https://127.0.0.1:4545/patch/npm/{TOKEN}/{UUID}/x.tgz"), + ] { + assert_eq!(hosted_patch_uuid(&other, &origins), None, "{other}"); + } + // The default host stays accepted alongside the override, and a + // malformed override is ignored rather than widening the allowlist. + let default = format!("https://patch.socket.dev/patch/npm/{TOKEN}/{UUID}/x.tgz"); + assert_eq!(hosted_patch_uuid(&default, &origins).as_deref(), Some(UUID)); + assert_eq!(hosted_patch_uuid(&url, &["not a url".to_string()]), None); + } + + /// The Go hosted namespace lives on the same host the URL allowlist + /// pins — the two spellings of "Socket's patch server" cannot drift. + #[test] + fn go_module_namespace_is_on_the_patch_server_host() { + assert!(crate::vendor::go_mod_edit::HOSTED_GO_MODULE_PREFIX + .starts_with(&format!("{SOCKET_PATCH_SERVER_HOST}/"))); + } +} diff --git a/crates/socket-patch-core/src/patch/redirect/pipenv.rs b/crates/socket-patch-core/src/patch/redirect/pipenv.rs index 3dc5b57b..ef62dce7 100644 --- a/crates/socket-patch-core/src/patch/redirect/pipenv.rs +++ b/crates/socket-patch-core/src/patch/redirect/pipenv.rs @@ -85,8 +85,8 @@ fn properties(text: &str, offset: usize) -> Result, String> { fn entries(text: &str) -> Result, String> { // A UTF-8 BOM (Windows editors) is not JSON; parse past it. Offsets // below come from `text.find('{')`, so they stay byte-accurate. - let value: Value = serde_json::from_str(text.trim_start_matches('\u{feff}')) - .map_err(|e| e.to_string())?; + let value = + crate::vendor::lock_inventory::pypi::parse_pipfile_lock(text).map_err(|e| e.to_string())?; if !value.is_object() { return Err("Pipfile.lock is not an object".into()); } @@ -334,7 +334,8 @@ fn owned_url(value: &str, dep: &DepOverride) -> bool { && url.host_str() == ours.host_str() && url.port_or_known_default() == ours.port_or_known_default(); let parts: Vec<_> = url.path().split('/').collect(); - (same_origin || (url.scheme() == "https" && url.host_str() == Some("patch.socket.dev"))) + (same_origin + || (url.scheme() == "https" && url.host_str() == Some(super::SOCKET_PATCH_SERVER_HOST))) && url.username().is_empty() && url.password().is_none() && url.query().is_none() diff --git a/crates/socket-patch-core/src/patch/redirect/pnpm.rs b/crates/socket-patch-core/src/patch/redirect/pnpm.rs index 59eb61a7..a5fa6a4d 100644 --- a/crates/socket-patch-core/src/patch/redirect/pnpm.rs +++ b/crates/socket-patch-core/src/patch/redirect/pnpm.rs @@ -18,13 +18,13 @@ pub(super) fn unsupported_early_shrinkwrap(content: &str) -> bool { && !minor.is_some_and(|v| v.trim().parse::().is_ok_and(|v| v > 0)) } -pub(super) struct Entry<'a> { +pub(crate) struct Entry<'a> { pub key: &'a str, pub body: &'a str, pub offset: usize, } -pub(super) fn entries(content: &str) -> Vec> { +pub(crate) fn entries(content: &str) -> Vec> { let mut out = Vec::new(); let mut in_packages = false; let mut current: Option<(&str, usize)> = None; @@ -62,13 +62,43 @@ pub(super) fn entries(content: &str) -> Vec> { out } -fn unquote(s: &str) -> &str { +/// Strip one layer of YAML single/double quotes (pnpm quotes keys that +/// start with `@` and scalars carrying flow delimiters). Lockfile discovery +/// reads keys and resolution fields through it too. +pub(crate) fn unquote(s: &str) -> &str { s.strip_prefix('\'') .and_then(|s| s.strip_suffix('\'')) .or_else(|| s.strip_prefix('"').and_then(|s| s.strip_suffix('"'))) .unwrap_or(s) } +/// 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. +pub(crate) fn is_pnpm_lock_text(text: &str) -> bool { + text.lines() + .any(|line| line.starts_with("lockfileVersion:") || line.starts_with("shrinkwrapVersion:")) +} + +/// The value of the entry-level (four-space) `field:` line of a packages +/// entry body, unquoted; `None` when absent, empty, or given more than once +/// (ambiguous — fail closed). Nested (deeper-indented) lines are never +/// entry fields. Reads the `name:` / `version:` lines the vendor backends +/// write on rekeyed entries. +pub(crate) fn entry_field<'a>(entry: &Entry<'a>, field: &str) -> Option<&'a str> { + let mut values = entry.body.lines().filter_map(|line| { + let line = line.trim_end_matches('\r'); + let rest = line.strip_prefix(" ")?; + if rest.starts_with(' ') { + return None; + } + let (k, v) = rest.split_once(':')?; + (k == field).then(|| unquote(v.trim())) + }); + let first = values.next().filter(|v| !v.is_empty())?; + values.next().is_none().then_some(first) +} + /// Loose identity match, also used to refuse unsupported suffixes atomically. pub(super) fn suffix<'a>(key: &'a str, name: &str, version: &str) -> Option<&'a str> { let key = unquote(key); @@ -103,14 +133,14 @@ pub(super) fn supported_suffix(suffix: &str) -> bool { depth == 0 } -pub(super) struct Resolution<'a> { +pub(crate) struct Resolution<'a> { pub range: Range, pub fields: Vec<(&'a str, &'a str)>, block: bool, newline: &'static str, } -impl Resolution<'_> { +impl<'a> Resolution<'a> { pub fn tarball(&self) -> Option<&str> { self.fields .iter() @@ -118,6 +148,16 @@ impl Resolution<'_> { .map(|(_, v)| unquote(v)) } + /// The `integrity` field, unquoted — UNFILTERED: the lock inventory + /// records whatever the lock says, lockfile discovery keeps only SRI + /// pins (`is_sri_pin`) at its call site. + pub fn integrity(&self) -> Option<&'a str> { + self.fields + .iter() + .find(|(k, _)| *k == "integrity") + .map(|(_, v)| unquote(v.trim())) + } + pub fn rewrite(&self, integrity: &str, url: &str) -> String { // JSON strings are also YAML scalars. Keep the usual URL/SRI spelling // byte-compatible, but quote flow delimiters and whitespace. @@ -152,30 +192,51 @@ impl Resolution<'_> { } } +/// The key line every `packages:` entry's resolution map starts at. +const RESOLUTION_KEY: &str = " resolution:"; + +/// The raw text of an entry's `resolution:` value(s), whatever their shape: +/// the rest of each `resolution:` line and every deeper-indented line under +/// it. For readers that must still SEE a mapping [`resolution`] refuses +/// (lockfile discovery diagnoses one that names a Socket patch). +pub(crate) fn resolution_raw_lines<'a>(entry: &Entry<'a>) -> Vec<&'a str> { + let mut out = Vec::new(); + let mut in_resolution = false; + for line in entry.body.lines() { + let line = line.trim_end_matches('\r'); + if let Some(rest) = line.strip_prefix(RESOLUTION_KEY) { + in_resolution = true; + out.push(rest); + } else if in_resolution && line.starts_with(" ") { + out.push(line); + } else if !line.trim().is_empty() { + in_resolution = false; + } + } + out +} + /// Flat string mapping only. Aliases, nested values, duplicate keys and /// malformed mappings are refused, never guessed or partially replaced. -pub(super) fn resolution<'a>(entry: &Entry<'a>) -> Option> { +pub(crate) fn resolution<'a>(entry: &Entry<'a>) -> Option> { let mut offset = entry.offset; let mut lines = entry.body.split_inclusive('\n').peekable(); while let Some(line) = lines.next() { let text = line.trim_end_matches(['\r', '\n']); let start = offset; offset += line.len(); - let Some(value) = text.strip_prefix(" resolution:") else { + let Some(value) = text.strip_prefix(RESOLUTION_KEY) else { continue; }; // A second resolution key is invalid YAML, so do not bless it. - if lines - .clone() - .any(|line| line.starts_with(" resolution:")) - { + if lines.clone().any(|line| line.starts_with(RESOLUTION_KEY)) { return None; } let block = value.trim().is_empty(); let mut parts = Vec::new(); let range; if block { - let begin = start + " resolution:".len(); + let begin = start + RESOLUTION_KEY.len(); let mut end = begin; while let Some(child) = lines.peek() { let Some(field) = child.strip_prefix(" ").filter(|s| !s.starts_with(' ')) diff --git a/crates/socket-patch-core/src/patch/redirect/requirements.rs b/crates/socket-patch-core/src/patch/redirect/requirements.rs index 6f3a4bfe..5e0b921c 100644 --- a/crates/socket-patch-core/src/patch/redirect/requirements.rs +++ b/crates/socket-patch-core/src/patch/redirect/requirements.rs @@ -138,20 +138,8 @@ enum RequirementVersion { fn archive_version(location: &str, name: &str) -> Option { let url = reqwest::Url::parse(location).ok()?; let filename = percent_decode_purl_component(url.path().rsplit('/').next()?); - let (distribution, version) = if let Some(stem) = filename.strip_suffix(".whl") { - let mut parts = stem.splitn(3, '-'); - let distribution = parts.next()?; - let version = parts.next()?; - parts.next()?; - (distribution, version) - } else { - let stem = [".tar.gz", ".zip", ".tar.bz2", ".tar.xz"] - .into_iter() - .find_map(|suffix| filename.strip_suffix(suffix))?; - stem.rsplit_once('-')? - }; - (canonicalize_pypi_name(distribution) == name && !version.is_empty()) - .then(|| version.to_string()) + let (distribution, version) = crate::utils::requirements::archive_filename_coords(&filename)?; + (canonicalize_pypi_name(distribution) == name).then(|| version.to_string()) } fn requirement_version(specifier: &str, name_re: &Regex, name: &str) -> RequirementVersion { diff --git a/crates/socket-patch-core/src/patch/redirect/takeover.rs b/crates/socket-patch-core/src/patch/redirect/takeover.rs index 6962d6f5..49c62fe4 100644 --- a/crates/socket-patch-core/src/patch/redirect/takeover.rs +++ b/crates/socket-patch-core/src/patch/redirect/takeover.rs @@ -416,7 +416,7 @@ fn bun_spec_names(spec: &str, name: &str, version: &str) -> bool { /// to parse fails the match (closed). The exact-leaf comparison is the /// version discriminator: `pkg-1.3.0.tgz` never equals `pkg-11.3.0.tgz` /// or `pkg-1.3.0-rc1.tgz`. -pub(super) fn hosted_url_names(url: &str, name: &str, version: &str) -> bool { +pub(crate) fn hosted_url_names(url: &str, name: &str, version: &str) -> bool { if !url.starts_with("https://") && !url.starts_with("http://") { return false; } @@ -432,6 +432,23 @@ pub(super) fn hosted_url_names(url: &str, name: &str, version: &str) -> bool { !leaf.is_empty() && leaf == format!("{bare}-{version}.tgz") } +/// The version a hosted artifact `url` names for `name`: its last path +/// segment is `-.tgz` with a semver ``, confirmed by +/// [`hosted_url_names`]. How a hosted bun binary redirect's version is +/// recovered (`bun_binary::names`) and how lockfile discovery reads a bun +/// hosted ref's version. +pub(crate) fn hosted_url_version<'u>(url: &'u str, name: &str) -> Option<&'u str> { + let bare = name.rsplit('/').next().unwrap_or(name); + let version = url + .rsplit('/') + .next()? + .strip_prefix(bare)? + .strip_prefix('-')? + .strip_suffix(".tgz")?; + (semver::Version::parse(version).is_ok() && hosted_url_names(url, name, version)) + .then_some(version) +} + /// Revert every hosted-redirect edit the ledger records for `purl` (an npm /// package), then drop that purl's record and edits from `state`. The caller /// persists the mutated ledger (see `persist_redirect_state`). diff --git a/crates/socket-patch-core/src/utils/digest.rs b/crates/socket-patch-core/src/utils/digest.rs new file mode 100644 index 00000000..ff887f27 --- /dev/null +++ b/crates/socket-patch-core/src/utils/digest.rs @@ -0,0 +1,69 @@ +//! Shape predicates for the content pins lockfiles record — the ONE copy of +//! each check the lock inventory, lockfile discovery, ledger recovery and +//! the rewriters share. +//! +//! Two case policies, chosen per call site: the `is_hex` family accepts +//! either case (and the `Option` helpers lowercase what they accept), while +//! [`is_hex64_lower`] is the exact shape cargo and `hex::encode` write. A +//! call site never switches from one policy to the other silently — the +//! inventory's cargo checksum, for one, feeds ledger liveness through the +//! crates.io provenance it records. + +/// Whether `s` is an SRI integrity pin in an algorithm npm-family package +/// managers verify: its first whitespace-separated token is +/// `sha512-` / `sha384-` / `sha256-` / `sha1-` followed by a digest. The ONE +/// rule the inventory and every lockfile-discovery extractor share, so a +/// string is a pin in both or in neither. +pub(crate) fn is_sri_pin(s: &str) -> bool { + s.split_whitespace().next().is_some_and(|first| { + ["sha512-", "sha384-", "sha256-", "sha1-"] + .iter() + .any(|p| first.starts_with(p) && first.len() > p.len()) + }) +} + +/// `len` hex digits, either case. +pub(crate) fn is_hex(s: &str, len: usize) -> bool { + s.len() == len && s.bytes().all(|b| b.is_ascii_hexdigit()) +} + +/// 64 LOWERCASE hex digits — the exact shape `hex::encode(sha256)` / the TS +/// `Buffer.toString('hex')` and cargo's `checksum` produce (anything else +/// written as a Cargo.lock `checksum` breaks the next fetch). +pub(crate) fn is_hex64_lower(s: &str) -> bool { + s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) +} + +/// A hex sha256 (64 hex digits, either case), lowercased. +pub(crate) fn sha256_hex(s: &str) -> Option { + is_hex(s, 64).then(|| s.to_ascii_lowercase()) +} + +/// `sha256:` (uv / poetry / pdm artifact hashes) → the lowercased hex. +pub(crate) fn sha256_prefixed(s: &str) -> Option { + s.strip_prefix("sha256:").and_then(sha256_hex) +} + +/// A hex sha1 (40 hex digits, either case), lowercased. +pub(crate) fn sha1_hex(s: &str) -> Option { + is_hex(s, 40).then(|| s.to_ascii_lowercase()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn helpers_keep_their_case_policies() { + let lower = "a".repeat(64); + let upper = "A".repeat(64); + assert!(is_hex(&lower, 64) && is_hex(&upper, 64)); + assert!(is_hex64_lower(&lower) && !is_hex64_lower(&upper)); + assert_eq!(sha256_hex(&upper), Some(lower.clone())); + assert_eq!(sha256_prefixed(&format!("sha256:{upper}")), Some(lower)); + assert_eq!(sha256_prefixed(&upper), None); + assert_eq!(sha1_hex(&"B".repeat(40)), Some("b".repeat(40))); + assert_eq!(sha1_hex(&"b".repeat(41)), None); + assert!(is_sri_pin("sha512-abc") && !is_sri_pin("sha512-") && !is_sri_pin("md5-x")); + } +} diff --git a/crates/socket-patch-core/src/utils/hatch.rs b/crates/socket-patch-core/src/utils/hatch.rs index 57cb5314..8ec5ad07 100644 --- a/crates/socket-patch-core/src/utils/hatch.rs +++ b/crates/socket-patch-core/src/utils/hatch.rs @@ -4,7 +4,7 @@ use toml_edit::{DocumentMut, Item, Value}; use crate::crawlers::python_crawler::canonicalize_pypi_name; use crate::utils::python_lock::preserve_line_endings; -use crate::vendor::common::pep508_name; +use crate::vendor::common::{pep508_name, pyproject_dependency_specs, DeclTable}; /// The two documents the hatch planner reads and rewrites, in the order /// [`plan`] parses them. The redirect overlay (`redirect/mod.rs`) clones @@ -28,40 +28,87 @@ pub fn is_hatch(files: &BTreeMap) -> bool { }) } -pub fn has_environment_dependency(files: &BTreeMap, name: &str) -> bool { - let external = files - .get("hatch.toml") - .and_then(|text| text.parse::().ok()); - let project = files - .get("pyproject.toml") - .and_then(|text| text.parse::().ok()); - let environments = external - .as_ref() - .and_then(|document| document.get("envs")) - .or_else(|| { - project - .as_ref() - .and_then(|document| document.get("tool")) - .and_then(|tool| tool.get("hatch")) - .and_then(|hatch| hatch.get("envs")) - }); - environments +/// One PEP 508 dependency string Hatch installs. +pub(crate) struct HatchSpec<'d> { + /// `pyproject.toml` or `hatch.toml` (a [`HATCH_FILES`] entry). + pub(crate) file: &'static str, + pub(crate) table: DeclTable, + pub(crate) spec: &'d str, +} + +/// Every dependency string Hatch reads, in this order: pyproject's +/// [`pyproject_dependency_specs`] (`[project]` dependencies, extras, PEP 735 +/// groups), then the environments' `dependencies` / `extra-dependencies` — +/// pyproject's `[tool.hatch.envs.*]` unless hatch.toml carries an `envs` key, +/// in which case hatch.toml's `[envs.*]`. Each caller parses the documents +/// its own way and passes `None` for one that is absent or not TOML. Shared +/// by the planner's predicates below and lockfile discovery +/// (`vex::discover::pypi_other`), which walk the same tables. +pub(crate) fn dependency_specs<'d>( + pyproject: Option<&'d DocumentMut>, + hatch_toml: Option<&'d DocumentMut>, +) -> Vec> { + let mut specs: Vec> = pyproject + .into_iter() + .flat_map(pyproject_dependency_specs) + .map(|(table, spec)| HatchSpec { + file: HATCH_FILES[0], + table, + spec, + }) + .collect(); + specs.extend(environment_specs(pyproject, hatch_toml)); + specs +} + +/// The Hatch environment tables: `envs` of the hatch.toml document, else of +/// pyproject's `[tool.hatch]` (Hatch merges an external config by TOP-LEVEL +/// key, so a hatch.toml `envs` key — whatever its value — replaces +/// pyproject's whole table). +fn environment_specs<'d>( + pyproject: Option<&'d DocumentMut>, + hatch_toml: Option<&'d DocumentMut>, +) -> Vec> { + let (file, hatch) = match hatch_toml.filter(|d| d.contains_key("envs")) { + Some(doc) => (HATCH_FILES[1], Some(doc.as_item())), + None => ( + HATCH_FILES[0], + pyproject + .and_then(|d| d.get("tool")) + .and_then(|t| t.get("hatch")), + ), + }; + hatch + .and_then(|h| h.get("envs")) .and_then(Item::as_table_like) - .is_some_and(|environments| { - environments.iter().any(|(_, environment)| { - ["dependencies", "extra-dependencies"].iter().any(|key| { - environment - .get(key) - .and_then(Item::as_array) - .is_some_and(|dependencies| { - dependencies.iter().filter_map(Value::as_str).any(|spec| { - canonicalize_pypi_name(pep508_name(spec)) - == canonicalize_pypi_name(name) - }) - }) - }) - }) + .into_iter() + .flat_map(|envs| envs.iter()) + .flat_map(|(_, env)| { + ["dependencies", "extra-dependencies"] + .into_iter() + .filter_map(move |key| env.get(key)) + }) + .filter_map(Item::as_array) + .flatten() + .filter_map(Value::as_str) + .map(|spec| HatchSpec { + file, + table: DeclTable::Env, + spec, }) + .collect() +} + +fn parsed(files: &BTreeMap, file: &str) -> Option { + files.get(file).and_then(|text| text.parse().ok()) +} + +pub fn has_environment_dependency(files: &BTreeMap, name: &str) -> bool { + let external = parsed(files, HATCH_FILES[1]); + let project = parsed(files, HATCH_FILES[0]); + environment_specs(project.as_ref(), external.as_ref()) + .iter() + .any(|s| canonicalize_pypi_name(pep508_name(s.spec)) == canonicalize_pypi_name(name)) } fn replacement(spec: &str, name: &str, version: &str, url: &str) -> Result, String> { @@ -282,35 +329,16 @@ fn enable_permission(document: &mut DocumentMut, external: bool) -> Result<(), S } pub fn has_project_direct_references(files: &BTreeMap) -> bool { - let Some(document) = files - .get("pyproject.toml") - .and_then(|text| text.parse::().ok()) - else { + let Some(document) = parsed(files, HATCH_FILES[0]) else { return false; }; - let project = document.get("project"); - let mut arrays = Vec::new(); - if let Some(dependencies) = project - .and_then(|project| project.get("dependencies")) - .and_then(Item::as_array) - { - arrays.push(dependencies); - } - for groups in [ - project.and_then(|project| project.get("optional-dependencies")), - document.get("dependency-groups"), - ] { - if let Some(groups) = groups.and_then(Item::as_table_like) { - arrays.extend(groups.iter().filter_map(|(_, value)| value.as_array())); - } - } - arrays.iter().any(|array| { - array.iter().filter_map(Value::as_str).any(|spec| { + pyproject_dependency_specs(&document) + .into_iter() + .any(|(_, spec)| { spec.split(';') .next() .is_some_and(|requirement| requirement.contains('@')) }) - }) } pub fn plan( @@ -530,6 +558,74 @@ mod tests { } } + fn both(pyproject: &str, hatch: Option<&str>) -> BTreeMap { + let mut inputs = files(pyproject); + if let Some(hatch) = hatch { + inputs.insert("hatch.toml".into(), hatch.into()); + } + inputs + } + + #[test] + fn environment_dependency_follows_the_effective_envs_table() { + let inline = "[tool.hatch.envs.default]\ndependencies=[\"Urllib3 >=1\"]\n\ + [tool.hatch.envs.test]\nextra-dependencies=[\"idna==3.6\"]\n"; + assert!(has_environment_dependency(&both(inline, None), "urllib3")); + assert!(has_environment_dependency(&both(inline, None), "IDNA")); + assert!(!has_environment_dependency(&both(inline, None), "six")); + // A hatch.toml `envs` table replaces pyproject's (top-level merge). + let external = "[envs.default]\ndependencies=[\"six==1.16.0\"]\n"; + assert!(!has_environment_dependency( + &both(inline, Some(external)), + "urllib3" + )); + assert!(has_environment_dependency( + &both(inline, Some(external)), + "six" + )); + // …even a non-table one; a hatch.toml without `envs`, or one that is + // not TOML, leaves pyproject's in force. + assert!(!has_environment_dependency( + &both(inline, Some("envs = 1\n")), + "urllib3" + )); + for hatch in ["[metadata]\nx = 1\n", "not = [toml"] { + assert!( + has_environment_dependency(&both(inline, Some(hatch)), "urllib3"), + "{hatch}" + ); + } + // Project tables are not environments; non-string members are skipped. + let project = "[project]\ndependencies=[\"urllib3==1\"]\n\ + [tool.hatch.envs.default]\ndependencies=[1, {x=1}]\n"; + assert!(!has_environment_dependency(&both(project, None), "urllib3")); + assert!(!has_environment_dependency( + &both("not = [toml", None), + "urllib3" + )); + } + + #[test] + fn project_direct_references_cover_every_project_table() { + for text in [ + "[project]\ndependencies=[\"a @ https://x.test/a.whl\"]", + "[project.optional-dependencies]\nx=[\"b\", \"a @ file:///a.whl\"]", + "[dependency-groups]\nqa=[{include-group=\"x\"}, \"a@https://x.test/a.whl\"]", + ] { + assert!(has_project_direct_references(&files(text)), "{text}"); + } + for text in [ + "not = [toml", + "[project]\ndependencies=[\"a ; python_version >= '3' and extra == 'x@y'\"]", + "[project.optional-dependencies]\nx=\"a @ https://x.test/a.whl\"", + "[dependency-groups]\nqa=\"a @ https://x.test/a.whl\"", + "[tool.hatch.envs.default]\ndependencies=[\"a @ https://x.test/a.whl\"]", + ] { + assert!(!has_project_direct_references(&files(text)), "{text}"); + } + assert!(!has_project_direct_references(&BTreeMap::new())); + } + #[test] fn groups_accept_hosted_and_refuse_unexpanded_vendor_context() { let inputs = files("[dependency-groups]\nqa=[\"urllib3==1.26.18\"]\n[tool.hatch.envs.default]\ndependency-groups=[\"qa\"]"); diff --git a/crates/socket-patch-core/src/utils/mod.rs b/crates/socket-patch-core/src/utils/mod.rs index f37fa126..ad349708 100644 --- a/crates/socket-patch-core/src/utils/mod.rs +++ b/crates/socket-patch-core/src/utils/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod digest; pub mod env_compat; pub mod fs; pub mod notice; @@ -9,6 +10,7 @@ pub mod process; pub mod purl; pub mod python_lock; pub mod python_script; +pub(crate) mod requirements; pub(crate) mod serde; pub mod socket_cli_config; pub mod socket_dir; diff --git a/crates/socket-patch-core/src/utils/poetry_lock.rs b/crates/socket-patch-core/src/utils/poetry_lock.rs index b04e3b36..edb73554 100644 --- a/crates/socket-patch-core/src/utils/poetry_lock.rs +++ b/crates/socket-patch-core/src/utils/poetry_lock.rs @@ -12,9 +12,35 @@ //! panic: every table access here is guarded and degrades to an `Err`, which //! callers surface as a refusal warning. -use toml_edit::{value, Array, DocumentMut, InlineTable, Item, Table, Value}; +use toml_edit::{value, Array, DocumentMut, InlineTable, Item, Table, TableLike, Value}; use crate::crawlers::python_crawler::canonicalize_pypi_name; +use crate::utils::python_lock::table_likes; + +/// The `{file, hash}` tables Poetry records in `package`'s own +/// `files = [...]` (lock 2.x; also written into 1.0/1.1 locks). Read by the +/// lock inventory and lockfile discovery alike. +pub(crate) fn package_files(package: &Table) -> Vec<&dyn TableLike> { + table_likes(package.get("files")) +} + +/// The lock-wide `[metadata.files]` entry for package `name` (lock +/// 1.0/1.1; keys compared PEP 503-canonical). +pub(crate) fn metadata_files<'d>(lock: &'d DocumentMut, name: &str) -> Vec<&'d dyn TableLike> { + let canon = canonicalize_pypi_name(name); + let entry = lock + .get("metadata") + .and_then(Item::as_table_like) + .and_then(|metadata| metadata.get("files")) + .and_then(Item::as_table_like) + .and_then(|files| { + files + .iter() + .find(|(key, _)| canonicalize_pypi_name(key) == canon) + .map(|(_, item)| item) + }); + table_likes(entry) +} /// The lock generation: `"0"`, `"1.0"`, `"1.1"`, or any `"2."` (Poetry /// bumps the minor additively — 2.0 → 2.1 kept every shape we rewrite, and the diff --git a/crates/socket-patch-core/src/utils/purl.rs b/crates/socket-patch-core/src/utils/purl.rs index a453b056..c317856e 100644 --- a/crates/socket-patch-core/src/utils/purl.rs +++ b/crates/socket-patch-core/src/utils/purl.rs @@ -1,5 +1,8 @@ use std::borrow::Cow; +use crate::crawlers::python_crawler::canonicalize_pypi_name; +use crate::patch::path_safety::{is_safe_multi_segment, is_safe_single_segment}; + /// Strip the trailing `?qualifiers` and `#subpath` components from a PURL, /// leaving the canonical `pkg:type/namespace/name@version` base. /// @@ -144,13 +147,30 @@ pub fn canonical_purl(purl: &str) -> String { /// is missing. Input must already be canonicalized (qualifiers stripped, /// percent-decoded) — the redirect ledger's version-exact matcher feeds it /// [`canonical_purl`] output. -pub(crate) fn purl_name_version(purl: &str) -> Option<(&str, &str)> { +pub fn purl_name_version(purl: &str) -> Option<(&str, &str)> { let rest = purl.strip_prefix("pkg:")?; let (_, coord) = rest.split_once('/')?; let at = coord.rfind('@').filter(|&i| i > 0)?; Some((&coord[..at], &coord[at + 1..])) } +/// `pkg:/@` → `(type, coordinate, version)`, for +/// API / ledger spellings: qualifiers and subpath stripped first, the +/// version split off at the LAST `@`, and coordinate and version each +/// percent-decoded (the API serves `1.2.3%2Bbuild` and `%40scope/name`; +/// lockfiles and install dirs store the literal forms). The coordinate keeps +/// its slashes (npm `@scope/name`, maven `group/artifact`, a go module path). +pub fn purl_parts(purl: &str) -> Option<(String, String, String)> { + let rest = strip_purl_qualifiers(purl).strip_prefix("pkg:")?; + let (ty, tail) = rest.split_once('/')?; + let (name, version) = tail.rsplit_once('@')?; + Some(( + ty.to_string(), + percent_decode_purl_component(name).into_owned(), + percent_decode_purl_component(version).into_owned(), + )) +} + /// Shared split for `pkg:/@` purls: strip /// `?qualifiers`/`#subpath` FIRST (a qualifier value can itself embed an /// `@`, e.g. a `git@github.com` source URL), require `prefix`, then split @@ -350,6 +370,118 @@ pub fn patch_matches(purl: &str, uuid: &str, identifier: &str) -> bool { } } +// ── validating builders ───────────────────────────────────────────────── +// The purl builders lockfile discovery (`vex::discover`, which re-exports +// them under the same names) and the lock inventory's registry views share: +// each validates the coordinates fail-closed — lockfiles are committed, +// tamper-able input whose names and versions later feed filesystem paths +// and download urls — and returns `None` rather than a purl it would have +// to trust. + +/// `pkg:npm/@` for lock-recorded coordinates, or `None` when +/// the name fails the npm backends' own shape rule (at most one `/`, only +/// under an `@scope`, traversal-safe) or the version is not a single safe +/// segment. +pub fn npm_purl(name: &str, version: &str) -> Option { + (crate::vendor::is_safe_npm_name(name) && is_safe_single_segment(version)) + .then(|| format!("pkg:npm/{name}@{version}")) +} + +/// `pkg:pypi/@`. +pub fn pypi_purl(name: &str, version: &str) -> Option { + let name = canonicalize_pypi_name(name); + (is_safe_single_segment(&name) && is_safe_single_segment(version)) + .then(|| format!("pkg:pypi/{name}@{version}")) +} + +/// `pkg:/@` for the single-segment-name ecosystems +/// (`cargo`, `gem`, `nuget`). +pub fn simple_purl(ty: &str, name: &str, version: &str) -> Option { + (matches!(ty, "cargo" | "gem" | "nuget") + && is_safe_single_segment(name) + && is_safe_single_segment(version)) + .then(|| format!("pkg:{ty}/{name}@{version}")) +} + +/// `pkg:golang/@` (module path multi-segment, version one +/// segment). +pub fn golang_purl(module: &str, version: &str) -> Option { + (is_safe_multi_segment(module) && is_safe_single_segment(version)) + .then(|| build_golang_purl(module, version)) +} + +/// `pkg:composer//@` from a composer `vendor/name` +/// (exactly two safe segments; lowercased like composer itself). +pub fn composer_purl(name: &str, version: &str) -> Option { + let name = name.to_lowercase(); + let (vendor, pkg) = name.split_once('/')?; + (is_safe_single_segment(vendor) + && is_safe_single_segment(pkg) + && is_safe_single_segment(version)) + .then(|| format!("pkg:composer/{vendor}/{pkg}@{version}")) +} + +/// `pkg:maven//@`, validated by the maven +/// crawler's own coordinate guard (every dot-split group segment, the +/// artifact and the version each a safe single segment — an empty group +/// fails as one empty segment). +pub fn maven_purl(group: &str, artifact: &str, version: &str) -> Option { + crate::crawlers::maven_crawler::is_safe_maven_coordinate(group, artifact, version) + .then(|| build_maven_purl(group, artifact, version)) +} + +#[cfg(test)] +mod builder_tests { + use super::*; + + #[test] + fn purl_builders_validate_coordinates() { + assert_eq!( + npm_purl("left-pad", "1.3.0").as_deref(), + Some("pkg:npm/left-pad@1.3.0") + ); + assert_eq!( + npm_purl("@s/x", "1.0.0").as_deref(), + Some("pkg:npm/@s/x@1.0.0") + ); + assert_eq!(npm_purl("a/b", "1.0.0"), None, "unscoped slash"); + assert_eq!(npm_purl("@s/x/y", "1.0.0"), None, "extra level"); + assert_eq!(npm_purl("x", "../1"), None); + assert_eq!(npm_purl("..", "1.0.0"), None); + assert_eq!( + pypi_purl("Foo.Bar", "1.0").as_deref(), + Some("pkg:pypi/foo-bar@1.0") + ); + assert_eq!(pypi_purl("a/b", "1.0"), None); + assert_eq!( + simple_purl("cargo", "serde", "1.0.0").as_deref(), + Some("pkg:cargo/serde@1.0.0") + ); + assert_eq!( + simple_purl("npm", "x", "1"), + None, + "not a single-segment ecosystem" + ); + assert_eq!(simple_purl("gem", "a:b", "1"), None); + assert_eq!( + golang_purl("github.com/foo/bar", "v1.2.3").as_deref(), + Some("pkg:golang/github.com/foo/bar@v1.2.3") + ); + assert_eq!(golang_purl("github.com/../x", "v1"), None); + assert_eq!( + composer_purl("Monolog/Monolog", "2.0.0").as_deref(), + Some("pkg:composer/monolog/monolog@2.0.0") + ); + assert_eq!(composer_purl("monolog", "2.0.0"), None); + assert_eq!( + maven_purl("org.slf4j", "slf4j-api", "1.7.36").as_deref(), + Some("pkg:maven/org.slf4j/slf4j-api@1.7.36") + ); + assert_eq!(maven_purl("org..x", "a", "1"), None); + assert_eq!(maven_purl("", "a", "1"), None, "empty group"); + } +} + #[cfg(test)] mod tests { use super::*; @@ -982,3 +1114,58 @@ mod tests { ); } } + +#[cfg(test)] +mod parts_tests { + use super::*; + + #[test] + fn purl_parts_strip_qualifiers_and_decode() { + assert_eq!( + purl_parts("pkg:maven/org.example/lib@1.0.0?classifier=native&ext=jar"), + Some(("maven".into(), "org.example/lib".into(), "1.0.0".into())) + ); + assert_eq!( + purl_parts("pkg:golang/github.com/foo/bar@v1.4.2"), + Some(( + "golang".into(), + "github.com/foo/bar".into(), + "v1.4.2".into() + )) + ); + assert_eq!(purl_parts("not a purl"), None); + } + + #[test] + fn purl_parts_percent_decodes_name_and_version() { + // The API serves canonical percent-encoded purls: npm build metadata + // `1.2.3+build` arrives as `1.2.3%2Bbuild`. Lock entries store the + // decoded form, so an undecoded version silently matches nothing. + assert_eq!( + purl_parts("pkg:npm/foo@1.2.3%2Bbuild"), + Some(( + "npm".to_string(), + "foo".to_string(), + "1.2.3+build".to_string() + )) + ); + // The coordinate keeps decoding too (scoped npm name). + assert_eq!( + purl_parts("pkg:npm/%40scope/name@1.0.0"), + Some(( + "npm".to_string(), + "@scope/name".to_string(), + "1.0.0".to_string() + )) + ); + // Plain versions pass through unchanged. + assert_eq!( + purl_parts("pkg:npm/left-pad@1.3.0"), + Some(( + "npm".to_string(), + "left-pad".to_string(), + "1.3.0".to_string() + )) + ); + } +} diff --git a/crates/socket-patch-core/src/utils/python_lock.rs b/crates/socket-patch-core/src/utils/python_lock.rs index 2c37d798..04887529 100644 --- a/crates/socket-patch-core/src/utils/python_lock.rs +++ b/crates/socket-patch-core/src/utils/python_lock.rs @@ -1,8 +1,9 @@ use std::path::Path; -use toml_edit::{Array, ArrayOfTables, DocumentMut, InlineTable, Item, Table, Value}; +use toml_edit::{Array, ArrayOfTables, DocumentMut, InlineTable, Item, Table, TableLike, Value}; use crate::crawlers::python_crawler::canonicalize_pypi_name; +use crate::utils::digest::{sha256_hex, sha256_prefixed}; #[derive(Clone, Copy, Debug)] pub enum ArtifactSource<'a> { @@ -26,6 +27,153 @@ impl ArtifactSource<'_> { } } +// ── read model shared by the lock inventory and lockfile discovery ──── + +/// The `[[…]]` array holding a native Python lock's packages, and whether +/// the lock is PEP 751: `packages` (a `lock-version` pylock), `distribution` +/// (uv 0.2.x) or `package` (uv ≥ 0.2.35 and PEP 723 script locks). The +/// lock inventory, lockfile discovery and `lock_inventory:: +/// wired_vendor_integrity` all pick the array with this one rule. +pub(crate) fn lock_package_collection(doc: &DocumentMut) -> (&'static str, bool) { + if doc.contains_key("lock-version") { + ("packages", true) + } else if doc.contains_key("distribution") { + ("distribution", false) + } else { + ("package", false) + } +} + +/// The tables of an array-of-tables, an array of inline tables, or a single +/// (inline) table item — every shape a lock writes an artifact list in. +pub(crate) fn table_likes(item: Option<&Item>) -> Vec<&dyn TableLike> { + match item { + Some(Item::ArrayOfTables(tables)) => tables.iter().map(|t| t as &dyn TableLike).collect(), + Some(Item::Value(Value::Array(values))) => values + .iter() + .filter_map(Value::as_inline_table) + .map(|t| t as &dyn TableLike) + .collect(), + Some(item) => item.as_table_like().into_iter().collect(), + None => Vec::new(), + } +} + +/// One artifact entry (`wheels[]`, `wheel`, `sdist`, `archive`) of a lock +/// package. +pub(crate) struct LockArtifact<'t> { + pub(crate) url: Option<&'t str>, + pub(crate) path: Option<&'t str>, + /// uv's local-wheel `filename`. + pub(crate) filename: Option<&'t str>, + /// The lowercase sha256: `hash = "sha256:"` (uv), else + /// `hashes = { sha256 = "" }` (PEP 751); only a 64-hex digest. + pub(crate) sha256: Option, +} + +impl<'t> LockArtifact<'t> { + /// Where the artifact is fetched from: its `url`, else its `path`. + pub(crate) fn location(&self) -> Option<&'t str> { + self.url.or(self.path) + } +} + +/// Read one artifact table (see [`LockArtifact`]). +pub(crate) fn lock_artifact(table: &dyn TableLike) -> LockArtifact<'_> { + let str_of = |key: &str| table.get(key).and_then(Item::as_str); + let sha256 = str_of("hash").and_then(sha256_prefixed).or_else(|| { + table + .get("hashes") + .and_then(Item::as_table_like) + .and_then(|hashes| hashes.get("sha256")) + .and_then(Item::as_str) + .and_then(sha256_hex) + }); + LockArtifact { + url: str_of("url"), + path: str_of("path"), + filename: str_of("filename"), + sha256, + } +} + +/// Every artifact of `package` under `keys`, in key order (singular tables +/// and arrays alike). +pub(crate) fn package_artifacts<'t>( + package: &'t dyn TableLike, + keys: &[&str], +) -> Vec> { + keys.iter() + .flat_map(|key| table_likes(package.get(key))) + .map(lock_artifact) + .collect() +} + +/// A uv package's `source`, in either spelling: the uv ≤ 0.2.17 string +/// grammar `"+"` or the `{ registry | url | path | … }` table. +/// The one reader of both; each caller keeps its own precedence between the +/// kinds — the inventory's [`Self::is_remote`], [`uv_source_location`] +/// (url before path, registry ignored) and lockfile discovery's +/// resolved-elsewhere evidence (registry first). +#[derive(Clone, Copy)] +pub(crate) enum UvSource<'t> { + Str(&'t str), + Table(&'t dyn TableLike), +} + +impl<'t> UvSource<'t> { + /// `package`'s `source`; `None` when absent or neither a string nor a + /// table. + pub(crate) fn of(package: &'t dyn TableLike) -> Option { + let source = package.get("source")?; + match source.as_str() { + Some(s) => Some(Self::Str(s)), + None => source.as_table_like().map(Self::Table), + } + } + + /// The `+` value of the string form, or the table's string `key`. + fn field(self, prefix: &str, key: &str) -> Option<&'t str> { + match self { + Self::Str(s) => s.strip_prefix(prefix), + Self::Table(table) => table.get(key).and_then(Item::as_str), + } + } + + /// `registry+` / `registry = ""`. + pub(crate) fn registry(self) -> Option<&'t str> { + self.field("registry+", "registry") + } + + /// `direct+` / `url = ""`. + pub(crate) fn url(self) -> Option<&'t str> { + self.field("direct+", "url") + } + + /// `path+` / `path = ""`. + pub(crate) fn path(self) -> Option<&'t str> { + self.field("path+", "path") + } + + /// Whether the source is a registry or a direct url — a remote the fetch + /// layer can resolve. The table form tests key PRESENCE (a non-string + /// value still counts). + pub(crate) fn is_remote(self) -> bool { + match self { + Self::Str(s) => s.starts_with("registry+") || s.starts_with("direct+"), + Self::Table(table) => table.contains_key("registry") || table.contains_key("url"), + } + } +} + +/// A uv package's install location: `source = { url | path }`, or the uv +/// 0.2.x string grammar `direct+` / `path+`. Registry, git, +/// editable, virtual and directory sources yield `None`. +pub(crate) fn uv_source_location(package: &dyn TableLike) -> Option<&str> { + let source = UvSource::of(package)?; + source.url().or_else(|| source.path()) +} + pub fn is_python_lock_name(name: &str) -> bool { name == "uv.lock" || name.ends_with(".py.lock") @@ -667,6 +815,45 @@ pub fn rewrite_python_lock( Ok(Some(preserve_line_endings(text, document.to_string()))) } +/// Whether `name` is a PEP 723 script lock (`