From 92630055336f74d898c59a1e2124f7cd907e171f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 15:00:36 +0000 Subject: [PATCH 01/10] Start refactor for #594 Assisted-by: Claude Code:claude-opus-5-5 From 3f929fd3a95e122588353464aad9171a24f9a2a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 15:16:29 +0000 Subject: [PATCH 02/10] Wire vendored nuget.config via formats::nuget Vendored NuGet now reads the source keys and finds the , and anchors through formats::nuget::parse_config, the reader that hosted, upstream restore and VEX already use. The private substring scanner (blank_comments, parse_config_source_keys, attr_value, self_closing_package_sources, insert_at_line) is deleted. User impact: - A close tag written with whitespace () is now the section that gets extended; vendor used to append a second section NuGet ignores, so restore failed NU1100/NU1403 (#685). - An empty is expanded in place instead of left beside a second mapping section. - A section opened and closed on one line receives the source inside it, not before its open tag. - Catch-all keys are written XML-encoded, so a key with & or a quote keeps its identity. - Malformed XML or a repeated section is refused with "malformed XML or a repeated section; not wired" instead of being spliced at the first substring match, as hosted already does. Output bytes for well-formed configs are unchanged. Fixes #685 Refs #594 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/formats/nuget/mod.rs | 14 + .../src/vendor/nuget_feed.rs | 529 +++++++++++------- 2 files changed, 335 insertions(+), 208 deletions(-) diff --git a/crates/socket-patch-core/src/formats/nuget/mod.rs b/crates/socket-patch-core/src/formats/nuget/mod.rs index fdf41875c..1499a27d2 100644 --- a/crates/socket-patch-core/src/formats/nuget/mod.rs +++ b/crates/socket-patch-core/src/formats/nuget/mod.rs @@ -291,6 +291,20 @@ fn decode_entities(raw: &str) -> String { out } +/// Encode `value` for a double-quoted attribute: the inverse of +/// [`parse_config`]'s decoding, so a key read as `a&b` is written back as +/// `a&b` and keeps its identity. +pub(crate) fn xml_attribute(value: &str) -> String { + value + .replace('&', "&") + .replace('"', """) + .replace('<', "<") + // Literal XML attribute whitespace would be normalized to spaces. + .replace('\t', " ") + .replace('\n', " ") + .replace('\r', " ") +} + #[cfg(test)] mod tests { #[test] diff --git a/crates/socket-patch-core/src/vendor/nuget_feed.rs b/crates/socket-patch-core/src/vendor/nuget_feed.rs index 70d6be479..13c335489 100644 --- a/crates/socket-patch-core/src/vendor/nuget_feed.rs +++ b/crates/socket-patch-core/src/vendor/nuget_feed.rs @@ -256,7 +256,8 @@ async fn nuget_prelude( // key merely mentioned elsewhere — is not wiring NuGet reads. let config_wired = config_text .as_deref() - .is_some_and(|t| parse_config_source_keys(&blank_comments(t)).contains(&source_key)); + .and_then(crate::formats::nuget::parse_config) + .is_some_and(|parsed| parsed.sources.iter().any(|(key, _)| *key == source_key)); let in_sync = config_wired && { // One guarded read of the committed nupkg serves both the member-hash // check and the lock's content-hash pin. @@ -891,17 +892,24 @@ fn build_config_edit( }) } Some(text) => { - // Every anchor find and source scan runs against the - // comment-blanked view (same length, so offsets splice into - // `text`). NuGet never reads a comment: a commented-out section - // must not capture an insert (the wired source would be invisible - // and restore would silently serve the UNPATCHED package), and a - // commented-out `` must not become a catch-all target (the - // mapping would fan `*` out to a source that does not exist). - let visible = blank_comments(text); + // Keys and anchors come from the one `nuget.config` reader that + // hosted, restore and VEX use. NuGet never reads a comment, CDATA + // or an element outside `configuration/
`: a commented-out + // section must not capture an insert (the wired source would be + // invisible and restore would silently serve the UNPATCHED + // package), and a commented-out `` must not become a + // catch-all target (the mapping would fan `*` out to a source that + // does not exist). Malformed XML or a repeated section has no + // single live anchor, so it is refused rather than guessed at. + let parsed = parse_wirable_config(text)?; // Whether we are about to CREATE the mapping section (vs. extend an - // existing one) — decided against the pre-edit text. - let creating_mapping = !visible.contains(""); + // existing one) — decided against the pre-edit text. An empty + // self-closing `` maps nothing, so it is + // created (expanded in place) too. + let creating_mapping = parsed + .source_mapping + .as_ref() + .is_none_or(|section| section.close_start.is_none()); // The pre-existing sources the catch-all fans `*` out to. When the // config has NONE and we are creating a mapping from scratch, a // socket-only mapping would NU1100 every other package, so seed the @@ -912,7 +920,12 @@ fn build_config_edit( // suppressing the seed on it recreates the exact socket-only // mapping the seed exists to prevent. Mirrors // redirect::add_nuget_source. - let mut catch_all_keys = parse_config_source_keys(&visible); + let mut catch_all_keys: Vec = Vec::new(); + for (key, _) in &parsed.sources { + if !catch_all_keys.contains(key) { + catch_all_keys.push(key.clone()); + } + } let seed_nuget_org = creating_mapping && catch_all_keys.is_empty(); let source_add = format!(" \n"); @@ -931,45 +944,59 @@ fn build_config_edit( // self-closing `` carries no children, so // expand it in place into an open/close pair rather than leaving // it dangling beside a duplicate element. - let with_source = if let Some((start, end)) = self_closing_package_sources(&visible) { - let mut expanded = String::with_capacity(text.len() + injected_sources.len() + 40); - expanded.push_str(&text[..start]); - expanded.push_str(&format!( - "\n{injected_sources} " - )); - expanded.push_str(&text[end..]); - expanded - } else if let Some(at) = visible.find("") { - insert_at_line(text, at, &injected_sources) - } else if let Some(at) = visible.find("") { - let block = format!(" \n{injected_sources} \n"); - insert_at_line(text, at, &block) - } else { - return Err("nuget.config has no to edit".to_string()); + let no_root = || "nuget.config has no to edit".to_string(); + let with_source = match &parsed.package_sources { + Some(section) => { + insert_children(text, section, "packageSources", &injected_sources) + } + None => { + let root = parsed.configuration.as_ref().ok_or_else(no_root)?; + let block = + format!(" \n{injected_sources} \n"); + insert_before_close(text, root, &block).ok_or_else(no_root)? + } }; // 2. Mapping: extend an existing section, or create one over the - // pre-existing sources (the load-bearing catch-all). The blanked - // view is recomputed — step 1 shifted the offsets. - let visible_ws = blank_comments(&with_source); + // pre-existing sources (the load-bearing catch-all). Step 1 + // shifted the offsets, so the edited text is re-read through + // the same tokenizer. + let updated = parse_wirable_config(&with_source)?; let new_text = if !creating_mapping { - let at = visible_ws.find("").ok_or_else(|| { + let section = updated.source_mapping.as_ref().ok_or_else(|| { "could not locate to insert the mapping".to_string() })?; - insert_at_line(&with_source, at, &mapping_fragment) + insert_children( + &with_source, + section, + "packageSourceMapping", + &mapping_fragment, + ) } else { - let mut block = String::from(" \n"); + let mut inner = String::new(); for key in &catch_all_keys { - block.push_str(&format!( - " \n \n \n" + inner.push_str(&format!( + " \n \n \n", + crate::formats::nuget::xml_attribute(key) )); } - block.push_str(&mapping_fragment); - block.push_str(" \n"); - let at = visible_ws.find("").ok_or_else(|| { - "could not locate to insert a packageSourceMapping section" - .to_string() - })?; - insert_at_line(&with_source, at, &block) + inner.push_str(&mapping_fragment); + match &updated.source_mapping { + Some(section) => { + insert_children(&with_source, section, "packageSourceMapping", &inner) + } + None => { + let block = + format!(" \n{inner} \n"); + updated + .configuration + .as_ref() + .and_then(|root| insert_before_close(&with_source, root, &block)) + .ok_or_else(|| { + "could not locate to insert a packageSourceMapping section" + .to_string() + })? + } + } }; Ok(ConfigEdit { new_text, @@ -979,121 +1006,58 @@ fn build_config_edit( } } -/// `text` with every `` comment blanked to spaces (newlines kept), -/// preserving length so offsets found in the blanked view splice into the -/// original. NuGet never reads a comment, so anchors and source keys inside -/// one must be invisible to the wiring logic — the nuget twin of maven's -/// `find_wireable_anchor` comment masking. An unterminated comment blanks -/// through EOF (fail-closed). -fn blank_comments(text: &str) -> String { - let mut out = text.as_bytes().to_vec(); - let mut from = 0; - while let Some(rel) = text[from..].find("") { - Some(rel_end) => start + 4 + rel_end + 3, - None => text.len(), - }; - for b in &mut out[start..end] { - if *b != b'\n' { - *b = b' '; - } - } - from = end; - } - // Every replaced byte became ASCII space; newlines are never continuation - // bytes, so the result is valid UTF-8. - String::from_utf8(out).expect("blanking preserves UTF-8") -} - -/// Insert `insertion` (already newline-terminated) at the start of the line -/// containing byte offset `at` — the offset comes from the comment-blanked -/// view, which shares offsets with `text`. -fn insert_at_line(text: &str, at: usize, insertion: &str) -> String { - let line_start = text[..at].rfind('\n').map(|n| n + 1).unwrap_or(0); - let mut out = String::with_capacity(text.len() + insertion.len()); - out.push_str(&text[..line_start]); - out.push_str(insertion); - out.push_str(&text[line_start..]); - out +/// `text` through [`crate::formats::nuget::parse_config`], or the refusal +/// when it has no single live layout to wire into. +fn parse_wirable_config(text: &str) -> Result { + crate::formats::nuget::parse_config(text) + .filter(|parsed| !parsed.repeated_sections) + .ok_or_else(|| { + "nuget.config has malformed XML or a repeated section; not wired".to_string() + }) } -/// Extract the `key` attribute of every `` element inside -/// ``. Deliberately minimal (no XML parser dependency): scans -/// the packageSources span for `` elements. These are -/// the "pre-existing sources" the catch-all maps `*` to. Callers pass the -/// comment-blanked text so a commented-out source never contributes a key. -fn parse_config_source_keys(text: &str) -> Vec { - let mut out = Vec::new(); - let Some(start) = text.find("` - // (valid, common) or a malformed config NuGet itself would reject. Scanning - // to EOF instead would harvest `` entries from unrelated - // sections (``, ``, …) as phantom catch-all - // sources — mapping `*` to a key NuGet has no source for hard-fails every - // restore. - let Some(end) = text[start..].find("").map(|e| start + e) else { +/// Insert `children` (newline-terminated lines) as the last children of +/// `section`, or expand a self-closing `section` in place (its attributes +/// kept) into an open/close pair holding them. +fn insert_children( + text: &str, + section: &crate::formats::nuget::ConfigSection, + name: &str, + children: &str, +) -> String { + if let Some(out) = insert_before_close(text, section, children) { return out; - }; - let span = &text[start..end]; - let mut rest = span; - while let Some(add_at) = rest.find("'. - let elem_end = after.find('>').unwrap_or(after.len()); - let elem = &after[..elem_end]; - if let Some(key) = attr_value(elem, "key") { - if !out.contains(&key) { - out.push(key); - } - } - rest = &after[elem_end..]; } + let head = text[section.open.start..section.open.end - 2].trim_end(); + let mut out = String::with_capacity(text.len() + children.len() + 2 * name.len() + 8); + out.push_str(&text[..section.open.start]); + out.push_str(&format!("{head}>\n{children} ")); + out.push_str(&text[section.open.end..]); out } -/// The value of `="..."` inside an element's attribute text, if present. -/// Tolerates whitespace around `=` (`key = "nuget.org"` is valid XML NuGet -/// parses): a real source the scan misses would read as "no sources", -/// triggering a duplicate nuget.org seed and leaving the missed source out of -/// the catch-all fan-out. -fn attr_value(elem: &str, attr: &str) -> Option { - let mut rest = elem; - loop { - let at = rest.find(attr)?; - let after = rest[at + attr.len()..].trim_start(); - if let Some(eq) = after.strip_prefix('=') { - // NuGet accepts either XML quote style; tolerate both, like the - // redirect twin (patch/redirect/mod.rs nuget key harvesting). - let val = eq.trim_start(); - for quote in ['"', '\''] { - if let Some(quoted) = val.strip_prefix(quote) { - let close = quoted.find(quote)?; - return Some(quoted[..close].to_string()); - } - } - } - rest = &rest[at + attr.len()..]; - } -} - -/// The `[start, end)` byte span of a self-closing `` element -/// (any whitespace before `/>`), or `None` if the config has no such element. -/// Deliberately minimal (no XML parser dependency), matching the rest of this -/// module's scanning style. -fn self_closing_package_sources(text: &str) -> Option<(usize, usize)> { - let start = text.find("` for a - // self-closing element — anything else (`>` or an attribute) is a normal - // open tag, which the caller handles separately. - let after_name = &text[start + "`, so a - // `` open tag or `")?; - let end = text.len() - rest.len(); - Some((start, end)) +/// Insert `insertion` (newline-terminated lines) at the start of the line +/// holding `section`'s close tag, so it lands indented like its siblings, or +/// right before the close tag when other markup shares its line (a section +/// opened and closed on one line still receives it inside). `None` for a +/// self-closing section. +fn insert_before_close( + text: &str, + section: &crate::formats::nuget::ConfigSection, + insertion: &str, +) -> Option { + let close = section.close_start?; + let line_start = text[..close].rfind('\n').map(|n| n + 1).unwrap_or(0); + let at = if text[line_start..close].trim().is_empty() { + line_start + } else { + close + }; + let mut out = String::with_capacity(text.len() + insertion.len()); + out.push_str(&text[..at]); + out.push_str(insertion); + out.push_str(&text[at..]); + Some(out) } /// Revert our `nuget.config` wiring. `Ok(true)` = reverted (or would be on dry @@ -1547,12 +1511,30 @@ mod tests { assert_eq!(t.matches("").count(), 1); } + /// A section opened and closed on one line receives the insert inside + /// it: the line-start anchor never reaches back before the open tag. #[test] - fn parse_config_source_keys_reads_adds() { + fn one_line_sections_receive_their_children_inside() { let text = "\ \ "; - assert_eq!(parse_config_source_keys(text), vec!["a", "b"]); + let edit = build_config_edit( + Some(text), + &source_key(), + &format!(".socket/vendor/nuget/{UUID}"), + "Newtonsoft.Json", + ) + .unwrap(); + let parsed = crate::formats::nuget::parse_config(&edit.new_text).unwrap(); + let keys: Vec<&str> = parsed.sources.iter().map(|(k, _)| k.as_str()).collect(); + assert_eq!(keys, ["a", "b", source_key().as_str()], "{}", edit.new_text); + let mapped: Vec<&str> = parsed.mappings.iter().map(|(k, _)| k.as_str()).collect(); + assert_eq!( + mapped, + ["a", "b", source_key().as_str()], + "{}", + edit.new_text + ); } #[test] @@ -1570,9 +1552,11 @@ mod tests { \x20 \n\ \x20 \n\ \n"; - assert_eq!( - parse_config_source_keys(orig), - Vec::::new(), + assert!( + crate::formats::nuget::parse_config(orig) + .unwrap() + .sources + .is_empty(), "a self-closing packageSources carries no source keys" ); let edit = build_config_edit( @@ -2107,7 +2091,11 @@ mod tests { .await .unwrap(); assert!( - parse_config_source_keys(&blank_comments(&rewired)).contains(&source_key()), + crate::formats::nuget::parse_config(&rewired) + .unwrap() + .sources + .iter() + .any(|(key, _)| *key == source_key()), "the re-run wires a live source: {rewired}" ); } @@ -3460,7 +3448,7 @@ mod tests { .error .as_deref() .unwrap_or("") - .contains("no "), + .contains("malformed XML"), "{:?}", result.error ); @@ -3515,10 +3503,10 @@ mod tests { assert!(t.trim_end().ends_with("")); } - /// A config whose only anchor is `` (step 1 lands) but - /// with no `` fails creating the mapping section. + /// A `` outside a `` root is not a section + /// NuGet reads, so it is no anchor: the edit fails on the missing root. #[test] - fn config_without_configuration_close_errs_on_mapping_section() { + fn config_without_configuration_root_errs() { let orig = "\n\n"; let err = build_config_edit( Some(orig), @@ -3527,13 +3515,13 @@ mod tests { "Newtonsoft.Json", ) .err() - .expect("a config without must fail the mapping insert"); - assert!(err.contains("packageSourceMapping section"), "{err}"); + .expect("a config without must fail the edit"); + assert!(err.contains("no to edit"), "{err}"); } - /// No usable anchor at all: fail-closed with the `` error. + /// An unclosed root is malformed XML: fail-closed before any splice. #[test] - fn config_without_any_anchor_errs() { + fn config_with_unclosed_root_errs() { let err = build_config_edit( Some(""), &source_key(), @@ -3542,7 +3530,7 @@ mod tests { ) .err() .expect("an anchorless config must fail the edit"); - assert!(err.contains("no to edit"), "{err}"); + assert!(err.contains("malformed XML"), "{err}"); } // ── marker write failure is a warning, not a failure ─────────────────── @@ -4236,32 +4224,180 @@ mod tests { ); } - // ── comment blanking + key scan edges ────────────────────────────────── + // ── shared-reader edges (formats::nuget::parse_config) ───────────────── + + fn wire(text: &str) -> Result { + build_config_edit( + Some(text), + &source_key(), + &format!(".socket/vendor/nuget/{UUID}"), + "Newtonsoft.Json", + ) + } + /// An unterminated comment, a mismatched close tag or a repeated section + /// has no single live anchor: the writer refuses instead of splicing into + /// whichever copy a substring search finds first. #[test] - fn blank_comments_unterminated_blanks_through_eof() { - let input = "keep \n\ + \x20 \n \n\n", + // commented before the real one + "\n \n \n\ + \x20 \n \n\n", + // commented + "\n \n \n\ + \x20 \n \n\ + \n", + // self-closing sections + "\n \n \n\n", + // single-quoted and spaced attributes, CRLF + "\r\n \r\n \r\n\ + \x20 \r\n\r\n", + // in a lookalike section outside packageSources + "\n \n \n\ + \x20 \n\n", + ]; + for text in cases { + let before = crate::formats::nuget::parse_config(text).unwrap(); + let t = wire(text) + .unwrap_or_else(|e| panic!("{e}: {text:?}")) + .new_text; + let after = crate::formats::nuget::parse_config(&t) + .unwrap_or_else(|| panic!("unparseable output: {t:?}")); + assert!(!after.repeated_sections, "{t}"); + let mut expected: Vec = before.sources.iter().map(|(k, _)| k.clone()).collect(); + if expected.is_empty() + && before + .source_mapping + .is_none_or(|m| m.close_start.is_none()) + { + expected.push(NUGET_ORG_SOURCE_KEY.to_string()); + } + let wired = after + .sources + .iter() + .map(|(k, _)| k.clone()) + .filter(|k| *k != source_key()) + .collect::>(); + let mut wired_sorted = wired.clone(); + wired_sorted.sort(); + let mut expected_sorted = expected.clone(); + expected_sorted.sort(); + assert_eq!(wired_sorted, expected_sorted, "{t}"); + assert!(after.sources.iter().any(|(k, _)| *k == source_key()), "{t}"); + let catch_all: Vec<&str> = after + .mappings + .iter() + .filter(|(_, p)| p == &["*"]) + .map(|(k, _)| k.as_str()) + .collect(); + assert_eq!(catch_all.len(), expected.len(), "{t}"); + assert!( + after + .mappings + .iter() + .any(|(k, p)| *k == source_key() && p == &["Newtonsoft.Json"]), + "{t}" + ); + } } // ── permission-failure unwinds (unix) ────────────────────────────────── @@ -4450,29 +4586,6 @@ mod tests { // ── remaining prod arms ──────────────────────────────────────────────── - /// `attr_value` scanning edges: a substring hit on the attribute NAME - /// (`keyring`) and a malformed unquoted value both advance the scan to the - /// next occurrence instead of aborting the harvest, and a config with no - /// properly quoted attribute at all terminates with `None`. - #[test] - fn attr_value_skips_name_lookalikes_and_unquoted_values() { - // "keyring" contains "key" but is not the attribute: the real quoted - // `key` later in the element must still be harvested. - assert_eq!( - attr_value(" keyring=\"x\" key=\"real\" /", "key").as_deref(), - Some("real") - ); - // An unquoted value (malformed XML NuGet would reject anyway) is not - // harvested; the scan moves on to the next, properly quoted match. - assert_eq!( - attr_value("key=bare key='q2'", "key").as_deref(), - Some("q2") - ); - // Lookalikes only ("keyring", "monkeys") and no quoted value → None, - // not an infinite loop. - assert_eq!(attr_value("keyring monkeys", "key"), None); - } - /// Tier A (service prebuilt) write failure: the served bytes cannot land /// because a regular FILE squats the uuid dir path → the hard /// `vendor_prebuilt_write_failed` refusal, before any wiring. On this From f72fc260303be6b98f0177ae864089dbebb9c17d Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 12:45:38 -0400 Subject: [PATCH 03/10] Start NuGet fix: nuget-lock-reader Draft placeholder while the fix is written. Co-Authored-By: Claude Opus 5.5 (1M context) From 84ef4db168d0814eb42798c190f5882eb9529fc8 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 12:45:49 -0400 Subject: [PATCH 04/10] Start NuGet fix: nuget-member-locks Draft placeholder while the fix is written. Co-Authored-By: Claude Opus 5.5 (1M context) From cbd7bcb7d1c37ea751a11bd822debdae6e23bd3c Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 13:15:10 -0400 Subject: [PATCH 05/10] Pin only the patched NuGet version; read BOM locks Hosted NuGet rewrote every packages.lock.json entry of the patched id, whatever version it resolved, so a multi-targeting project silently got the patched version's bytes in another framework (#593). Vendored only pinned the matching version but still routed every version of the id to a feed serving one, so the other framework failed NU1102. Both modes now refuse such a lock (redirect_ / vendor_nuget_lock_other_version) before writing anything, and only entries at the patched version are re-pinned. A lock starting with a UTF-8 BOM, which dotnet restores fine, made hosted skip the redirect with exit 0 and vendored fail apply (#623). The lock is now read past the BOM, and the BOM and layout are kept on write. All four lock walkers (vendored pin, hosted redirect, upstream restore, VEX) now share one reader in formats::nuget::lock. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/CLI_CONTRACT.md | 4 +- .../src/formats/nuget/lock.rs | 179 +++++++++++++++ .../src/formats/nuget/mod.rs | 2 + .../src/patch/redirect/mod.rs | 215 +++++++++++++----- .../src/patch/redirect/upstream/nuget.rs | 43 ++-- .../src/vendor/nuget_feed.rs | 133 +++++++---- .../src/vex/discover/nuget.rs | 3 +- 7 files changed, 457 insertions(+), 122 deletions(-) create mode 100644 crates/socket-patch-core/src/formats/nuget/lock.rs diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index bb4592f4b..dd88c2fd2 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -171,7 +171,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Vendored entries and the rest of the CLI.** Because nothing is in the manifest, vendored patches are invisible to `apply` (nothing to apply in place) but fully visible to `list` (listed from the ledger, labeled `Mode: vendored (recorded in .socket/vendor/state.json)` in human mode, exit 0 on a vendored-only project), `vex` (attested from the embedded records while a lockfile still wires the artifact — see "Manifest-less VEX"), `repair` (health-checked and rebuilt from the ledger), and `scan --prune` (lockfile-driven reconcile). They are exempt from standalone `vendor`'s manifest reconcile (`reconcile_dropped` never touches `detached` entries) and exit via `remove ` (which reverts them), `vendor --revert`, or `rollback`, whose vendored leg reverts every in-scope ledger entry (unscoped and identifier-scoped runs; path-scoped runs reach them only when an installed copy matches). -`scan --mode hosted` swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither counted nor attested. **No ledger (v5.0)**: hosted mode writes ONLY the lockfile / registry-config edits — `.socket/vendor/redirect-state.json` is never written (on success or failure), and a pre-v5 one on disk is ignored (never read for planning, never quarantined, left byte-identical). The lockfiles are the only record of a hosted patch: `list`, `vex`, `rollback`, `remove`, `vendor` and `repair` all discover the hosted pins from them (a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin), and commit-ready output is just the lockfile / config changes. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. Gradle is confirmed the same way (`confirmed_gradle_uuids`): only when the final files hold the owned script, the index row, the live apply line in every build's settings file and the suffixed version in every lock entry of the GA (see [Gradle builds](#gradle-builds-v50)). A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). A vendored PyPI package (requirements.txt, Poetry, Pipenv, uv, Hatch, PDM, pylock) is taken over the same way: its vendored wiring is restored to the recorded registry entry, its ledger entry and wheel are removed, and it is redirected in the same commit. The Python rewriters treat any non-registry source as user-authored, so without the revert they refused socket-patch's own vendored source and left the project vendored. A takeover revert that leaves vendored wiring in place is refused with `redirect_vendored_revert_failed`. That covers a drift-skipped record (`vendor_lock_entry_drifted`) and a reverted file that still references the artifact (`vendor_revert_residual_reference`). The ledger entry and artifact are kept, and the package stays vendored and skipped. `--dry-run` predicts the same refusal from the same signals instead of previewing `redirect_would_revert_vendored`. The hosted requirements.txt rewriter only rewrites an existing pin in the root `requirements.txt`, so a vendored requirements.txt package whose wiring is a pin in a `-r` include or a `(transitive)` line vendored mode appended is kept vendored, wet and `--dry-run` alike, with `redirect_requirements_takeover_unreachable` (`redirect.warnings[]`, and `redirect.skipped[].reason`). Its wiring, ledger entry and wheel are kept, so it stays vendored and patched (exit 0). The uv and Poetry rewriters are covered the same way: a vendored uv package whose recorded pre-vendor `uv.lock` entry is at another version than the patch (vendored uv pins the entry down to the patch's version; the revert brings the lock's own version back, and hosted mode only pins the version the lock resolves) is kept vendored with `redirect_uv_takeover_version_unreachable`, and a vendored Poetry package on a Poetry 0.x lock (which hosted mode refuses outright) is kept vendored with `redirect_poetry_lock_unsupported`. **Staged takeover (v5.0)**: the takeover is atomic. Each purl's vendored revert is staged in memory (the run's group commit, the same journaled commit vendored mode uses), the hosted rewrite plans against the reverted project, and a staged purl the rewrite does not pin is retracted: every staged revert is undone, that purl keeps its vendored wiring, ledger entry and artifact byte-identical, and the rest are staged and rewritten again. A retracted purl is skipped (`redirect.skipped[].reason`) with the cause: its existing skip reason (unavailable wheel metadata, …), the rewriter warning that names the package (`redirect_yarn_berry_missing_checksum`, `redirect_pypi_platform_wheel`, …), `redirect_requirements_takeover_unreachable`, the rewrite's lock-level refusal (`redirect_yarn_berry_mixed_line_endings`, `redirect_bun_lock_unsupported`, a Gradle planner refusal, …) or `redirect_takeover_not_pinned`; that warning is reported in `redirect.warnings[]`, followed by `redirect_takeover_kept_vendored` naming the package. Exit 0: the package stays vendored and patched, never unpatched in both modes. The reverts, the hosted pins and the vendored ledger then reach the disk in one commit, and the reverted artifacts are deleted only after it: a refusal or write failure before the commit writes nothing, a failed commit puts back the files it replaced, and a commit interrupted after its journal was written is finished by the next command that takes the apply lock. `--dry-run` runs the same steps and drops the staged state instead of committing, so its `redirected` count and warnings are the wet run's. A hosted run with no takeover stages its writes the same way but commits them without the journal (it writes nothing under `.socket/`): a file that fails to be replaced puts back the ones already replaced, so a failed run leaves no lock half-redirected. Re-runs over already-rewritten output plan from the current lock text and are idempotent (exit 0, lock unchanged). **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` before its first wet write (the staged takeover reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/`; contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE any project file is written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON keeps the hosted shape — top-level `status: "error"`, `error: {code: "lock_held" | "lock_io", message}` (v5.0: the same object every command uses; no top-level `error.code`), and `redirect: {mode: "hosted"}` retained. **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: hosted `scan` prints the results table and update detection like the other modes, then rewrites without a prompt (scan never prompts); `--dry-run` previews through the engine, and a detail fetch that leaves nothing to redirect enters the engine as a no-op (`Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine; a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, patches, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). `patches` (additive, v5.0) is the per-purl outcome of every selected patch, sorted by purl: `{purl, uuid, action}` with `action` `pinned` (`would_pin` under `--dry-run`; `redirected` counts these), `skipped` (`errorCode` = the `skipped[]` reason, `error` = its detail when it has one), or `unpinned` (`errorCode: redirect_unconfirmed` — the patch was granted but no lockfile entry pinning it could be rewritten; the human output's `Not hosted : …` line). An `unpinned` or `skipped` row does not change `status` or the exit code (the hosted exit policy is an open decision, #704). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_gem_bundle_gemfile_unsupported` (gem: `BUNDLE_GEMFILE` — `BUNDLE_GEMFILE:` in the bundler app config, which outranks the environment variable as in `Bundler::Settings`, else the environment variable — names a manifest other than the project's `Gemfile` / `gems.rb`, so no gem is redirected or attested; a value naming one of those two selects that pair even when the other spelling is present), `redirect_gem_bundle_lockfile_unsupported` (gem: bundler 4's custom lockfile — the `BUNDLE_LOCKFILE` environment variable, else `BUNDLE_LOCKFILE:` in the bundler app config, else in the global config — names a lock other than the loaded pair's own `Gemfile.lock` / `gems.locked`, so no gem is redirected or attested rather than pinning a lock bundler ignores), `redirect_gem_twin_manifest_ambiguous` (gem: a `Gemfile` + `gems.rb` twin under default discovery; bundler 1.x loads the `Gemfile` and bundler ≥ 2 loads `gems.rb`, and a lock's `BUNDLED WITH` records which bundler wrote it, not which one installs it, so neither pair is wired or attested — remove the unused spelling or set `BUNDLE_GEMFILE` to the one in use), `redirect_gem_mirror_overrides_source` (gem: Bundler's all-source, exact patch-source or patch-hostname mirror can route the per-dep `source` block to an unpatched upstream gem. Intake reads the app config (`BUNDLE_APP_CONFIG`, where a set-but-empty value selects `/config`, honoring `BUNDLE_IGNORE_CONFIG`) and all `BUNDLE_MIRROR__...` variables visible to the scan; app config overrides the environment per encoded key, then `mirror.all` takes precedence over exact source, which takes precedence over hostname. URI matching follows Bundler's whole-URI case folding, default-port/trailing-slash normalization and single slash key alias, not URL prefixes. An exact-source fallback-timeout key without a mirror URL shadows the hostname mirror and fetches that source directly; a configured URL is conservatively refused even if a timeout could bypass an unreachable mirror at install time. Like `redirect_gem_bundle_gemfile_unsupported`, the gate leaves the Gemfile pair byte-identical and confirms no gem redirect. On an embedded `scan --vex`, rediscovered older hosted gem pins may attest only from verified installed bytes: a missing tree is not excused by the lockfile, and `--vex-no-verify` omits those hosted gems with `mirror_overrides_source` rather than trusting their intercepted source. Agent/vendored evidence, unrelated ecosystems and standalone VEX behavior are unchanged. Details identify the setting form and its app/environment origin without printing mirror values or source URLs, which may contain credentials. Remove the applicable all/source/hostname setting (including any slash alias) from that origin and reuse its existing mirror URL under `mirror.https://rubygems.org` to clear the refusal; an environment setting must be unset in the scan/install environment. User-global Bundler config and mirrors set only in a later install environment are not inspected; keep those mirrors scoped to the upstream source too), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also additive: `redirect_gem_version_not_locked` (gem: no `GEM` section of the lock lists the crawled `name (version)`, for example a version another project installed into the shared gem home; the gem is skipped with nothing written, so the user's declared constraint and the locked version are never overwritten). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang. Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips (and `rollback`'s upstream restore keeps the lock's own ending). A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). The root `package.json`, which the rewrite re-renders to add `resolutions`, gets the same gate: a mixed one is refused untouched with the same code — the decision vendored mode takes with `vendor_yarn_berry_mixed_line_endings`, from the same shared berry gate set. This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover of a yarn-berry entry is checked against these project gates (mixed `yarn.lock` / `package.json` line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) on the project as it is before any revert, because the revert re-renders `package.json` in its majority ending — wet and `--dry-run` alike. A refused purl keeps its vendored wiring, ledger entry and artifact byte-identical, is skipped with the gate's code (reported in `redirect.warnings[]`, followed by `redirect_takeover_kept_vendored`) and is never announced as `redirect_takeover_reverted_vendored`. +`scan --mode hosted` swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither counted nor attested. **No ledger (v5.0)**: hosted mode writes ONLY the lockfile / registry-config edits — `.socket/vendor/redirect-state.json` is never written (on success or failure), and a pre-v5 one on disk is ignored (never read for planning, never quarantined, left byte-identical). The lockfiles are the only record of a hosted patch: `list`, `vex`, `rollback`, `remove`, `vendor` and `repair` all discover the hosted pins from them (a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin), and commit-ready output is just the lockfile / config changes. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. Gradle is confirmed the same way (`confirmed_gradle_uuids`): only when the final files hold the owned script, the index row, the live apply line in every build's settings file and the suffixed version in every lock entry of the GA (see [Gradle builds](#gradle-builds-v50)). A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). A vendored PyPI package (requirements.txt, Poetry, Pipenv, uv, Hatch, PDM, pylock) is taken over the same way: its vendored wiring is restored to the recorded registry entry, its ledger entry and wheel are removed, and it is redirected in the same commit. The Python rewriters treat any non-registry source as user-authored, so without the revert they refused socket-patch's own vendored source and left the project vendored. A takeover revert that leaves vendored wiring in place is refused with `redirect_vendored_revert_failed`. That covers a drift-skipped record (`vendor_lock_entry_drifted`) and a reverted file that still references the artifact (`vendor_revert_residual_reference`). The ledger entry and artifact are kept, and the package stays vendored and skipped. `--dry-run` predicts the same refusal from the same signals instead of previewing `redirect_would_revert_vendored`. The hosted requirements.txt rewriter only rewrites an existing pin in the root `requirements.txt`, so a vendored requirements.txt package whose wiring is a pin in a `-r` include or a `(transitive)` line vendored mode appended is kept vendored, wet and `--dry-run` alike, with `redirect_requirements_takeover_unreachable` (`redirect.warnings[]`, and `redirect.skipped[].reason`). Its wiring, ledger entry and wheel are kept, so it stays vendored and patched (exit 0). The uv and Poetry rewriters are covered the same way: a vendored uv package whose recorded pre-vendor `uv.lock` entry is at another version than the patch (vendored uv pins the entry down to the patch's version; the revert brings the lock's own version back, and hosted mode only pins the version the lock resolves) is kept vendored with `redirect_uv_takeover_version_unreachable`, and a vendored Poetry package on a Poetry 0.x lock (which hosted mode refuses outright) is kept vendored with `redirect_poetry_lock_unsupported`. **Staged takeover (v5.0)**: the takeover is atomic. Each purl's vendored revert is staged in memory (the run's group commit, the same journaled commit vendored mode uses), the hosted rewrite plans against the reverted project, and a staged purl the rewrite does not pin is retracted: every staged revert is undone, that purl keeps its vendored wiring, ledger entry and artifact byte-identical, and the rest are staged and rewritten again. A retracted purl is skipped (`redirect.skipped[].reason`) with the cause: its existing skip reason (unavailable wheel metadata, …), the rewriter warning that names the package (`redirect_yarn_berry_missing_checksum`, `redirect_pypi_platform_wheel`, …), `redirect_requirements_takeover_unreachable`, the rewrite's lock-level refusal (`redirect_yarn_berry_mixed_line_endings`, `redirect_bun_lock_unsupported`, a Gradle planner refusal, …) or `redirect_takeover_not_pinned`; that warning is reported in `redirect.warnings[]`, followed by `redirect_takeover_kept_vendored` naming the package. Exit 0: the package stays vendored and patched, never unpatched in both modes. The reverts, the hosted pins and the vendored ledger then reach the disk in one commit, and the reverted artifacts are deleted only after it: a refusal or write failure before the commit writes nothing, a failed commit puts back the files it replaced, and a commit interrupted after its journal was written is finished by the next command that takes the apply lock. `--dry-run` runs the same steps and drops the staged state instead of committing, so its `redirected` count and warnings are the wet run's. A hosted run with no takeover stages its writes the same way but commits them without the journal (it writes nothing under `.socket/`): a file that fails to be replaced puts back the ones already replaced, so a failed run leaves no lock half-redirected. Re-runs over already-rewritten output plan from the current lock text and are idempotent (exit 0, lock unchanged). **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` before its first wet write (the staged takeover reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/`; contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE any project file is written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON keeps the hosted shape — top-level `status: "error"`, `error: {code: "lock_held" | "lock_io", message}` (v5.0: the same object every command uses; no top-level `error.code`), and `redirect: {mode: "hosted"}` retained. **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: hosted `scan` prints the results table and update detection like the other modes, then rewrites without a prompt (scan never prompts); `--dry-run` previews through the engine, and a detail fetch that leaves nothing to redirect enters the engine as a no-op (`Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine; a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, patches, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). `patches` (additive, v5.0) is the per-purl outcome of every selected patch, sorted by purl: `{purl, uuid, action}` with `action` `pinned` (`would_pin` under `--dry-run`; `redirected` counts these), `skipped` (`errorCode` = the `skipped[]` reason, `error` = its detail when it has one), or `unpinned` (`errorCode: redirect_unconfirmed` — the patch was granted but no lockfile entry pinning it could be rewritten; the human output's `Not hosted : …` line). An `unpinned` or `skipped` row does not change `status` or the exit code (the hosted exit policy is an open decision, #704). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_gem_bundle_gemfile_unsupported` (gem: `BUNDLE_GEMFILE` — `BUNDLE_GEMFILE:` in the bundler app config, which outranks the environment variable as in `Bundler::Settings`, else the environment variable — names a manifest other than the project's `Gemfile` / `gems.rb`, so no gem is redirected or attested; a value naming one of those two selects that pair even when the other spelling is present), `redirect_gem_bundle_lockfile_unsupported` (gem: bundler 4's custom lockfile — the `BUNDLE_LOCKFILE` environment variable, else `BUNDLE_LOCKFILE:` in the bundler app config, else in the global config — names a lock other than the loaded pair's own `Gemfile.lock` / `gems.locked`, so no gem is redirected or attested rather than pinning a lock bundler ignores), `redirect_gem_twin_manifest_ambiguous` (gem: a `Gemfile` + `gems.rb` twin under default discovery; bundler 1.x loads the `Gemfile` and bundler ≥ 2 loads `gems.rb`, and a lock's `BUNDLED WITH` records which bundler wrote it, not which one installs it, so neither pair is wired or attested — remove the unused spelling or set `BUNDLE_GEMFILE` to the one in use), `redirect_gem_mirror_overrides_source` (gem: Bundler's all-source, exact patch-source or patch-hostname mirror can route the per-dep `source` block to an unpatched upstream gem. Intake reads the app config (`BUNDLE_APP_CONFIG`, where a set-but-empty value selects `/config`, honoring `BUNDLE_IGNORE_CONFIG`) and all `BUNDLE_MIRROR__...` variables visible to the scan; app config overrides the environment per encoded key, then `mirror.all` takes precedence over exact source, which takes precedence over hostname. URI matching follows Bundler's whole-URI case folding, default-port/trailing-slash normalization and single slash key alias, not URL prefixes. An exact-source fallback-timeout key without a mirror URL shadows the hostname mirror and fetches that source directly; a configured URL is conservatively refused even if a timeout could bypass an unreachable mirror at install time. Like `redirect_gem_bundle_gemfile_unsupported`, the gate leaves the Gemfile pair byte-identical and confirms no gem redirect. On an embedded `scan --vex`, rediscovered older hosted gem pins may attest only from verified installed bytes: a missing tree is not excused by the lockfile, and `--vex-no-verify` omits those hosted gems with `mirror_overrides_source` rather than trusting their intercepted source. Agent/vendored evidence, unrelated ecosystems and standalone VEX behavior are unchanged. Details identify the setting form and its app/environment origin without printing mirror values or source URLs, which may contain credentials. Remove the applicable all/source/hostname setting (including any slash alias) from that origin and reuse its existing mirror URL under `mirror.https://rubygems.org` to clear the refusal; an environment setting must be unset in the scan/install environment. User-global Bundler config and mirrors set only in a later install environment are not inspected; keep those mirrors scoped to the upstream source too), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds; a leading UTF-8 BOM, which dotnet reads past, is not corrupt and is kept on rewrite), `redirect_nuget_lock_other_version` (the lock also resolves the patched id at another version in some target framework: the exact-id `packageSourceMapping` would route that framework to a feed that serves only the patched version, so the dep is skipped with nothing written; only lock entries at the patched version are ever re-pinned, and `resolved` is never rewritten), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also additive: `redirect_gem_version_not_locked` (gem: no `GEM` section of the lock lists the crawled `name (version)`, for example a version another project installed into the shared gem home; the gem is skipped with nothing written, so the user's declared constraint and the locked version are never overwritten). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang. Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips (and `rollback`'s upstream restore keeps the lock's own ending). A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). The root `package.json`, which the rewrite re-renders to add `resolutions`, gets the same gate: a mixed one is refused untouched with the same code — the decision vendored mode takes with `vendor_yarn_berry_mixed_line_endings`, from the same shared berry gate set. This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover of a yarn-berry entry is checked against these project gates (mixed `yarn.lock` / `package.json` line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) on the project as it is before any revert, because the revert re-renders `package.json` in its majority ending — wet and `--dry-run` alike. A refused purl keeps its vendored wiring, ledger entry and artifact byte-identical, is skipped with the gate's code (reported in `redirect.warnings[]`, followed by `redirect_takeover_kept_vendored`) and is never announced as `redirect_takeover_reverted_vendored`. **Attribution gate (v5.0).** A hosted run never leaves wiring that lockfile discovery calls contested: before the rewrite writes any lockfile, the same discovery `vex`, `list`, `rollback`, `remove` and `vendor` read runs over the project as the rewrite would leave it. A candidate whose pin discovery reads but cannot attribute to one package version (a requirements `-r` include resolving the same version from the registry beside a rewired `Pipfile.lock`, #567; a Maven pin in a ``, #260) is left out of the rewrite and reported in `redirect.skipped[]` as `redirect_unattributable` (nothing written for it; exit code unchanged). Unchanged: a pin in a file discovery does not read (a pre-2.6 bundler `Gemfile`, locked by the next `bundle install`) keeps the rewriter's verdict, and so does a deliberate partial redirect the run already reports (a bundled or `bun patch`-ed copy left on the registry, a yarn `npm:` alias entry left on the registry with `redirect_yarn_classic_alias_skipped` / `redirect_yarn_berry_alias_skipped` while the package's direct entry is pinned, a dep withheld from the vlt rewrite while a sibling lock takes it), and so does a vendored→hosted takeover: its vendored wiring is reverted in the run's staged overlay before the rewrite plans, and it is redirected when the rewriters pin it (otherwise it is retracted and stays vendored), in a `--dry-run` preview the same. A lockless NuGet / Cargo pin (an exclusive Socket source mapping without `packages.lock.json`, a Cargo registry pin without `Cargo.lock`) is still written, with a `redirect_pin_lockless` warning: no lockfile records its version, so `vex` cannot attest it and `rollback` / `remove` / `vendor` refuse it until the lockfile exists (whether such pins should be written at all is an open decision). The rollout's recorded view uses the same discovery: a uuid a file merely mentions (a stale `package.json` field, an inactive `pdm.lock`, a comment) is not a pin and does not count as already patched. @@ -754,7 +754,7 @@ to **six flavors**. | pypi / pdm (pdm.lock) | (rebuilt wheel) | lock-only: the `[[package]]` gains the local-file `path` + `files[]` hash. pyproject + `content_hash` untouched. Non-fixture `[metadata] strategy` / hash-less locks refused | `pdm sync` (+ `pdm install --check`), cold cache | | pypi / pipenv (Pipfile.lock) | (rebuilt wheel) | lock-only: the `default`/`develop` entry → `{file, hashes:[sha256-of-our-wheel]}`. Pipfile + `_meta.hash` untouched. Emits `vendor_integrity_unverified` — pipenv does not hash-check file entries; the committed wheel bytes are the protection | `pipenv install --deploy` (+ `pipenv verify`), cold cache | | pypi / requirements.txt (pip / `uv pip`) | (rebuilt wheel) | pin line → `./` (markers carried over; transitive deps appended), plus `--hash=sha256:` only when the requirements tree is already in pip's hash-checking mode (any `--hash` or `--require-hashes`) | `pip install -r` / `uv pip install -r` **run from the project root** (both resolve bare paths against the CWD) | -| nuget | deterministically rebuilt `.nupkg` at `..nupkg` (the uuid dir IS a NuGet folder feed; the stale embedded signature is dropped — unsigned is accepted under NuGet's default validation) | `nuget.config` source + `packageSourceMapping` for the id (creating the mapping from scratch ALSO fans a `` out to every pre-existing source — mapping is exclusive, NU1100 otherwise) **+** `packages.lock.json` `contentHash` → `base64(sha512(nupkg))` when the lock exists (`vendor_nuget_no_lockfile` warning otherwise) | `dotnet restore --locked-mode`, cold cache, `--network none` (tampered nupkg fails NU1403) | +| nuget | deterministically rebuilt `.nupkg` at `..nupkg` (the uuid dir IS a NuGet folder feed; the stale embedded signature is dropped — unsigned is accepted under NuGet's default validation) | `nuget.config` source + `packageSourceMapping` for the id (creating the mapping from scratch ALSO fans a `` out to every pre-existing source — mapping is exclusive, NU1100 otherwise) **+** `packages.lock.json` `contentHash` → `base64(sha512(nupkg))` for the entries at the patched version when the lock exists (`vendor_nuget_no_lockfile` warning otherwise; a lock that also resolves the id at another version is refused with `vendor_nuget_lock_other_version`, nothing written) | `dotnet restore --locked-mode`, cold cache, `--network none` (tampered nupkg fails NU1403) | | maven | the patched `.jar` + the upstream pom (only its `` suffixed; transitives survive) + `.sha1` sidecars + an ownership marker under `.socket/vendor/maven2///-socket./` | every pom root, single-module (a reactor of one) or multi-module: the pinned `` + `` pin, `.mvn/maven.config` (`maven.repo.local.tail`) and the `socket-patch-vendor` fallback file repository (`checksumPolicy=fail`); Gradle, sbt and scala-cli roots go to the same JVM backend (ledger ecosystem `jvm`). Pre-v5 `maven_pom_repository` entries (`` to `.socket/vendor/maven/`) are revert-only: vendoring their root is refused (`vendor_jvm_shape_unsupported`, `legacy_maven_root`) | `mvn` build on a fresh checkout with a warm local repository and behind `mirrorOf external:*` (host capstone `e2e_vendor_maven_build` across the Maven matrix) | Ecosystems with no vendor backend (jsr) refuse per-purl with diff --git a/crates/socket-patch-core/src/formats/nuget/lock.rs b/crates/socket-patch-core/src/formats/nuget/lock.rs new file mode 100644 index 000000000..7ce813272 --- /dev/null +++ b/crates/socket-patch-core/src/formats/nuget/lock.rs @@ -0,0 +1,179 @@ +//! `packages.lock.json`: the one reader every NuGet lock walker shares — +//! the vendored pin, the hosted redirect, the hosted upstream restore and +//! VEX discovery. +//! +//! A lock is `{"dependencies": {"": {"": {"resolved": …, +//! "contentHash": …}}}}`. NuGet matches ids case-insensitively, and one +//! multi-targeting project can resolve the same id at a different version +//! per framework, so a walker that pins a patched ` ` must +//! match the id AND the version: an entry at another version is a +//! different package that the patched bytes must never replace (#593). +//! +//! dotnet restores a lock that starts with a UTF-8 BOM (one saved by a +//! Windows editor or Windows PowerShell 5.1), so the parse reads past it +//! (#623). Writers keep it: the vendored and upstream-restore edits are +//! string surgery on the hash value, and the hosted rewrite re-renders in +//! the original layout. + +use serde_json::{Map, Value}; + +use crate::vendor::nuget_feed::normalize_nuget_version; + +/// The lock NuGet writes beside a project under its default name. +pub(crate) const PACKAGES_LOCK: &str = "packages.lock.json"; + +/// One `dependencies..` entry that restores from a source: its raw +/// id key, `resolved` and `contentHash` strings. +#[derive(Debug, Clone, Copy)] +pub(crate) struct NugetLockEntry<'a> { + pub(crate) id: &'a str, + pub(crate) resolved: &'a str, + pub(crate) content_hash: Option<&'a str>, +} + +/// Parse a lock's text as dotnet does, reading past a leading UTF-8 BOM. +pub(crate) fn parse_lock(text: &str) -> serde_json::Result { + serde_json::from_str(crate::formats::text::strip_bom(text)) +} + +/// Every entry of a parsed lock, target framework by target framework, in +/// document-key order. Frameworks that are not objects and entries without +/// a string `resolved` (`type: "Project"` references, which nothing +/// restores from a source) are skipped; strings are raw (callers trim / +/// normalize / compare ids as they need). +pub(crate) fn nuget_lock_entries(doc: &Value) -> impl Iterator> { + doc.get("dependencies") + .and_then(Value::as_object) + .into_iter() + .flat_map(|frameworks| frameworks.values()) + .filter_map(Value::as_object) + .flatten() + .filter_map(|(id, entry)| { + Some(NugetLockEntry { + id, + resolved: entry.get("resolved").and_then(Value::as_str)?, + content_hash: entry.get("contentHash").and_then(Value::as_str), + }) + }) +} + +/// The entries of package `id` (case-insensitive) whose `resolved` +/// normalizes to `version_norm` ([`normalize_nuget_version`]): the entries +/// a patch of `id version_norm` replaces. +pub(crate) fn locked_at<'a>( + doc: &'a Value, + id: &'a str, + version_norm: &'a str, +) -> impl Iterator> { + nuget_lock_entries(doc).filter(move |e| { + e.id.eq_ignore_ascii_case(id) && normalize_nuget_version(e.resolved) == version_norm + }) +} + +/// [`locked_at`] for editing: `(id key, entry object)` of every matching +/// entry, framework by framework. +pub(crate) fn locked_at_mut<'a>( + doc: &'a mut Value, + id: &'a str, + version_norm: &'a str, +) -> Vec<(&'a str, &'a mut Map)> { + doc.get_mut("dependencies") + .and_then(Value::as_object_mut) + .into_iter() + .flat_map(|frameworks| frameworks.values_mut()) + .filter_map(Value::as_object_mut) + .flat_map(|fw| fw.iter_mut()) + .filter(|(key, _)| key.eq_ignore_ascii_case(id)) + .filter_map(|(key, entry)| Some((key.as_str(), entry.as_object_mut()?))) + .filter(|(_, entry)| { + entry + .get("resolved") + .and_then(Value::as_str) + .is_some_and(|r| normalize_nuget_version(r) == version_norm) + }) + .collect() +} + +/// The other versions the lock resolves `id` at, normalized, sorted and +/// deduplicated. Both writers route the WHOLE id to a feed that serves only +/// the patched version (`packageSourceMapping` patterns name ids, never +/// versions), so a framework that resolves the id at another version could +/// no longer restore it: the writers refuse such a lock rather than break +/// that framework or re-pin it to the patched version. +pub(crate) fn other_versions(doc: &Value, id: &str, version_norm: &str) -> Vec { + let mut out: Vec = nuget_lock_entries(doc) + .filter(|e| e.id.eq_ignore_ascii_case(id)) + .map(|e| normalize_nuget_version(e.resolved)) + .filter(|v| v != version_norm) + .collect(); + out.sort(); + out.dedup(); + out +} + +/// The refusal detail both writers give for [`other_versions`]. +pub(crate) fn other_versions_detail( + lock_rel: &str, + id: &str, + version_norm: &str, + others: &[String], +) -> String { + format!( + "{lock_rel} also resolves {id} at {}; the patch for {version_norm} would route every \ + version of {id} to a feed that serves only {version_norm}, so those target frameworks \ + could not restore. Align {id} on {version_norm} in every target framework (or patch \ + each version), restore, and re-run", + others.join(", ") + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + const MULTI: &str = r#"{ + "version": 1, + "dependencies": { + "net6.0": { + "Newtonsoft.Json": { "type": "Direct", "requested": "[12.0.3, )", "resolved": "12.0.3", "contentHash": "OLD12==" } + }, + "net8.0": { + "newtonsoft.json": { "type": "Direct", "requested": "[13.0.3, )", "resolved": "13.0.3", "contentHash": "OLD13==" }, + "App.Lib": { "type": "Project" } + } + } +}"#; + + #[test] + fn bom_lock_parses_like_dotnet_reads_it() { + let bom = format!("\u{feff}{MULTI}"); + assert!(serde_json::from_str::(&bom).is_err()); + assert_eq!(parse_lock(&bom).unwrap(), parse_lock(MULTI).unwrap()); + } + + #[test] + fn locked_at_matches_id_and_version() { + let doc = parse_lock(MULTI).unwrap(); + let hits: Vec<_> = locked_at(&doc, "Newtonsoft.Json", "13.0.3").collect(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].content_hash, Some("OLD13==")); + let mut doc = doc; + let hits = locked_at_mut(&mut doc, "NEWTONSOFT.JSON", "13.0.3"); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].0, "newtonsoft.json"); + } + + #[test] + fn other_versions_names_every_other_resolution() { + let doc = parse_lock(MULTI).unwrap(); + assert_eq!( + other_versions(&doc, "newtonsoft.json", "13.0.3"), + ["12.0.3"] + ); + assert!(other_versions(&doc, "newtonsoft.json", "12.0.3") == ["13.0.3"]); + assert!(other_versions(&doc, "App.Lib", "1.0.0").is_empty()); + // A spelling of the same version is not another version. + let doc = parse_lock(&MULTI.replace("\"12.0.3\"", "\"13.0.3.0\"")).unwrap(); + assert!(other_versions(&doc, "Newtonsoft.Json", "13.0.3").is_empty()); + } +} diff --git a/crates/socket-patch-core/src/formats/nuget/mod.rs b/crates/socket-patch-core/src/formats/nuget/mod.rs index 1499a27d2..f3617fb90 100644 --- a/crates/socket-patch-core/src/formats/nuget/mod.rs +++ b/crates/socket-patch-core/src/formats/nuget/mod.rs @@ -10,6 +10,8 @@ //! and DOCTYPEs are skipped; an unterminated tag or comment, an unquoted //! attribute or a mismatched close tag makes the whole file `None`. +pub(crate) mod lock; + use std::collections::BTreeSet; use std::ops::Range; diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 7f007a03f..e6aa5f55a 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -477,6 +477,7 @@ pub(crate) fn full_name(dep: &DepOverride) -> String { /// Canonical JSON serialization matching TS `JSON.stringify(v, null, 2) + '\n'` /// (2-space pretty via serde_json, key order preserved by `preserve_order`, /// `/` unescaped). +#[cfg(test)] fn serialize_json(value: &Value) -> String { // A `Value` into an in-memory buffer cannot fail; swallowing an `Err` // into an empty string would truncate the user's lockfile to "\n". @@ -5759,6 +5760,8 @@ fn nuget_xml_attribute(value: &str) -> String { .replace('\r', " ") } +use crate::formats::nuget::lock::PACKAGES_LOCK; + fn rewrite_nuget( files: &BTreeMap, overrides: &[DepOverride], @@ -5794,9 +5797,12 @@ fn rewrite_nuget( // contentHash (NU1403 on restore) and the ledger claimed the redirect. // Warn once and skip the whole nuget redirect before anything is planned // (the npm twin does the same). An ABSENT lock is fine — config-only. - let mut lock: Option = match files.get("packages.lock.json") { + // Read past a UTF-8 BOM the way dotnet does (#623); the write below + // keeps it. + let lock_text = files.get(PACKAGES_LOCK); + let mut lock: Option = match lock_text { None => None, - Some(text) => match serde_json::from_str::(text) { + Some(text) => match crate::formats::nuget::lock::parse_lock(text) { Ok(parsed) => Some(parsed), Err(_) => { result.warnings.push(RewriteWarning { @@ -5835,6 +5841,34 @@ fn rewrite_nuget( .clone() .unwrap_or_else(|| dep.name.to_lowercase()); + let version_norm = crate::vendor::nuget_feed::normalize_nuget_version( + ov.identifiers + .nuget_version_norm + .as_deref() + .unwrap_or(&dep.version), + ); + // The mapping routes every version of the id to the Socket source, + // which serves only the patched one: a target framework that locks + // another version could no longer restore it, and re-pinning that + // entry would silently swap in the patched version (#593). Refused + // before anything is wired. + if let Some(lock_val) = &lock { + let others = + crate::formats::nuget::lock::other_versions(lock_val, &id_lower, &version_norm); + if !others.is_empty() { + result.warnings.push(RewriteWarning { + code: "redirect_nuget_lock_other_version".into(), + detail: crate::formats::nuget::lock::other_versions_detail( + PACKAGES_LOCK, + &dep.name, + &version_norm, + &others, + ), + }); + continue; + } + } + let unwritable = || RewriteWarning { code: "redirect_nuget_config_unwritable".into(), detail: format!( @@ -5878,55 +5912,34 @@ fn rewrite_nuget( }); } + // Only the entries at the patched version: another version of the + // id is a different package (#593). if let Some(lock_val) = lock.as_mut() { - if let Some(deps) = lock_val - .get_mut("dependencies") - .and_then(Value::as_object_mut) + for (id, obj) in + crate::formats::nuget::lock::locked_at_mut(lock_val, &id_lower, &version_norm) { - for framework in deps.values_mut() { - if let Some(fw) = framework.as_object_mut() { - for (id, entry) in fw.iter_mut() { - if id.to_lowercase() == id_lower { - if let Some(obj) = entry.as_object_mut() { - let resolved = ov - .identifiers - .nuget_version_norm - .clone() - .unwrap_or_else(|| dep.version.clone()); - // Already redirected (re-run): no edit. - if obj.get("resolved").and_then(Value::as_str) - == Some(resolved.as_str()) - && obj.get("contentHash").and_then(Value::as_str) - == Some(content_hash.as_str()) - { - continue; - } - let original = json!({ - "resolved": obj.get("resolved").cloned().unwrap_or(Value::Null), - "contentHash": obj.get("contentHash").cloned().unwrap_or(Value::Null), - }); - obj.insert("resolved".into(), Value::String(resolved.clone())); - obj.insert( - "contentHash".into(), - Value::String(content_hash.clone()), - ); - lock_changed = true; - result.edits.push(FileEdit { - path: "packages.lock.json".into(), - kind: "redirect_nuget_lock".into(), - action: "rewritten".into(), - key: Some(id.clone()), - original: Some(original), - new: Some(json!({ - "resolved": resolved, - "contentHash": content_hash, - })), - }); - } - } - } - } + // Already redirected (re-run): no edit. + if obj.get("contentHash").and_then(Value::as_str) == Some(content_hash.as_str()) { + continue; } + let resolved = obj.get("resolved").cloned().unwrap_or(Value::Null); + let original = json!({ + "resolved": resolved, + "contentHash": obj.get("contentHash").cloned().unwrap_or(Value::Null), + }); + obj.insert("contentHash".into(), Value::String(content_hash.clone())); + lock_changed = true; + result.edits.push(FileEdit { + path: PACKAGES_LOCK.into(), + kind: "redirect_nuget_lock".into(), + action: "rewritten".into(), + key: Some(id.to_string()), + original: Some(original), + new: Some(json!({ + "resolved": resolved, + "contentHash": content_hash, + })), + }); } } } @@ -5935,10 +5948,12 @@ fn rewrite_nuget( result.files.insert(config_path.into(), config); } if lock_changed { - if let Some(lock_val) = lock { - result - .files - .insert("packages.lock.json".into(), serialize_json(&lock_val)); + if let (Some(lock_val), Some(original)) = (lock, lock_text) { + // In the lock's own layout: its BOM, indent and line endings. + result.files.insert( + PACKAGES_LOCK.into(), + serialize_json_like(&lock_val, original), + ); } } } @@ -22557,6 +22572,102 @@ packages: ); } + /// #623: dotnet restores a lock that starts with a UTF-8 BOM, so hosted + /// wires and re-pins it like any other lock (no `unparseable` skip), + /// and the rewritten lock keeps its BOM and its CRLF layout. + #[test] + fn nuget_bom_lock_is_redirected_and_keeps_its_bom() { + let lock = "\u{feff}{\r\n \"version\": 1,\r\n \"dependencies\": {\r\n \"net8.0\": {\r\n \"Newtonsoft.Json\": {\r\n \"type\": \"Direct\",\r\n \"requested\": \"[13.0.3, )\",\r\n \"resolved\": \"13.0.3\",\r\n \"contentHash\": \"ORIGINALHASH==\"\r\n }\r\n }\r\n }\r\n}"; + let mut files = BTreeMap::new(); + files.insert("nuget.config".to_string(), default_nuget_config()); + files.insert("packages.lock.json".to_string(), lock.to_string()); + let r = rewrite_registry_redirect(&files, &[nuget_override()]); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert!(r.files.contains_key("nuget.config"), "{:?}", r.files.keys()); + let out = r.files.get("packages.lock.json").expect("lock re-pinned"); + assert_eq!( + *out, + lock.replace("ORIGINALHASH==", "PATCHED=="), + "only the hash changes; BOM and CRLF layout kept" + ); + } + + /// #593: a multi-targeting lock resolving the patched id at another + /// version in some framework is refused whole: the exact-id mapping + /// would route that framework to a feed that serves only the patched + /// version, and re-pinning its entry would swap in the patched + /// version's bytes. Nothing is written. + #[test] + fn nuget_lock_with_the_id_at_another_version_is_refused() { + let lock = r#"{ + "version": 1, + "dependencies": { + "net6.0": { + "Newtonsoft.Json": { "type": "Direct", "requested": "[12.0.3, )", "resolved": "12.0.3", "contentHash": "OLD12==" } + }, + "net8.0": { + "Newtonsoft.Json": { "type": "Direct", "requested": "[13.0.3, )", "resolved": "13.0.3", "contentHash": "OLD13==" } + } + } +} +"#; + let mut files = BTreeMap::new(); + files.insert("nuget.config".to_string(), default_nuget_config()); + files.insert("packages.lock.json".to_string(), lock.to_string()); + let r = rewrite_registry_redirect(&files, &[nuget_override()]); + assert!( + r.files.is_empty() && r.edits.is_empty(), + "nothing may land: files={:?} edits={:?}", + r.files.keys(), + r.edits + ); + assert_eq!(warning_codes(&r), vec!["redirect_nuget_lock_other_version"]); + assert!( + r.warnings[0].detail.contains("12.0.3"), + "{:?}", + r.warnings[0] + ); + } + + /// #593: only the entries at the patched version are re-pinned, and + /// `resolved` is never rewritten (a `13.0.3.0` spelling stays). + #[test] + fn nuget_lock_repins_only_entries_at_the_patched_version() { + let lock = r#"{ + "version": 1, + "dependencies": { + "net6.0": { + "Newtonsoft.Json": { "type": "Direct", "requested": "[13.0.3, )", "resolved": "13.0.3.0", "contentHash": "OLD13==" }, + "Other.Pkg": { "type": "Direct", "requested": "[1.0.0, )", "resolved": "1.0.0", "contentHash": "OTHER==" } + }, + "net8.0": { + "newtonsoft.json": { "type": "Transitive", "resolved": "13.0.3", "contentHash": "OLD13==" } + } + } +} +"#; + let mut files = BTreeMap::new(); + files.insert("nuget.config".to_string(), default_nuget_config()); + files.insert("packages.lock.json".to_string(), lock.to_string()); + let r = rewrite_registry_redirect(&files, &[nuget_override()]); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + let out = r.files.get("packages.lock.json").expect("lock re-pinned"); + assert_eq!( + serde_json::from_str::(out).unwrap(), + serde_json::from_str::(&lock.replace("OLD13==", "PATCHED==")).unwrap() + ); + let locks: Vec<&FileEdit> = r + .edits + .iter() + .filter(|e| e.kind == "redirect_nuget_lock") + .collect(); + assert_eq!(locks.len(), 2, "{locks:?}"); + assert_eq!( + locks[0].new, + Some(json!({"resolved": "13.0.3.0", "contentHash": "PATCHED=="})) + ); + } + /// The re-run probe reads the parsed `` keys, so a /// hand-normalized spelling of the socket source (single quotes, spaces /// around `=`) is recognized as already wired instead of being added a diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/nuget.rs b/crates/socket-patch-core/src/patch/redirect/upstream/nuget.rs index 37d370c33..31fd19ea8 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/nuget.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/nuget.rs @@ -262,7 +262,10 @@ pub(crate) async fn restore( continue; } }; - let mut lock: Option = match lock_text.as_deref().map(serde_json::from_str) { + let mut lock: Option = match lock_text + .as_deref() + .map(crate::formats::nuget::lock::parse_lock) + { None => None, Some(Ok(v)) => Some(v), Some(Err(_)) => { @@ -279,17 +282,15 @@ pub(crate) async fn restore( let Some(lock) = lock.as_mut() else { continue; }; - let entries: Vec<&mut serde_json::Map> = lock - .get_mut("dependencies") - .and_then(Value::as_object_mut) - .into_iter() - .flat_map(|fws| fws.values_mut()) - .filter_map(Value::as_object_mut) - .flat_map(|fw| fw.iter_mut()) - .filter(|(k, _)| k.eq_ignore_ascii_case(id)) - .filter_map(|(_, e)| e.as_object_mut()) - .filter(|e| e.contains_key("contentHash")) - .collect(); + // Only the entries at the pinned version: another version of + // the id was never re-pinned (#593). + let norm = normalize_nuget_version(version); + let entries: Vec<&mut serde_json::Map> = + crate::formats::nuget::lock::locked_at_mut(lock, id, &norm) + .into_iter() + .map(|(_, e)| e) + .filter(|e| e.contains_key("contentHash")) + .collect(); if entries.is_empty() { continue; } @@ -297,7 +298,6 @@ pub(crate) async fn restore( result.refuse(&pin.uuid, why); continue; } - let norm = normalize_nuget_version(version); let hash = match ctx.client.nuget_content_hash(id, &norm).await { Ok(h) => h, Err(why) => { @@ -306,13 +306,6 @@ pub(crate) async fn restore( } }; for entry in entries { - let keeps_resolved = entry - .get("resolved") - .and_then(Value::as_str) - .is_some_and(|r| normalize_nuget_version(r).eq_ignore_ascii_case(&norm)); - if !keeps_resolved { - entry.insert("resolved".into(), Value::String(norm.clone())); - } entry.insert("contentHash".into(), Value::String(hash.clone())); } } @@ -335,9 +328,13 @@ pub(crate) async fn restore( } view.write(rel, text); if let (Some(lock), Some(before)) = (lock, lock_text) { - let after = super::super::serialize_json(&lock); - if serde_json::from_str::(&before).ok().as_ref() != Some(&lock) { - view.write(&lock_rel, after); + // In the lock's own layout (BOM, indent, line endings). + if crate::formats::nuget::lock::parse_lock(&before) + .ok() + .as_ref() + != Some(&lock) + { + view.write(&lock_rel, super::super::serialize_json_like(&lock, &before)); } } result diff --git a/crates/socket-patch-core/src/vendor/nuget_feed.rs b/crates/socket-patch-core/src/vendor/nuget_feed.rs index 13c335489..30c27f52d 100644 --- a/crates/socket-patch-core/src/vendor/nuget_feed.rs +++ b/crates/socket-patch-core/src/vendor/nuget_feed.rs @@ -28,7 +28,7 @@ use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorServiceConfig, Vendo /// Project-relative lockfile this backend pins (optional — NuGet only writes /// it when `RestorePackagesWithLockFile`/`--use-lock-file` is set). -const PACKAGES_LOCK: &str = "packages.lock.json"; +use crate::formats::nuget::lock::{locked_at, PACKAGES_LOCK}; /// Wiring-record discriminators. `nuget_config_source` carries the WHOLE-FILE /// pre/post `nuget.config` snapshot (the authoritative revert record); @@ -106,48 +106,6 @@ pub(crate) fn nupkg_leaf(id_lower: &str, version: &str) -> String { format!("{id_lower}.{}.nupkg", normalize_nuget_version(version)) } -/// One `packages.lock.json` `dependencies..` entry that restores -/// from a source: its raw id key, `resolved` and `contentHash` strings. -#[derive(Debug, Clone, Copy)] -pub(crate) struct NugetLockEntry<'a> { - pub(crate) id: &'a str, - pub(crate) resolved: &'a str, - pub(crate) content_hash: Option<&'a str>, -} - -/// Every entry of a parsed `packages.lock.json`, target framework by target -/// framework, in document-key order. Frameworks that are not objects and -/// entries without a string `resolved` (`type: "Project"` references, which -/// nothing restores from a source) are skipped; strings are raw (callers -/// trim / normalize / compare ids as they need). -pub(crate) fn nuget_lock_entries(doc: &Value) -> impl Iterator> { - doc.get("dependencies") - .and_then(Value::as_object) - .into_iter() - .flat_map(|frameworks| frameworks.values()) - .filter_map(Value::as_object) - .flatten() - .filter_map(|(id, entry)| { - Some(NugetLockEntry { - id, - resolved: entry.get("resolved").and_then(Value::as_str)?, - content_hash: entry.get("contentHash").and_then(Value::as_str), - }) - }) -} - -/// The lock entries of package `id` (case-insensitive) whose `resolved` -/// normalizes to `version_norm` — the entries the vendored nupkg replaces. -fn locked_at<'a>( - doc: &'a Value, - id: &'a str, - version_norm: &'a str, -) -> impl Iterator> { - nuget_lock_entries(doc).filter(move |e| { - e.id.eq_ignore_ascii_case(id) && normalize_nuget_version(e.resolved) == version_norm - }) -} - /// Everything [`vendor_nuget`] decides before it can first ask the patch /// service: the coordinate guards, the no-op of an empty patch, the /// nuget.config and packages.lock.json reads, and whether the feed already @@ -258,6 +216,25 @@ async fn nuget_prelude( .as_deref() .and_then(crate::formats::nuget::parse_config) .is_some_and(|parsed| parsed.sources.iter().any(|(key, _)| *key == source_key)); + // The mapping routes every version of the id to this feed, which serves + // only the patched one: a framework that locks another version could no + // longer restore (NU1102). Refused before anything is wired (#593). + if !config_wired { + if let Some(Ok(doc)) = lock_text.as_deref().map(lock_value) { + let others = crate::formats::nuget::lock::other_versions(&doc, name, &version_norm); + if !others.is_empty() { + return Err(refused( + "vendor_nuget_lock_other_version", + crate::formats::nuget::lock::other_versions_detail( + PACKAGES_LOCK, + name, + &version_norm, + &others, + ), + )); + } + } + } let in_sync = config_wired && { // One guarded read of the committed nupkg serves both the member-hash // check and the lock's content-hash pin. @@ -1191,7 +1168,9 @@ static LOCK_VALUE_MEMO: ParseMemo = ParseMemo::new(); /// [`PACKAGES_LOCK`] as JSON, reusing the run's parse while `text` is the /// text it came from. fn lock_value(text: &str) -> Result, serde_json::Error> { - LOCK_VALUE_MEMO.parse(text.as_bytes(), || serde_json::from_str::(text)) + LOCK_VALUE_MEMO.parse(text.as_bytes(), || { + crate::formats::nuget::lock::parse_lock(text) + }) } /// Rewrite `contentHash` to `new_hash` for every framework entry of `id` @@ -1971,6 +1950,72 @@ mod tests { Some(out) } + /// #593: a lock that also resolves the patched id at another version + /// (a multi-targeting project) is refused before anything is written: + /// the exact-id mapping would send that framework to a feed that only + /// serves the patched version (NU1102). + #[tokio::test] + async fn lock_with_the_id_at_another_version_is_refused_untouched() { + let (dir, blobs, installed, record) = fixture(false, None).await; + let root = dir.path(); + let lock = lock_json("ORIGINALcachedhash==").replacen( + "\"resolved\": \"13.0.3\"", + "\"resolved\": \"12.0.3\"", + 1, + ); + tokio::fs::write(root.join(PACKAGES_LOCK), &lock) + .await + .unwrap(); + let (code, detail) = + unwrap_refused(run_vendor(root, &blobs, &installed, &record, false).await); + assert_eq!(code, "vendor_nuget_lock_other_version"); + assert!(detail.contains("12.0.3"), "{detail}"); + assert_eq!( + tokio::fs::read_to_string(root.join(PACKAGES_LOCK)) + .await + .unwrap(), + lock + ); + assert!(!root.join("nuget.config").exists()); + assert!(!root.join(".socket").exists()); + } + + /// #623: dotnet restores a BOM'd lock, so vendor pins it (the BOM kept) + /// and revert restores it byte-identically. + #[tokio::test] + async fn bom_lock_is_pinned_and_reverted_byte_identically() { + let (dir, blobs, installed, record) = fixture(false, None).await; + let root = dir.path(); + let lock = format!("\u{feff}{}", lock_json("ORIGINALcachedhash==")); + tokio::fs::write(root.join(PACKAGES_LOCK), &lock) + .await + .unwrap(); + let (result, entry, warnings) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); + assert!(result.success, "{:?}", result.error); + assert!( + !warnings.iter().any(|w| w.code.contains("lock")), + "{warnings:?}" + ); + let pinned = tokio::fs::read_to_string(root.join(PACKAGES_LOCK)) + .await + .unwrap(); + let nupkg = tokio::fs::read(root.join(copy_rel())).await.unwrap(); + assert_eq!( + pinned, + lock.replace("ORIGINALcachedhash==", &sha512_base64_of(&nupkg)) + ); + let entry = entry.expect("ledger entry"); + let reverted = revert_nuget(&entry, root, false).await; + assert!(reverted.success, "{:?}", reverted.error); + assert_eq!( + tokio::fs::read_to_string(root.join(PACKAGES_LOCK)) + .await + .unwrap(), + lock + ); + } + #[tokio::test] async fn happy_path_wires_config_lock_and_artifact() { let (dir, blobs, installed, record) = fixture(true, None).await; diff --git a/crates/socket-patch-core/src/vex/discover/nuget.rs b/crates/socket-patch-core/src/vex/discover/nuget.rs index 225e97f36..6a01a1fed 100644 --- a/crates/socket-patch-core/src/vex/discover/nuget.rs +++ b/crates/socket-patch-core/src/vex/discover/nuget.rs @@ -73,10 +73,11 @@ use super::{ Discovery, PatchedRef, UnlockedPin, WiringMode, DIAG_LOCKFILE_UNPARSEABLE, DIAG_REF_INVALID, DIAG_REF_UNATTRIBUTABLE, }; +use crate::formats::nuget::lock::nuget_lock_entries; use crate::formats::nuget::{parse_config, NugetConfig}; use crate::vendor::lock_inventory::LockIntegrity; use crate::vendor::nuget_config::{same_file, CONFIG_NAMES}; -use crate::vendor::nuget_feed::{is_plain_nuget_token, nuget_lock_entries, nupkg_leaf}; +use crate::vendor::nuget_feed::{is_plain_nuget_token, nupkg_leaf}; use crate::vendor::path::VENDOR_DIR; /// The lock NuGet writes beside the project (default name). From 0168763f69440e249f21dcfeb282ea4b689125d5 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 13:55:46 -0400 Subject: [PATCH 06/10] Pin every NuGet lock the root config governs vendor / scan --mode vendored and scan --mode hosted wired the root nuget.config, which every project under the root inherits, but only re-pinned /packages.lock.json. A member project's own lock (#353) or a per-project packages..lock.json (#514) kept the upstream contentHash, so every fresh restore failed NU1403 while the run reported success, and vendored even claimed the lockfile setting was off. Both modes now discover the locks the projects under the root restore into (formats::nuget::lock::governed_locks over a walk of the project files) and pin all of them: the root lock, member locks, named locks and a literal NuGetLockFilePath. A NuGetLockFilePath that cannot be evaluated is refused (vendor_ / redirect_nuget_lock_path_unresolved) instead of leaving a lock unpinned. Vendored records one wiring entry per lock path and reverts each; hosted rewrites them under their own paths; the hosted unwind and VEX read the same set. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/CLI_CONTRACT.md | 6 +- .../tests/e2e_nuget_dotnet_build.rs | 104 ++++++ .../src/formats/nuget/lock.rs | 246 +++++++++++++++ crates/socket-patch-core/src/hosted/engine.rs | 30 ++ .../src/patch/redirect/mod.rs | 188 +++++++++-- .../src/patch/redirect/upstream/nuget.rs | 138 +++++--- .../src/vendor/nuget_config.rs | 88 ++++++ .../src/vendor/nuget_feed.rs | 298 +++++++++++++++--- .../src/vex/discover/nuget.rs | 125 ++++++-- 9 files changed, 1071 insertions(+), 152 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index dd88c2fd2..7d1e785ce 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -175,7 +175,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Attribution gate (v5.0).** A hosted run never leaves wiring that lockfile discovery calls contested: before the rewrite writes any lockfile, the same discovery `vex`, `list`, `rollback`, `remove` and `vendor` read runs over the project as the rewrite would leave it. A candidate whose pin discovery reads but cannot attribute to one package version (a requirements `-r` include resolving the same version from the registry beside a rewired `Pipfile.lock`, #567; a Maven pin in a ``, #260) is left out of the rewrite and reported in `redirect.skipped[]` as `redirect_unattributable` (nothing written for it; exit code unchanged). Unchanged: a pin in a file discovery does not read (a pre-2.6 bundler `Gemfile`, locked by the next `bundle install`) keeps the rewriter's verdict, and so does a deliberate partial redirect the run already reports (a bundled or `bun patch`-ed copy left on the registry, a yarn `npm:` alias entry left on the registry with `redirect_yarn_classic_alias_skipped` / `redirect_yarn_berry_alias_skipped` while the package's direct entry is pinned, a dep withheld from the vlt rewrite while a sibling lock takes it), and so does a vendored→hosted takeover: its vendored wiring is reverted in the run's staged overlay before the rewrite plans, and it is redirected when the rewriters pin it (otherwise it is retracted and stays vendored), in a `--dry-run` preview the same. A lockless NuGet / Cargo pin (an exclusive Socket source mapping without `packages.lock.json`, a Cargo registry pin without `Cargo.lock`) is still written, with a `redirect_pin_lockless` warning: no lockfile records its version, so `vex` cannot attest it and `rollback` / `remove` / `vendor` refuse it until the lockfile exists (whether such pins should be written at all is an open decision). The rollout's recorded view uses the same discovery: a uuid a file merely mentions (a stale `package.json` field, an inactive `pdm.lock`, a comment) is not a pin and does not count as already patched. -The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and, for a Gradle build, every settings, build, `buildSrc`, included-build, applied and plugin-source script, version catalog and lock file the script graph reaches, plus `gradle/verification-metadata.xml`, `gradle/wrapper/gradle-wrapper.properties` and the owned `.socket/gradle/` files), and the sbt build files (`socket-patch.sbt`, `socket-patch-vendor.sbt`, `build.sbt`, `project/build.properties`, `.sbtopts`, `.jvmopts`; `build.sbt.lock` and the Mill / scala-cli build files `build.mill`, `build.mill.yaml`, `build.sc`, `.mill-version`, `project.scala` for their presence only) — read, never edited; `socket-patch.sbt` is the only sbt file hosted mode writes (see **Hosted sbt** below). **npm-family flavor coverage**: package-lock / npm-shrinkwrap (a package the project patches itself with npm ≥ 12.1's native `npm patch` — a root `patchedDependencies` key, or the lock entry's `patched` record — is left on its registry entry in every npm lock, `redirect_npm_patched_dependency_skipped`), pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic (a yarn 2+ install migrates a v1 `yarn.lock` and drops its pins, so a run whose v1 lock carries a hosted pin warns `redirect_yarn_classic_berry_migration_risk` — the hosted twin of the vendored `yarn_classic_berry_migration_risk` — unless the root `package.json`, read as advisory input, declares `"packageManager": "yarn@1…"`), **yarn berry** (the pin yarn writes for a root `resolutions` entry: the root `package.json` — edited only beside a berry `yarn.lock` — gains one `"@npm:": ""` selector per locked range (`redirect_yarn_berry_resolution` edits), and only that `yarn.lock` entry is re-keyed `"@"` with the same `resolution:` + `yarnBerry10c0` checksum (`redirect_yarn_berry_entry`), moved to yarn's key order; never an `npm:` locator, whose fetcher sends npm registry auth to the patch host, nor a tarball locator under an `npm:` key, which hardened mode rejects (YN0078). An older release's `npm:::__archiveUrl=` pin is still recognized and is re-pinned on the next run; rollback rebuilds the key from the selectors and drops them. Refused, nothing written: a user-authored `resolutions` entry for the package `redirect_yarn_berry_resolutions_conflict`, no root manifest `redirect_yarn_berry_manifest_missing`, a builtin `patch:` entry wrapping the same descriptor `redirect_yarn_berry_shared_descriptor`, an artifact URL yarn cannot fetch as a tarball `redirect_yarn_berry_artifact_url_unsupported`; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found` (or `redirect_bun_non_registry_entry_skipped` when the only same-version entry is a user URL / `file:` tarball, #497), a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when the `repo-state.json` beside a rewritten lock exists (`common/config/rush/repo-state.json` for the common lock, `common/config/subspaces//repo-state.json` for a subspace lock; the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives), and `redirect_pnpm_trust_lockfile` carries the Rush pnpm >=11 install remedy (see the trust paragraph above). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`; when `.mvn/wrapper/maven-wrapper.properties` pins a Maven older than 3.9.4, which ignores those files, the additive warning `redirect_maven_trusted_checksums_unenforced`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). **gradle** (v5.0) is automated wiring, no longer a pasted snippet: the owned settings script `.socket/gradle/socket-patch.hosted.settings.gradle` with its index `.socket/gradle/hosted-index.tsv`, one apply line per build's settings file, every lock entry of the GA moved to the suffixed version, and the suffixed component in an existing `gradle/verification-metadata.xml`. A refused dep writes nothing and keeps `redirect_gradle_manual_snippet` as its fallback; same-GAV grants are refused (`redirect_gradle_same_gav_unsupported`). Rules, refusals and codes: [Gradle builds](#gradle-builds-v50). +The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json` (plus, v5.0 #353/#514, every lock a project under the root restores into: a member project's `packages.lock.json`, a per-project `packages..lock.json`, a literal `NuGetLockFilePath` — each pinned with the root config; a `NuGetLockFilePath` it cannot evaluate warns `redirect_nuget_lock_path_unresolved` and a project tree it cannot list warns `redirect_nuget_lock_unreadable`, both skipping the nuget redirect with nothing written), `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and, for a Gradle build, every settings, build, `buildSrc`, included-build, applied and plugin-source script, version catalog and lock file the script graph reaches, plus `gradle/verification-metadata.xml`, `gradle/wrapper/gradle-wrapper.properties` and the owned `.socket/gradle/` files), and the sbt build files (`socket-patch.sbt`, `socket-patch-vendor.sbt`, `build.sbt`, `project/build.properties`, `.sbtopts`, `.jvmopts`; `build.sbt.lock` and the Mill / scala-cli build files `build.mill`, `build.mill.yaml`, `build.sc`, `.mill-version`, `project.scala` for their presence only) — read, never edited; `socket-patch.sbt` is the only sbt file hosted mode writes (see **Hosted sbt** below). **npm-family flavor coverage**: package-lock / npm-shrinkwrap (a package the project patches itself with npm ≥ 12.1's native `npm patch` — a root `patchedDependencies` key, or the lock entry's `patched` record — is left on its registry entry in every npm lock, `redirect_npm_patched_dependency_skipped`), pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic (a yarn 2+ install migrates a v1 `yarn.lock` and drops its pins, so a run whose v1 lock carries a hosted pin warns `redirect_yarn_classic_berry_migration_risk` — the hosted twin of the vendored `yarn_classic_berry_migration_risk` — unless the root `package.json`, read as advisory input, declares `"packageManager": "yarn@1…"`), **yarn berry** (the pin yarn writes for a root `resolutions` entry: the root `package.json` — edited only beside a berry `yarn.lock` — gains one `"@npm:": ""` selector per locked range (`redirect_yarn_berry_resolution` edits), and only that `yarn.lock` entry is re-keyed `"@"` with the same `resolution:` + `yarnBerry10c0` checksum (`redirect_yarn_berry_entry`), moved to yarn's key order; never an `npm:` locator, whose fetcher sends npm registry auth to the patch host, nor a tarball locator under an `npm:` key, which hardened mode rejects (YN0078). An older release's `npm:::__archiveUrl=` pin is still recognized and is re-pinned on the next run; rollback rebuilds the key from the selectors and drops them. Refused, nothing written: a user-authored `resolutions` entry for the package `redirect_yarn_berry_resolutions_conflict`, no root manifest `redirect_yarn_berry_manifest_missing`, a builtin `patch:` entry wrapping the same descriptor `redirect_yarn_berry_shared_descriptor`, an artifact URL yarn cannot fetch as a tarball `redirect_yarn_berry_artifact_url_unsupported`; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found` (or `redirect_bun_non_registry_entry_skipped` when the only same-version entry is a user URL / `file:` tarball, #497), a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when the `repo-state.json` beside a rewritten lock exists (`common/config/rush/repo-state.json` for the common lock, `common/config/subspaces//repo-state.json` for a subspace lock; the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives), and `redirect_pnpm_trust_lockfile` carries the Rush pnpm >=11 install remedy (see the trust paragraph above). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`; when `.mvn/wrapper/maven-wrapper.properties` pins a Maven older than 3.9.4, which ignores those files, the additive warning `redirect_maven_trusted_checksums_unenforced`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). **gradle** (v5.0) is automated wiring, no longer a pasted snippet: the owned settings script `.socket/gradle/socket-patch.hosted.settings.gradle` with its index `.socket/gradle/hosted-index.tsv`, one apply line per build's settings file, every lock entry of the GA moved to the suffixed version, and the suffixed component in an existing `gradle/verification-metadata.xml`. A refused dep writes nothing and keeps `redirect_gradle_manual_snippet` as its fallback; same-GAV grants are refused (`redirect_gradle_same_gav_unsupported`). Rules, refusals and codes: [Gradle builds](#gradle-builds-v50). **Hosted sbt (v5.0, additive)**: an sbt build root (`project/build.properties` naming an `sbt.version`, 0.13.18 or later) is wired through ONE generated root file, `socket-patch.sbt` — no user file is edited. It pins every granted Maven patch build-wide (a `ThisBuild` `dependencyOverrides +=` of the Socket-only `-socket.` version plus a `file:` resolver over `.socket/sbt-hosted/maven2/`, moved ahead of the default repositories on sbt 0.13 / 1.x so an unreachable one never blocks it offline), downloads the pinned pom and jar there on the first sbt load (sha256-checked, gitignored by the file itself), and installs a load-time verifier that fails `update` when any project resolves another version or a pinned artifact whose bytes are not pinned. Edits: `redirect_sbt_pin` (added), `redirect_sbt_pin_updated` (an existing row replaced: same GA and base under a new uuid, or the same uuid with new served values; `original` names the previous uuid and version), `redirect_sbt_pin_rechecked` (an existing row re-verified after the build's dependencies changed: its dependency digest is recorded anew, `original`/`new` are `{deps}`). The load-time verifier also fails `update` when a project declares a pinned GA at a version newer than the pin's base (the build-wide override would otherwise force it back down). A new pin is gated on sbt's own resolution records under `target/` (never the machine-wide cache): run-level stops wire nothing, warn once and exit 0 — `redirect_sbt_no_resolution_evidence` (none; run `sbt update` first; always the in-memory engine's answer), `redirect_sbt_resolution_incomplete` (a declared project left no evidence, or the project definitions cannot be read statically), `redirect_sbt_resolution_stale` (a build source is newer than some project's evidence: each project is dated by its own newest record, so a partial `sbt /update` does not vouch for the others). Per-patch refusals (never confirmed): `redirect_sbt_missing_override` (no `maven2` override or no suffixed version), `redirect_sbt_integrity_missing` (jar or pom sha256 missing), `redirect_sbt_unsafe_value` (a value unsafe in a Scala literal, or an index URL not naming the uuid), `redirect_sbt_version_conflict` (some project resolves another version, or a build source declares the GA newer than the patch's base), `redirect_sbt_override_conflict` (two patches for one GA in a run, or another base already pinned), `redirect_sbt_vendored_conflict` (the GA is pinned by `socket-patch-vendor.sbt`, or that file cannot be parsed — then every Maven patch), `redirect_sbt_owned_file_modified` / `redirect_sbt_owned_file_foreign` (`socket-patch.sbt` edited, or not socket-patch's — every Maven patch), `redirect_sbt_owned_file_unreadable` (a whole-run refusal: `socket-patch.sbt` is on disk but cannot be read as UTF-8 text, so writing it would replace it; nothing is written), `redirect_sbt_unsupported_version`, `redirect_sbt_build_root_unknown` (sbt files but no versioned build root — every Maven patch), `redirect_sbt_overrides_assignment` / `redirect_sbt_resolvers_assignment` (a build source reassigns `dependencyOverrides` / `resolvers` with `:=`, `~=` or `--=`), `redirect_sbt_dependency_lock_present` (a `build.sbt.lock`), `redirect_sbt_scala_runtime_unsupported` (`org.scala-lang`), `redirect_sbt_classifier_unsupported`; a GA no library configuration resolves is skipped silently (`redirect_sbt_meta_build_only` when only the meta-build resolves it). Advisories: `redirect_sbt_version_untested` (sbt 2.1+, still wired), `redirect_sbt_override_build_repos` (`sbt.override.build.repos=true`), `redirect_maven_pom_ignored_sbt_build` (a `pom.xml` beside the sbt build, which sbt never reads; the Maven rewriter still edits it for the Maven build). A re-run keeps an existing row and re-checks it. When the build's dependency digest changed since the pin, evidence resolved after the change (fresh, newer than the generated file) re-verifies it and the row's digest is refreshed (`redirect_sbt_pin_rechecked`); the uuid is NOT confirmed on `redirect_sbt_pin_declared_newer` (a build source now declares the GA newer than the pin's base; the row stays, sbt's load-time verifier fails the build, and the remedy is `socket-patch rollback` or declaring the base again), `redirect_sbt_pin_unverifiable` (the digest changed and the evidence predates the change, or the digest cannot be computed: run `sbt update`, then re-run socket-patch), `redirect_sbt_override_shadowed` (the evidence still resolves the base version) or `redirect_sbt_resolved_elsewhere` (the pinned version resolves from outside the pin repository from a file whose sha256 is not the pinned jar's; a copy holding the pinned bytes, such as the Ivy cache a second checkout reads, is fine — at most 64 pinned artifact files of up to 256 MiB are hashed, anything else counts as elsewhere), and also when a build source now reassigns `dependencyOverrides` / `resolvers` or a `build.sbt.lock` appeared (the same `redirect_sbt_overrides_assignment` / `redirect_sbt_resolvers_assignment` / `redirect_sbt_dependency_lock_present` codes; the row stays and sbt's load-time verifier fails the build). For a pure sbt root (no `pom.xml` / Gradle script beside it), maven confirmation is decided only by the sbt rewriter's report; on a mixed root a uuid the sbt rewriter refused is still confirmed by the Maven rewriter's own `pom.xml` pin (the generated sbt files never prove a pin by substring). **Mill and scala-cli** are guidance only: per Maven patch `redirect_mill_manual_snippet` / `redirect_scala_cli_manual_snippet` carry a paste-able snippet (repository + forced suffixed version), nothing is written or confirmed, and a pure Mill / scala-cli root gets no `redirect_maven_no_pom`; there, a Maven patch the server sent without a `maven2` registry override gets `redirect_maven_missing_override` instead of a snippet (with a `pom.xml` beside the Mill / scala-cli files the pom rewriter reports it). `rollback` / `remove` restore `socket-patch.sbt` offline (the rows removed, the file deleted with its last pin; the gitignored downloads are left). Manifest-less VEX reads every strictly parsed pin as a hosted reference but grants it the lockfile basis only when the local evidence shows every recorded version of the GA is the pinned one and every recorded artifact hashes to a pinned sha256 (else `sbt_resolution_unverified`). @@ -754,7 +754,7 @@ to **six flavors**. | pypi / pdm (pdm.lock) | (rebuilt wheel) | lock-only: the `[[package]]` gains the local-file `path` + `files[]` hash. pyproject + `content_hash` untouched. Non-fixture `[metadata] strategy` / hash-less locks refused | `pdm sync` (+ `pdm install --check`), cold cache | | pypi / pipenv (Pipfile.lock) | (rebuilt wheel) | lock-only: the `default`/`develop` entry → `{file, hashes:[sha256-of-our-wheel]}`. Pipfile + `_meta.hash` untouched. Emits `vendor_integrity_unverified` — pipenv does not hash-check file entries; the committed wheel bytes are the protection | `pipenv install --deploy` (+ `pipenv verify`), cold cache | | pypi / requirements.txt (pip / `uv pip`) | (rebuilt wheel) | pin line → `./` (markers carried over; transitive deps appended), plus `--hash=sha256:` only when the requirements tree is already in pip's hash-checking mode (any `--hash` or `--require-hashes`) | `pip install -r` / `uv pip install -r` **run from the project root** (both resolve bare paths against the CWD) | -| nuget | deterministically rebuilt `.nupkg` at `..nupkg` (the uuid dir IS a NuGet folder feed; the stale embedded signature is dropped — unsigned is accepted under NuGet's default validation) | `nuget.config` source + `packageSourceMapping` for the id (creating the mapping from scratch ALSO fans a `` out to every pre-existing source — mapping is exclusive, NU1100 otherwise) **+** `packages.lock.json` `contentHash` → `base64(sha512(nupkg))` for the entries at the patched version when the lock exists (`vendor_nuget_no_lockfile` warning otherwise; a lock that also resolves the id at another version is refused with `vendor_nuget_lock_other_version`, nothing written) | `dotnet restore --locked-mode`, cold cache, `--network none` (tampered nupkg fails NU1403) | +| nuget | deterministically rebuilt `.nupkg` at `..nupkg` (the uuid dir IS a NuGet folder feed; the stale embedded signature is dropped — unsigned is accepted under NuGet's default validation) | `nuget.config` source + `packageSourceMapping` for the id (creating the mapping from scratch ALSO fans a `` out to every pre-existing source — mapping is exclusive, NU1100 otherwise) **+** `packages.lock.json` `contentHash` → `base64(sha512(nupkg))` for the entries at the patched version in every lock a project under the root restores into — the root `packages.lock.json`, member projects' locks, `packages..lock.json`, a literal `NuGetLockFilePath` (v5.0 #353/#514; an unevaluable `NuGetLockFilePath` is refused with `vendor_nuget_lock_path_unresolved`) — (`vendor_nuget_no_lockfile` warning when there is none; a lock that also resolves the id at another version is refused with `vendor_nuget_lock_other_version`, nothing written) | `dotnet restore --locked-mode`, cold cache, `--network none` (tampered nupkg fails NU1403) | | maven | the patched `.jar` + the upstream pom (only its `` suffixed; transitives survive) + `.sha1` sidecars + an ownership marker under `.socket/vendor/maven2///-socket./` | every pom root, single-module (a reactor of one) or multi-module: the pinned `` + `` pin, `.mvn/maven.config` (`maven.repo.local.tail`) and the `socket-patch-vendor` fallback file repository (`checksumPolicy=fail`); Gradle, sbt and scala-cli roots go to the same JVM backend (ledger ecosystem `jvm`). Pre-v5 `maven_pom_repository` entries (`` to `.socket/vendor/maven/`) are revert-only: vendoring their root is refused (`vendor_jvm_shape_unsupported`, `legacy_maven_root`) | `mvn` build on a fresh checkout with a warm local repository and behind `mirrorOf external:*` (host capstone `e2e_vendor_maven_build` across the Maven matrix) | Ecosystems with no vendor backend (jsr) refuse per-purl with @@ -967,7 +967,7 @@ v5.0 replaces v4's per-purl reverts and whole-ledger reverse replay (`revert_rem * **gem** — `Gemfile.lock` / `gems.locked` + `Gemfile` / `gems.rb`: the spec moves back into the upstream `GEM` section (or the Socket remote leaves a merged section), the `source "" do … end` block is undone, the `CHECKSUMS` entry is re-pinned from the rubygems.org compact index (`SOCKET_RUBYGEMS_URL`) and the `DEPENDENCIES` pin loses its `!`. The declaration's original constraint is not recorded, so it comes back as the exact pin `gem "", ""`. A transitive gem (one the manifest never declared) gets an appended block with a blank line before it; the restore removes that block, its blank line and the `DEPENDENCIES` entry, so the pair comes back byte for byte. An appended block with no blank line before it (written by a release before this one) can't be told apart from an in-place rewrite, so it still comes back as the exact pin. Refused: an ambiguous upstream section, an upstream remote other than rubygems.org. The manifest pair can't make a committed bundler cache upstream: a `-.gem` left in Bundler's cache dir that isn't the upstream archive is named by `upstream_gem_stale_cache`, never deleted. * **composer** — `composer.lock`: `dist` and the deleted `source` block from packagist's composer v2 metadata (`SOCKET_PACKAGIST_URL`). Refused unless the entry is packagist-sourced and packagist still serves the lock's `dist.reference` for the version. * **maven** — `pom.xml` (the `-socket.` version suffix, the added `` / `` entry) and the `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` lines hosted mode writes: **no network**, so it restores under `--offline` too. `.mvn` files holding anything else keep the resolver lines (`maven_trusted_checksums_left`). - * **nuget** — `nuget.config` loses the `socket-patch-` source and its exact-id mapping; every `packages.lock.json` entry of the id gets nuget.org's `contentHash` back (`SOCKET_NUGET_URL`). Refused when the restored config would not resolve the id from nuget.org alone. A config hosted mode created from scratch is kept (`nuget_default_config_left`). + * **nuget** — `nuget.config` loses the `socket-patch-` source and its exact-id mapping; every lock entry of the id at the pinned version, in every lock the root config governs (member projects' and `packages..lock.json` included), gets nuget.org's `contentHash` back (`SOCKET_NUGET_URL`). Refused when the restored config would not resolve the id from nuget.org alone. A config hosted mode created from scratch is kept (`nuget_default_config_left`). * Any other file wiring a pin refuses it (`socket-patch cannot re-derive the upstream entry in `). * **Refusals.** `--offline` refuses every pin whose restore needs a registry lookup (all but maven), as does a registry that does not answer or no longer describes the entry. A refused pin writes nothing; its message is `cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)` — for a `bun.lock` / `bun.lockb` followed by `, then run `bun install --force` (a plain `bun install` keeps the patched copy)`, since Bun's hoisted linker does not re-extract a package whose entry returns to the registry copy of the same `name@version` (#764) — human `Error: Cannot restore …` on stderr (even under `--silent`), JSON `hosted.failed[{purl, error}]`, and `partial_failure` exit 1 (`remove`: the `hosted_revert_failed` error). A write failure after every pin resolved is one `hosted.failed` entry with the pseudo-purl `files`. * **Output.** Human `Restored to its upstream registry entry` / `Would restore to its upstream registry entry` (`--dry-run`). vlt: the stale installed copies of restored nodes are removed afterwards, as before (`--no-vlt-install-cleanup` keeps them). diff --git a/crates/socket-patch-cli/tests/e2e_nuget_dotnet_build.rs b/crates/socket-patch-cli/tests/e2e_nuget_dotnet_build.rs index 3238fdd3c..c6df01926 100644 --- a/crates/socket-patch-cli/tests/e2e_nuget_dotnet_build.rs +++ b/crates/socket-patch-cli/tests/e2e_nuget_dotnet_build.rs @@ -967,3 +967,107 @@ fn nuget_vendored_dotnet_restore_then_manifestless_vex() { None, ); } + +// ── solution layout: member and named locks (#353, #514) ────────────── + +/// A solution layout: `nuget.config` at the root, one project under +/// `src/App/` with its own `packages.lock.json`, and one under `src/Named/` +/// whose lock is the per-project `packages.named.lock.json`. Vendoring from +/// the root must pin BOTH locks, so a fresh checkout restores each project +/// (`--locked-mode`, cold cache) with the patched bytes instead of NU1403. +#[test] +#[ignore = "real .NET SDK + nuget.org: run with --ignored (CI e2e matrix pins each SDK major)"] +fn nuget_vendored_solution_member_and_named_locks_restore() { + let sb = Sandbox::new(); + let Some(dn) = Dotnet::probe("vendored-solution", &sb) else { + return; + }; + let sdk = dn.version.clone(); + let root = sb.dir("solution"); + let store_fx = sb.dir("store-solution"); + std::fs::write(root.join("nuget.config"), REGISTRY_CONFIG).unwrap(); + let projects = [("src/App", "app"), ("src/Named", "named")]; + for (dir, name) in projects { + let d = root.join(dir); + std::fs::create_dir_all(&d).unwrap(); + std::fs::write(d.join(format!("{name}.csproj")), dn.csproj()).unwrap(); + dn.restore_ok(&sb, &d, &store_fx, &[], "member restore from nuget.org"); + } + // NuGet reads (and writes) the per-project name once it exists. + std::fs::rename( + root.join("src/Named/packages.lock.json"), + root.join("src/Named/packages.named.lock.json"), + ) + .unwrap(); + assert!(!root.join("packages.lock.json").exists()); + + let pristine = std::fs::read(pkg_dir(&store_fx).join(FILE_KEY)).unwrap(); + let mut patched = pristine.clone(); + patched.extend_from_slice(MARKER); + let upstream = std::fs::read(pkg_dir(&store_fx).join(NUPKG_NAME)).unwrap(); + let nupkg = patched_nupkg(&upstream, &patched); + let backend = Backend::start(VENDORED_UUID, &pristine, &patched, Some(&nupkg)); + let uri = backend.uri(); + + let (code, env, stderr) = socket_patch( + &root, + &store_fx, + &[ + "scan", + "--mode", + "vendored", + "--vendor-source", + "service", + "--json", + "--yes", + "--api-url", + &uri, + "--org", + ORG, + "--api-token", + "fake-token", + ], + ); + assert_eq!(code, Some(0), "SDK {sdk}: {env:#}\n{stderr}"); + assert!( + !env.to_string().contains("vendor_nuget_no_lockfile"), + "the member locks were found: {env:#}" + ); + let artifact = root.join(format!(".socket/vendor/nuget/{VENDORED_UUID}/{NUPKG_NAME}")); + let pin = content_hash(&std::fs::read(&artifact).unwrap()); + for lock in [ + "src/App/packages.lock.json", + "src/Named/packages.named.lock.json", + ] { + let text = std::fs::read_to_string(root.join(lock)).unwrap(); + assert!(text.contains(&pin), "SDK {sdk}: {lock} re-pinned: {text}"); + } + + // Fresh checkout of the committable files, cold cache, each project. + let checkout = sb.dir("solution-checkout"); + std::fs::copy(root.join("nuget.config"), checkout.join("nuget.config")).unwrap(); + copy_tree(&root.join(".socket"), &checkout.join(".socket")); + strip_manifest(&checkout); + let blobs = checkout.join(".socket/blobs"); + if blobs.exists() { + std::fs::remove_dir_all(blobs).unwrap(); + } + for (dir, name) in projects { + let (from, to) = (root.join(dir), checkout.join(dir)); + std::fs::create_dir_all(&to).unwrap(); + for entry in std::fs::read_dir(&from).unwrap() { + let entry = entry.unwrap(); + let file = entry.file_name().to_string_lossy().into_owned(); + if file.ends_with(".csproj") || file.ends_with(".lock.json") { + std::fs::copy(entry.path(), to.join(&file)).unwrap(); + } + } + let store = sb.dir(&format!("store-checkout-{name}")); + dn.restore_ok(&sb, &to, &store, &["--locked-mode"], "member fresh restore"); + assert_eq!( + std::fs::read(pkg_dir(&store).join(FILE_KEY)).unwrap(), + patched, + "SDK {sdk}: {dir} restored the PATCHED {FILE_KEY}" + ); + } +} diff --git a/crates/socket-patch-core/src/formats/nuget/lock.rs b/crates/socket-patch-core/src/formats/nuget/lock.rs index 7ce813272..2dd1b3dea 100644 --- a/crates/socket-patch-core/src/formats/nuget/lock.rs +++ b/crates/socket-patch-core/src/formats/nuget/lock.rs @@ -127,6 +127,179 @@ pub(crate) fn other_versions_detail( ) } +/// MSBuild project files NuGet restores `PackageReference`s for. +const PROJECT_EXTENSIONS: [&str; 3] = [".csproj", ".fsproj", ".vbproj"]; + +/// Whether `name` (a basename) is an MSBuild project file. +pub(crate) fn is_project_file(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + PROJECT_EXTENSIONS + .iter() + .any(|ext| lower.len() > ext.len() && lower.ends_with(ext)) +} + +/// The locks a project tree restores into, as root-relative `/` paths +/// (#353, #514). Every project under the root inherits the root +/// `nuget.config`, so the Socket source and mapping wired there reach every +/// one of them, and each lock they restore must be pinned with it: +/// +/// * the root `packages.lock.json`, when present (a project file NuGet +/// restores from the root, or a lock with no project beside it); +/// * per project, the lock NuGet reads: a literal `NuGetLockFilePath` +/// (relative to the project), else `packages..lock.json` +/// beside it (spaces in the name become `_`) when that file exists, else +/// `packages.lock.json` beside it. +/// +/// Only existing locks are returned. A `NuGetLockFilePath` this reader cannot +/// evaluate (an MSBuild property or item reference, a `Condition`, an +/// absolute path or one leaving the root) is returned in `unresolved` as +/// `(project, detail)`: the writers refuse rather than leave a lock they +/// cannot find on its upstream hash. +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct GovernedLocks { + pub(crate) locks: Vec, + pub(crate) unresolved: Vec<(String, String)>, +} + +/// [`GovernedLocks`] of `projects` (`(root-relative project path, text)`), +/// asking `exists` whether a root-relative file exists. +pub(crate) fn governed_locks( + projects: &[(String, String)], + exists: impl Fn(&str) -> bool, +) -> GovernedLocks { + let mut out = GovernedLocks::default(); + if exists(PACKAGES_LOCK) { + out.locks.push(PACKAGES_LOCK.to_string()); + } + for (project, text) in projects { + let (dir, file) = match project.rsplit_once('/') { + Some((dir, file)) => (dir, file), + None => ("", project.as_str()), + }; + let join = |leaf: &str| { + if dir.is_empty() { + leaf.to_string() + } else { + format!("{dir}/{leaf}") + } + }; + let lock = match lock_file_path_property(text) { + Some(Err(detail)) => { + out.unresolved.push((project.clone(), detail)); + continue; + } + Some(Ok(value)) => { + match resolve_relative(dir, &value) { + Some(rel) => rel, + None => { + out.unresolved.push(( + project.clone(), + format!("NuGetLockFilePath `{value}` is absolute or leaves the project root"), + )); + continue; + } + } + } + None => { + let stem = &file[..file.rfind('.').unwrap_or(file.len())]; + let named = join(&format!("packages.{}.lock.json", stem.replace(' ', "_"))); + if exists(&named) { + named + } else { + join(PACKAGES_LOCK) + } + } + }; + if exists(&lock) && !out.locks.contains(&lock) { + out.locks.push(lock); + } + } + out +} + +/// The project's literal `NuGetLockFilePath`: `None` when it sets none, +/// `Some(Err)` when it sets one this reader cannot evaluate. +fn lock_file_path_property(text: &str) -> Option> { + const OPEN: &str = "> = None; + let mut rest = text.as_str(); + while let Some(at) = rest.find(OPEN) { + let after = &rest[at + OPEN.len()..]; + // `` is another property. + if !after.starts_with(['>', ' ', '\t', '\r', '\n', '/']) { + rest = after; + continue; + } + let Some(gt) = after.find('>') else { + return Some(Err("an unterminated NuGetLockFilePath element".to_string())); + }; + let attrs = after[..gt].trim(); + if attrs.ends_with('/') { + // ``: an empty value, NuGet's default. + value = None; + rest = &after[gt + 1..]; + continue; + } + let Some(end) = after[gt + 1..].find(CLOSE) else { + return Some(Err("an unterminated NuGetLockFilePath element".to_string())); + }; + let raw = after[gt + 1..gt + 1 + end].trim(); + rest = &after[gt + 1 + end + CLOSE.len()..]; + if raw.is_empty() && attrs.is_empty() { + value = None; + continue; + } + value = Some(if !attrs.is_empty() { + Err(format!( + "NuGetLockFilePath `{raw}` is conditional ({attrs}); its value depends on the build" + )) + } else if raw.contains("$(") || raw.contains("@(") || raw.contains("%(") { + Err(format!( + "NuGetLockFilePath `{raw}` references MSBuild properties or items" + )) + } else { + Ok(raw.to_string()) + }); + } + value +} + +fn strip_xml_comments(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while let Some(at) = rest.find("") { + Some(end) => rest = &rest[at + 4 + end + 3..], + None => return out, + } + } + out.push_str(rest); + out +} + +/// `value` (a project-relative path, either separator) resolved against +/// the root-relative `dir`; `None` when it is absolute or leaves the root. +fn resolve_relative(dir: &str, value: &str) -> Option { + let value = value.replace('\\', "/"); + if value.starts_with('/') || value.as_bytes().get(1) == Some(&b':') { + return None; + } + let mut parts: Vec<&str> = dir.split('/').filter(|p| !p.is_empty()).collect(); + for seg in value.split('/') { + match seg { + "" | "." => {} + ".." => { + parts.pop()?; + } + seg => parts.push(seg), + } + } + (!parts.is_empty()).then(|| parts.join("/")) +} + #[cfg(test)] mod tests { use super::*; @@ -176,4 +349,77 @@ mod tests { let doc = parse_lock(&MULTI.replace("\"12.0.3\"", "\"13.0.3.0\"")).unwrap(); assert!(other_versions(&doc, "Newtonsoft.Json", "13.0.3").is_empty()); } + + fn exists_in(files: &'static [&'static str]) -> impl Fn(&str) -> bool { + move |p| files.contains(&p) + } + + #[test] + fn member_and_named_locks_are_governed() { + let projects = vec![ + ("src/App/App.csproj".to_string(), "".to_string()), + ( + "src/Lib/My Lib.fsproj".to_string(), + "".to_string(), + ), + ("tests/T/T.vbproj".to_string(), "".to_string()), + ]; + let got = governed_locks( + &projects, + exists_in(&[ + "packages.lock.json", + "src/App/packages.lock.json", + "src/Lib/packages.My_Lib.lock.json", + "src/Lib/packages.lock.json", + ]), + ); + assert_eq!( + got.locks, + [ + "packages.lock.json", + "src/App/packages.lock.json", + "src/Lib/packages.My_Lib.lock.json" + ] + ); + assert!(got.unresolved.is_empty()); + } + + #[test] + fn lock_file_path_property_is_honored_or_refused() { + let project = |body: &str| { + vec![( + "src/App/App.csproj".to_string(), + format!("{body}"), + )] + }; + let got = governed_locks( + &project("..\\locks/app.lock.json"), + exists_in(&["src/locks/app.lock.json", "src/App/packages.lock.json"]), + ); + assert_eq!(got.locks, ["src/locks/app.lock.json"]); + // A commented-out property is not set. + let got = governed_locks( + &project(""), + exists_in(&["src/App/packages.lock.json"]), + ); + assert_eq!(got.locks, ["src/App/packages.lock.json"]); + for body in [ + "$(MSBuildProjectDirectory)/l.json", + "l.json", + "../../../outside.json", + "/abs/l.json", + ] { + let got = governed_locks(&project(body), exists_in(&[])); + assert!(got.locks.is_empty(), "{body}"); + assert_eq!(got.unresolved.len(), 1, "{body}"); + } + } + + #[test] + fn project_files_are_recognized_by_extension() { + assert!(is_project_file("App.csproj")); + assert!(is_project_file("App.FSPROJ")); + assert!(!is_project_file(".csproj")); + assert!(!is_project_file("App.sln")); + } } diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index e1f023d25..746f4a616 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -632,6 +632,36 @@ pub async fn read_candidate_files( } } + // NuGet: the root config routes every project under the root, so each + // project's lock is pinned with it (#353, #514). The project files ride + // along as advisory input (never rewritten) so the pure rewriter + // re-derives which keys are locks; a walk that cannot see the whole + // tree rides a synthetic key and the rewriter skips the redirect. + // NuGet is disk-only (the in-memory engine refuses it). + if candidates.iter().any(|c| c.dep.ecosystem == "nuget") { + if let Some(root) = view.disk_root() { + match crate::vendor::nuget_config::project_files(root) { + Ok(projects) => { + let governed = crate::formats::nuget::lock::governed_locks(&projects, |rel| { + crate::vendor::nuget_config::lock_present(root, rel) + }); + for (rel, text) in projects { + out.files.entry(rel).or_insert(text); + } + for rel in governed.locks { + if !out.files.contains_key(&rel) { + out.read(view, unreadable, &rel).await; + } + } + } + Err(why) => { + out.files + .insert(crate::patch::redirect::NUGET_LOCK_WALK_KEY.to_string(), why); + } + } + } + } + for path in view.python_lock_paths() { if let Some(script) = crate::utils::python_lock::script_of_lock(&path) { out.read(view, unreadable, script).await; diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index e6aa5f55a..98995721f 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -5760,7 +5760,11 @@ fn nuget_xml_attribute(value: &str) -> String { .replace('\r', " ") } -use crate::formats::nuget::lock::PACKAGES_LOCK; +/// The synthetic candidate key carrying why the engine could not list the +/// project's NuGet locks (an unreadable directory or project file): the +/// rewriter then skips the nuget redirect rather than leave a lock it never +/// saw on its upstream hash. Never a path (see [`sbt::SYNTHETIC_KEY_PREFIX`]). +pub const NUGET_LOCK_WALK_KEY: &str = ""; fn rewrite_nuget( files: &BTreeMap, @@ -5799,21 +5803,60 @@ fn rewrite_nuget( // (the npm twin does the same). An ABSENT lock is fine — config-only. // Read past a UTF-8 BOM the way dotnet does (#623); the write below // keeps it. - let lock_text = files.get(PACKAGES_LOCK); - let mut lock: Option = match lock_text { - None => None, - Some(text) => match crate::formats::nuget::lock::parse_lock(text) { - Ok(parsed) => Some(parsed), + // + // Every lock a project under the root restores into: the root config + // routes them all, so each is pinned with it (#353, #514). The engine + // reads the project files and their locks; the same pure discovery + // re-derives which keys are locks here. + if let Some(why) = files.get(NUGET_LOCK_WALK_KEY) { + result.warnings.push(RewriteWarning { + code: "redirect_nuget_lock_unreadable".into(), + detail: format!( + "cannot list the project's NuGet locks ({why}); nuget redirect skipped" + ), + }); + return; + } + let projects: Vec<(String, String)> = files + .iter() + .filter(|(rel, _)| { + !sbt::is_synthetic_key(rel) + && crate::formats::nuget::lock::is_project_file( + rel.rsplit('/').next().unwrap_or(rel), + ) + }) + .map(|(rel, text)| (rel.clone(), text.clone())) + .collect(); + let governed = + crate::formats::nuget::lock::governed_locks(&projects, |rel| files.contains_key(rel)); + if let Some((project, detail)) = governed.unresolved.first() { + result.warnings.push(RewriteWarning { + code: "redirect_nuget_lock_path_unresolved".into(), + detail: format!( + "{project}: {detail}; the lock it restores into cannot be pinned, so nuget \ + redirect is skipped (set a literal NuGetLockFilePath, or remove it)" + ), + }); + return; + } + // Read past a UTF-8 BOM the way dotnet does (#623); the write below + // keeps it. + let mut locks: Vec<(String, &String, Value, bool)> = Vec::new(); + for rel in governed.locks { + let Some(text) = files.get(&rel) else { + continue; + }; + match crate::formats::nuget::lock::parse_lock(text) { + Ok(parsed) => locks.push((rel, text, parsed, false)), Err(_) => { result.warnings.push(RewriteWarning { code: "redirect_nuget_lock_unparseable".into(), - detail: "packages.lock.json is not valid JSON; nuget redirect skipped".into(), + detail: format!("{rel} is not valid JSON; nuget redirect skipped"), }); return; } - }, - }; - let mut lock_changed = false; + } + } for dep in &nuget { let Some(ov) = registry_override_of_kind(dep, "nuget-v3") else { @@ -5852,21 +5895,24 @@ fn rewrite_nuget( // another version could no longer restore it, and re-pinning that // entry would silently swap in the patched version (#593). Refused // before anything is wired. - if let Some(lock_val) = &lock { + let other_version = locks.iter().find_map(|(rel, _, lock_val, _)| { let others = crate::formats::nuget::lock::other_versions(lock_val, &id_lower, &version_norm); - if !others.is_empty() { - result.warnings.push(RewriteWarning { - code: "redirect_nuget_lock_other_version".into(), - detail: crate::formats::nuget::lock::other_versions_detail( - PACKAGES_LOCK, - &dep.name, - &version_norm, - &others, - ), - }); - continue; - } + (!others.is_empty()).then(|| { + crate::formats::nuget::lock::other_versions_detail( + rel, + &dep.name, + &version_norm, + &others, + ) + }) + }); + if let Some(detail) = other_version { + result.warnings.push(RewriteWarning { + code: "redirect_nuget_lock_other_version".into(), + detail, + }); + continue; } let unwritable = || RewriteWarning { @@ -5914,7 +5960,7 @@ fn rewrite_nuget( // Only the entries at the patched version: another version of the // id is a different package (#593). - if let Some(lock_val) = lock.as_mut() { + for (rel, _, lock_val, lock_changed) in locks.iter_mut() { for (id, obj) in crate::formats::nuget::lock::locked_at_mut(lock_val, &id_lower, &version_norm) { @@ -5928,9 +5974,9 @@ fn rewrite_nuget( "contentHash": obj.get("contentHash").cloned().unwrap_or(Value::Null), }); obj.insert("contentHash".into(), Value::String(content_hash.clone())); - lock_changed = true; + *lock_changed = true; result.edits.push(FileEdit { - path: PACKAGES_LOCK.into(), + path: rel.clone(), kind: "redirect_nuget_lock".into(), action: "rewritten".into(), key: Some(id.to_string()), @@ -5947,13 +5993,12 @@ fn rewrite_nuget( if config_changed { result.files.insert(config_path.into(), config); } - if lock_changed { - if let (Some(lock_val), Some(original)) = (lock, lock_text) { + for (rel, original, lock_val, changed) in locks { + if changed { // In the lock's own layout: its BOM, indent and line endings. - result.files.insert( - PACKAGES_LOCK.into(), - serialize_json_like(&lock_val, original), - ); + result + .files + .insert(rel, serialize_json_like(&lock_val, original)); } } } @@ -22592,6 +22637,85 @@ packages: ); } + fn simple_lock(hash: &str) -> String { + format!( + "{{\n \"version\": 1,\n \"dependencies\": {{\n \"net8.0\": {{\n \"Newtonsoft.Json\": {{\n \"type\": \"Direct\",\n \"requested\": \"[13.0.3, )\",\n \"resolved\": \"13.0.3\",\n \"contentHash\": \"{hash}\"\n }}\n }}\n }}\n}}\n" + ) + } + + /// #353 / #514: member-project locks and named locks the root config + /// governs are re-pinned under their own paths with the config. + #[test] + fn nuget_member_and_named_locks_are_repinned() { + let mut files = BTreeMap::new(); + files.insert("nuget.config".to_string(), default_nuget_config()); + files.insert("src/App/App.csproj".to_string(), "".to_string()); + files.insert( + "src/App/packages.lock.json".to_string(), + simple_lock("ORIGINALHASH=="), + ); + files.insert("src/Lib/Lib.csproj".to_string(), "".to_string()); + files.insert( + "src/Lib/packages.Lib.lock.json".to_string(), + simple_lock("ORIGINALHASH=="), + ); + let r = rewrite_registry_redirect(&files, &[nuget_override()]); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + for rel in [ + "src/App/packages.lock.json", + "src/Lib/packages.Lib.lock.json", + ] { + assert_eq!( + r.files.get(rel).map(String::as_str), + Some(simple_lock("PATCHED==").as_str()), + "{rel}" + ); + } + assert!(!r.files.contains_key("src/App/App.csproj")); + let lock_paths: Vec<&str> = r + .edits + .iter() + .filter(|e| e.kind == "redirect_nuget_lock") + .map(|e| e.path.as_str()) + .collect(); + assert_eq!( + lock_paths, + [ + "src/App/packages.lock.json", + "src/Lib/packages.Lib.lock.json" + ] + ); + } + + /// #514: an unresolvable `NuGetLockFilePath`, or a project tree the + /// engine could not list, skips the nuget redirect with nothing written. + #[test] + fn nuget_unknowable_locks_skip_the_redirect() { + let mut files = BTreeMap::new(); + files.insert("nuget.config".to_string(), default_nuget_config()); + files.insert( + "app.csproj".to_string(), + "$(X)/l.json" + .to_string(), + ); + let r = rewrite_registry_redirect(&files, &[nuget_override()]); + assert!(r.files.is_empty() && r.edits.is_empty(), "{:?}", r.files); + assert_eq!( + warning_codes(&r), + vec!["redirect_nuget_lock_path_unresolved"] + ); + + let mut files = BTreeMap::new(); + files.insert("nuget.config".to_string(), default_nuget_config()); + files.insert( + NUGET_LOCK_WALK_KEY.to_string(), + "unreadable src".to_string(), + ); + let r = rewrite_registry_redirect(&files, &[nuget_override()]); + assert!(r.files.is_empty() && r.edits.is_empty(), "{:?}", r.files); + assert_eq!(warning_codes(&r), vec!["redirect_nuget_lock_unreadable"]); + } + /// #593: a multi-targeting lock resolving the patched id at another /// version in some framework is refused whole: the exact-id mapping /// would route that framework to a feed that serves only the patched diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/nuget.rs b/crates/socket-patch-core/src/patch/redirect/upstream/nuget.rs index 31fd19ea8..fc968c45e 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/nuget.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/nuget.rs @@ -212,9 +212,9 @@ pub(crate) async fn restore( Some((d, l)) => (format!("{d}/"), l), None => (String::new(), rel.as_str()), }; - if leaf == PACKAGES_LOCK { - // Restored together with the config that wires it; a pin no - // config claims is refused by the driver. + if !crate::patch::redirect::NUGET_CONFIG_FILE_NAMES.contains(&leaf) { + // A lock is restored together with the config that wires it; a + // pin no config claims is refused by the driver. continue; } let Some(original) = read_or_refuse(view, rel, &pins, &mut result).await else { @@ -254,44 +254,68 @@ pub(crate) async fn restore( continue; }; - let lock_rel = format!("{dir}{PACKAGES_LOCK}"); - let lock_text = match view.read(&lock_rel).await { - Ok(t) => t, - Err(e) => { - refuse_all_in(&pins, rel, &mut result, e); - continue; + // The locks the config governs: at the root, every lock a project + // under it restores into (#353, #514); a nested config, its own + // directory's default lock. + let lock_rels: Vec = if dir.is_empty() { + match crate::vendor::nuget_config::governed_locks_on_disk(view.root()) { + Ok(governed) => { + if let Some((project, detail)) = governed.unresolved.first() { + refuse_all_in( + &pins, + rel, + &mut result, + format!("{project}: {detail}; its lock cannot be restored"), + ); + continue; + } + governed.locks + } + Err(why) => { + refuse_all_in(&pins, rel, &mut result, why); + continue; + } } + } else { + vec![format!("{dir}{PACKAGES_LOCK}")] }; - let mut lock: Option = match lock_text - .as_deref() - .map(crate::formats::nuget::lock::parse_lock) - { - None => None, - Some(Ok(v)) => Some(v), - Some(Err(_)) => { - refuse_all_in( - &pins, - rel, - &mut result, - format!("{lock_rel} is not valid JSON"), - ); - continue; + let mut locks: Vec<(String, String, Value)> = Vec::new(); + let mut unreadable = false; + for lock_rel in lock_rels { + match view.read(&lock_rel).await { + Ok(None) => {} + Ok(Some(text)) => match crate::formats::nuget::lock::parse_lock(&text) { + Ok(value) => locks.push((lock_rel, text, value)), + Err(_) => { + refuse_all_in( + &pins, + rel, + &mut result, + format!("{lock_rel} is not valid JSON"), + ); + unreadable = true; + break; + } + }, + Err(e) => { + refuse_all_in(&pins, rel, &mut result, e); + unreadable = true; + break; + } } - }; + } + if unreadable { + continue; + } for (pin, id, version) in &restored { - let Some(lock) = lock.as_mut() else { - continue; - }; // Only the entries at the pinned version: another version of // the id was never re-pinned (#593). let norm = normalize_nuget_version(version); - let entries: Vec<&mut serde_json::Map> = - crate::formats::nuget::lock::locked_at_mut(lock, id, &norm) - .into_iter() - .map(|(_, e)| e) - .filter(|e| e.contains_key("contentHash")) - .collect(); - if entries.is_empty() { + let pinned = locks.iter().any(|(_, _, lock)| { + crate::formats::nuget::lock::locked_at(lock, id, &norm) + .any(|e| e.content_hash.is_some()) + }); + if !pinned { continue; } if let Err(why) = check_upstream_feed(&cfg, id, rel) { @@ -305,8 +329,12 @@ pub(crate) async fn restore( continue; } }; - for entry in entries { - entry.insert("contentHash".into(), Value::String(hash.clone())); + for (_, _, lock) in locks.iter_mut() { + for (_, entry) in crate::formats::nuget::lock::locked_at_mut(lock, id, &norm) { + if entry.contains_key("contentHash") { + entry.insert("contentHash".into(), Value::String(hash.clone())); + } + } } } // A refusal in this file reruns the pass without that pin; write @@ -327,7 +355,7 @@ pub(crate) async fn restore( )); } view.write(rel, text); - if let (Some(lock), Some(before)) = (lock, lock_text) { + for (lock_rel, before, lock) in locks { // In the lock's own layout (BOM, indent, line endings). if crate::formats::nuget::lock::parse_lock(&before) .ok() @@ -516,6 +544,42 @@ mod tests { assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); } + /// #353: the root config governs a member project's lock, so the + /// unwind restores it with the config. + #[tokio::test] + #[serial_test::serial] + async fn a_member_project_lock_is_restored_with_the_root_config() { + let server = nuget_org().await; + std::env::set_var("SOCKET_NUGET_URL", server.uri()); + let tmp = tempfile::tempdir().unwrap(); + let app = tmp.path().join("src/App"); + std::fs::create_dir_all(&app).unwrap(); + std::fs::write(tmp.path().join("nuget.config"), hosted_config(USER_MAPPING)).unwrap(); + std::fs::write(app.join("App.csproj"), "").unwrap(); + std::fs::write(app.join(PACKAGES_LOCK), lock(PATCHED)).unwrap(); + let pins = [HostedPin { + purl: "pkg:nuget/Newtonsoft.Json@13.0.3".into(), + uuid: UUID.into(), + files: vec!["nuget.config".into()], + }]; + let outcome = restore_upstream(tmp.path(), &pins, &RestoreOptions::default()).await; + std::env::remove_var("SOCKET_NUGET_URL"); + assert_eq!( + outcome.pins[0].status, + PinStatus::Restored, + "{:?}", + outcome.pins + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join("nuget.config")).unwrap(), + USER_MAPPING + ); + assert_eq!( + std::fs::read_to_string(app.join(PACKAGES_LOCK)).unwrap(), + lock(UPSTREAM) + ); + } + #[tokio::test] #[serial_test::serial] async fn a_config_created_from_scratch_is_kept_and_warned() { diff --git a/crates/socket-patch-core/src/vendor/nuget_config.rs b/crates/socket-patch-core/src/vendor/nuget_config.rs index 460530741..c626dbe97 100644 --- a/crates/socket-patch-core/src/vendor/nuget_config.rs +++ b/crates/socket-patch-core/src/vendor/nuget_config.rs @@ -47,6 +47,94 @@ fn regular_file(path: &std::path::Path) -> bool { std::fs::symlink_metadata(path).is_ok_and(|meta| meta.file_type().is_file()) } +// ── project walk ── + +/// Directories the project walk never enters: build output, the restore's +/// `obj/`, a legacy `packages/` folder and JS dependencies (hidden ones — +/// `.git`, `.socket` — are skipped too). The NuGet crawler's restore scope +/// skips the same set. +const SKIPPED_DIRS: [&str; 4] = ["bin", "obj", "packages", "node_modules"]; + +/// Directories the walk lists before giving up. +const WALK_DIR_BUDGET: usize = 10_000; + +/// A project file larger than this is not an MSBuild project anyone wrote. +const MAX_PROJECT_BYTES: u64 = 4 * 1024 * 1024; + +/// Every MSBuild project file under `root` (root-relative, `/`-separated, +/// sorted) with its text. Symlinked directories are not followed. `Err` +/// when the walk cannot see the whole tree (an unreadable directory or +/// project file, or more than [`WALK_DIR_BUDGET`] directories): a lock the +/// walk missed would keep its upstream hash under the wired mapping. +pub(crate) fn project_files(root: &std::path::Path) -> Result, String> { + let mut out = Vec::new(); + let mut pending = vec![String::new()]; + let mut listed = 0usize; + while let Some(rel) = pending.pop() { + listed += 1; + if listed > WALK_DIR_BUDGET { + return Err(format!( + "more than {WALK_DIR_BUDGET} directories under the project root" + )); + } + let dir = if rel.is_empty() { + root.to_path_buf() + } else { + root.join(&rel) + }; + let entries = + std::fs::read_dir(&dir).map_err(|e| format!("unreadable {}: {e}", dir.display()))?; + for entry in entries { + let entry = entry.map_err(|e| format!("unreadable {}: {e}", dir.display()))?; + let Some(name) = entry.file_name().to_str().map(str::to_string) else { + continue; + }; + let child = if rel.is_empty() { + name.clone() + } else { + format!("{rel}/{name}") + }; + let Ok(kind) = entry.file_type() else { + continue; + }; + if kind.is_dir() { + if !name.starts_with('.') && !SKIPPED_DIRS.contains(&name.as_str()) { + pending.push(child); + } + } else if kind.is_file() && crate::formats::nuget::lock::is_project_file(&name) { + let path = entry.path(); + if std::fs::metadata(&path).is_ok_and(|m| m.len() > MAX_PROJECT_BYTES) { + return Err(format!("{} is too large to read", path.display())); + } + let text = crate::utils::fs::read_regular_to_string_sync(&path) + .map_err(|e| format!("unreadable {}: {e}", path.display()))?; + out.push((child, text)); + } + } + } + out.sort(); + Ok(out) +} + +/// [`crate::formats::nuget::lock::governed_locks`] of the project tree on +/// disk under `root`. +pub(crate) fn governed_locks_on_disk( + root: &std::path::Path, +) -> Result { + let projects = project_files(root)?; + Ok(crate::formats::nuget::lock::governed_locks( + &projects, + |rel| lock_present(root, rel), + )) +} + +/// Whether something other than a directory sits at `root/rel` (`lstat`): +/// a FIFO or link under a lock name is then read, and refused, by the +/// FIFO-safe reader rather than taken for an absent lock. +pub(crate) fn lock_present(root: &std::path::Path, rel: &str) -> bool { + std::fs::symlink_metadata(root.join(rel)).is_ok_and(|m| !m.is_dir()) +} + #[cfg(test)] mod tests { use super::same_file; diff --git a/crates/socket-patch-core/src/vendor/nuget_feed.rs b/crates/socket-patch-core/src/vendor/nuget_feed.rs index 30c27f52d..146f4c582 100644 --- a/crates/socket-patch-core/src/vendor/nuget_feed.rs +++ b/crates/socket-patch-core/src/vendor/nuget_feed.rs @@ -7,7 +7,7 @@ use serde_json::Value; use crate::manifest::schema::PatchRecord; use crate::patch::apply::{ApplyResult, PatchSources}; use crate::patch::copy_tree::remove_tree; -use crate::patch::path_safety::is_safe_single_segment; +use crate::patch::path_safety::{is_safe_multi_segment, is_safe_single_segment}; use crate::utils::fs::{ atomic_write_artifact, atomic_write_bytes_preserving_mode, read_regular_to_string, }; @@ -122,8 +122,8 @@ struct NugetPrelude { source_key: String, config_path: Option, config_text: Option, - lock_path: PathBuf, - lock_text: Option, + /// Every lock the projects under the root restore into (#353, #514). + locks: Vec, /// nuget.config already carries this uuid's source. config_wired: bool, /// ...and the committed nupkg plus the lock pin are in sync (the hot @@ -197,17 +197,40 @@ async fn nuget_prelude( }, None => None, }; - let lock_path = project_root.join(PACKAGES_LOCK); - let lock_text: Option = match read_regular_to_string(&lock_path).await { - Ok(t) => Some(t), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + // Every lock a project under the root restores into: the root config + // routes all of them, so each must be pinned with it (#353, #514). + let governed = match super::nuget_config::governed_locks_on_disk(project_root) { + Ok(governed) => governed, Err(e) => { return Err(refused( "vendor_nuget_lock_unreadable", - format!("unreadable {}: {e}", lock_path.display()), + format!("cannot list the project's NuGet locks: {e}"), )); } }; + if let Some((project, detail)) = governed.unresolved.first() { + return Err(refused( + "vendor_nuget_lock_path_unresolved", + format!( + "{project}: {detail}; the lock it restores into cannot be pinned, so {name} is \ + not vendored (set a literal NuGetLockFilePath, or remove it)" + ), + )); + } + let mut locks: Vec = Vec::with_capacity(governed.locks.len()); + for rel in governed.locks { + let path = project_root.join(&rel); + match read_regular_to_string(&path).await { + Ok(text) => locks.push(LockFile { rel, path, text }), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => { + return Err(refused( + "vendor_nuget_lock_unreadable", + format!("unreadable {}: {e}", path.display()), + )); + } + } + } // The idempotent hot path's test (see `vendor_nuget`): a live // `` source under our key. A commented-out one — or the @@ -220,13 +243,16 @@ async fn nuget_prelude( // only the patched one: a framework that locks another version could no // longer restore (NU1102). Refused before anything is wired (#593). if !config_wired { - if let Some(Ok(doc)) = lock_text.as_deref().map(lock_value) { + for lock in &locks { + let Ok(doc) = lock_value(&lock.text) else { + continue; + }; let others = crate::formats::nuget::lock::other_versions(&doc, name, &version_norm); if !others.is_empty() { return Err(refused( "vendor_nuget_lock_other_version", crate::formats::nuget::lock::other_versions_detail( - PACKAGES_LOCK, + &lock.rel, name, &version_norm, &others, @@ -244,17 +270,22 @@ async fn nuget_prelude( .is_some_and(|bytes| zip_bytes_match_after_hashes(bytes, &record.files)); // Only worth computing when the artifact itself is in sync (a stale // nupkg rebuilds regardless of what the lock pins). - let lock_ok = match (&lock_text, &nupkg_bytes) { - (None, _) => true, - (Some(text), Some(bytes)) if nupkg_ok => { + let lock_ok = match &nupkg_bytes { + _ if locks.is_empty() => true, + Some(bytes) if nupkg_ok => { let expected = sha512_base64_of(bytes); // Pinned at our bytes, or no matching resolved entry at // all — the same absence `edit_lock` tolerates with a // warning on the first run. Treating absence as stale // would misreport "missing or stale; rebuilt" on every // rerun with nothing to actually pin. - lock_pinned(text, name, &version_norm, &expected) - || matches!(edit_lock(text, name, &version_norm, &expected), Ok(None)) + locks.iter().all(|lock| { + lock_pinned(&lock.text, name, &version_norm, &expected) + || matches!( + edit_lock(&lock.text, name, &version_norm, &expected), + Ok(None) + ) + }) } _ => false, }; @@ -271,8 +302,7 @@ async fn nuget_prelude( source_key, config_path, config_text, - lock_path, - lock_text, + locks, config_wired, in_sync, }) @@ -331,8 +361,7 @@ pub async fn vendor_nuget( source_key, config_path, config_text, - lock_path, - lock_text, + locks, config_wired, in_sync, } = match nuget_prelude(purl, project_root, record).await { @@ -395,32 +424,32 @@ pub async fn vendor_nuget( // pre-vendor contentHash from the entry being replaced and // re-attaches the untouched config records. let mut wiring: Vec = Vec::new(); - if let Some(text) = &lock_text { - let new_hash = sha512_base64_of(&bytes); - match edit_lock(text, name, &version_norm, &new_hash) { + let new_hash = sha512_base64_of(&bytes); + for lock in &locks { + match edit_lock(&lock.text, name, &version_norm, &new_hash) { Ok(Some(edit)) => { LOCK_VALUE_MEMO.invalidate(); if let Err(e) = - atomic_write_bytes_preserving_mode(&lock_path, edit.text.as_bytes()) + atomic_write_bytes_preserving_mode(&lock.path, edit.text.as_bytes()) .await { result.success = false; - result.error = Some(format!("failed to rewrite {PACKAGES_LOCK}: {e}")); + result.error = Some(format!("failed to rewrite {}: {e}", lock.rel)); return done(result, None, warnings); } wiring.push(WiringRecord { - file: PACKAGES_LOCK.to_string(), + file: lock.rel.clone(), kind: LOCK_WIRING_KIND.to_string(), action: WiringAction::Rewritten, key: Some(name.to_string()), original: None, - new: Some(Value::String(new_hash)), + new: Some(Value::String(new_hash.clone())), }); } Ok(None) => {} Err(detail) => { result.success = false; - result.error = Some(detail); + result.error = Some(format!("{}: {detail}", lock.rel)); return done(result, None, warnings); } } @@ -518,21 +547,26 @@ pub async fn vendor_nuget( } // ── packages.lock.json pinning (a failure here unwinds the config) ──── - let mut lock_record: Option = None; - if let Some(text) = &lock_text { - match edit_lock(text, name, &version_norm, &new_hash) { + let mut lock_records: Vec = Vec::new(); + // The locks already re-pinned, with their pre-vendor text, so a later + // failure puts every one of them back with the config. + let mut written: Vec<(&LockFile, &str)> = Vec::new(); + for lock in &locks { + match edit_lock(&lock.text, name, &version_norm, &new_hash) { Ok(Some(edit)) => { LOCK_VALUE_MEMO.invalidate(); if let Err(e) = - atomic_write_bytes_preserving_mode(&lock_path, edit.text.as_bytes()).await + atomic_write_bytes_preserving_mode(&lock.path, edit.text.as_bytes()).await { + unwind_locks(&written).await; unwind_config(&config_target, config_text.as_deref(), &uuid_dir).await; result.success = false; - result.error = Some(format!("failed to write {PACKAGES_LOCK}: {e}")); + result.error = Some(format!("failed to write {}: {e}", lock.rel)); return done(result, None, warnings); } - lock_record = Some(WiringRecord { - file: PACKAGES_LOCK.to_string(), + written.push((lock, lock.text.as_str())); + lock_records.push(WiringRecord { + file: lock.rel.clone(), kind: LOCK_WIRING_KIND.to_string(), action: WiringAction::Rewritten, key: Some(name.to_string()), @@ -547,24 +581,28 @@ pub async fn vendor_nuget( warnings.push(VendorWarning::new( "vendor_nuget_lock_entry_absent", format!( - "{PACKAGES_LOCK} has no resolved entry for {name} {version_norm}; the \ - vendored feed still serves it but its contentHash is not pinned" + "{} has no resolved entry for {name} {version_norm}; the vendored feed \ + still serves it but its contentHash is not pinned there", + lock.rel ), )); } Err(detail) => { + unwind_locks(&written).await; unwind_config(&config_target, config_text.as_deref(), &uuid_dir).await; result.success = false; - result.error = Some(detail); + result.error = Some(format!("{}: {detail}", lock.rel)); return done(result, None, warnings); } } - } else { + } + if locks.is_empty() { warnings.push(VendorWarning::new( "vendor_nuget_no_lockfile", format!( - "no {PACKAGES_LOCK} (RestorePackagesWithLockFile is off); the vendored feed \ - forces {name} from the patched copy but its contentHash is not pinned" + "no project under the root restores into a {PACKAGES_LOCK} (or a \ + packages..lock.json); the vendored feed serves {name} from the patched \ + copy but its contentHash is not pinned" ), )); } @@ -608,9 +646,7 @@ pub async fn vendor_nuget( // Application order: config source, config mapping, then the lock pin. // Revert runs them in reverse (lock → mapping → source). let mut wiring = vec![source_record, mapping_record]; - if let Some(rec) = lock_record { - wiring.push(rec); - } + wiring.extend(lock_records); let entry = nuget_entry(base_purl, record, copy_rel, &nupkg_bytes, wiring); @@ -701,9 +737,15 @@ pub async fn revert_nuget_opts( // record, then the authoritative config restore. for w in entry.wiring.iter().rev() { let restored = match w.kind.as_str() { - LOCK_WIRING_KIND => { - revert_lock_record(&project_root.join(PACKAGES_LOCK), w, dry_run).await - } + // SECURITY: state.json is committed and tamper-able; the lock + // path is joined under the root and written through, so only a + // plain relative path is accepted (a `../`, an absolute path + // would make the restore an arbitrary file write). + LOCK_WIRING_KIND if !is_safe_multi_segment(&w.file) => Err(format!( + "refusing revert: unsafe wiring file path {:?}", + w.file + )), + LOCK_WIRING_KIND => revert_lock_record(&project_root.join(&w.file), w, dry_run).await, // Audit-only: the whole-file config restore lives on the source // record, so there is nothing to undo here. CONFIG_MAPPING_WIRING_KIND => Ok(true), @@ -1275,6 +1317,21 @@ async fn revert_lock_record( Ok(true) } +/// One project lock: its root-relative path, absolute path and text. +struct LockFile { + rel: String, + path: PathBuf, + text: String, +} + +/// Put back the locks a failed vendor already re-pinned. +async fn unwind_locks(written: &[(&LockFile, &str)]) { + for (lock, original) in written { + let _ = atomic_write_bytes_preserving_mode(&lock.path, original.as_bytes()).await; + } + LOCK_VALUE_MEMO.invalidate(); +} + /// Restore the config to its pre-vendor state (or delete a created file) after /// a later wiring step failed, then remove the partial uuid dir. async fn unwind_config(config_target: &Path, original: Option<&str>, uuid_dir: &Path) { @@ -1980,6 +2037,155 @@ mod tests { assert!(!root.join(".socket").exists()); } + /// #353: a solution layout keeps each project's lock beside it. The + /// root nuget.config routes every project, so the member lock is pinned + /// (and recorded under its own path) and revert restores it. + #[tokio::test] + async fn member_project_lock_is_pinned_and_reverted() { + let (dir, blobs, installed, record) = fixture(false, None).await; + let root = dir.path(); + let app = root.join("src/App"); + tokio::fs::create_dir_all(&app).await.unwrap(); + tokio::fs::write( + app.join("App.csproj"), + "", + ) + .await + .unwrap(); + let lock = lock_json("ORIGINALcachedhash=="); + tokio::fs::write(app.join(PACKAGES_LOCK), &lock) + .await + .unwrap(); + // Build output is never walked. + tokio::fs::create_dir_all(app.join("obj")).await.unwrap(); + tokio::fs::write(app.join("obj/Stray.csproj"), "") + .await + .unwrap(); + + let (result, entry, warnings) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); + assert!(result.success, "{:?}", result.error); + assert!( + !warnings + .iter() + .any(|w| w.code == "vendor_nuget_no_lockfile"), + "{warnings:?}" + ); + let nupkg = tokio::fs::read(root.join(copy_rel())).await.unwrap(); + let pinned = tokio::fs::read_to_string(app.join(PACKAGES_LOCK)) + .await + .unwrap(); + assert_eq!( + pinned, + lock.replace("ORIGINALcachedhash==", &sha512_base64_of(&nupkg)) + ); + let entry = entry.unwrap(); + let files: Vec<&str> = entry + .wiring + .iter() + .filter(|w| w.kind == LOCK_WIRING_KIND) + .map(|w| w.file.as_str()) + .collect(); + assert_eq!(files, ["src/App/packages.lock.json"]); + + // The re-run is the in-sync hot path. + let (_r, rerun_entry, _w) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); + assert!( + rerun_entry.is_none(), + "already vendored: nothing re-recorded" + ); + + let reverted = revert_nuget(&entry, root, false).await; + assert!(reverted.success, "{:?}", reverted.error); + assert_eq!( + tokio::fs::read_to_string(app.join(PACKAGES_LOCK)) + .await + .unwrap(), + lock + ); + } + + /// #514: `packages..lock.json` is the lock NuGet reads when it + /// exists; it is pinned (the plain name beside it is not NuGet's). + #[tokio::test] + async fn named_project_lock_is_pinned() { + let (dir, blobs, installed, record) = fixture(false, None).await; + let root = dir.path(); + tokio::fs::write(root.join("app.csproj"), "") + .await + .unwrap(); + let lock = lock_json("ORIGINALcachedhash=="); + tokio::fs::write(root.join("packages.app.lock.json"), &lock) + .await + .unwrap(); + let (result, entry, warnings) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); + assert!(result.success, "{:?}", result.error); + assert!( + !warnings + .iter() + .any(|w| w.code == "vendor_nuget_no_lockfile"), + "{warnings:?}" + ); + let pinned = tokio::fs::read_to_string(root.join("packages.app.lock.json")) + .await + .unwrap(); + assert!(!pinned.contains("ORIGINALcachedhash=="), "{pinned}"); + let entry = entry.unwrap(); + assert!(entry + .wiring + .iter() + .any(|w| w.kind == LOCK_WIRING_KIND && w.file == "packages.app.lock.json")); + } + + /// #514: a `NuGetLockFilePath` this reader cannot evaluate is refused + /// before anything is written, rather than left on its upstream hash. + #[tokio::test] + async fn unresolvable_lock_file_path_is_refused() { + let (dir, blobs, installed, record) = fixture(false, None).await; + let root = dir.path(); + tokio::fs::write( + root.join("app.csproj"), + "$(BaseDir)app.lock.json", + ) + .await + .unwrap(); + let (code, detail) = + unwrap_refused(run_vendor(root, &blobs, &installed, &record, false).await); + assert_eq!(code, "vendor_nuget_lock_path_unresolved"); + assert!(detail.contains("app.csproj"), "{detail}"); + assert!(!root.join("nuget.config").exists()); + assert!(!root.join(".socket").exists()); + } + + /// A tampered lock record naming a path outside the root is refused. + #[tokio::test] + async fn revert_refuses_an_unsafe_lock_path() { + let dir = tempfile::tempdir().unwrap(); + let entry = entry_with_wiring( + UUID, + vec![WiringRecord { + file: "../outside/packages.lock.json".to_string(), + kind: LOCK_WIRING_KIND.to_string(), + action: WiringAction::Rewritten, + key: Some("Newtonsoft.Json".to_string()), + original: Some(Value::String("A==".to_string())), + new: Some(Value::String("B==".to_string())), + }], + ); + let outcome = revert_nuget(&entry, dir.path(), false).await; + assert!(!outcome.success); + assert!( + outcome + .error + .as_deref() + .is_some_and(|e| e.contains("unsafe wiring file path")), + "{:?}", + outcome.error + ); + } + /// #623: dotnet restores a BOM'd lock, so vendor pins it (the BOM kept) /// and revert restores it byte-identically. #[tokio::test] diff --git a/crates/socket-patch-core/src/vex/discover/nuget.rs b/crates/socket-patch-core/src/vex/discover/nuget.rs index 6a01a1fed..a04d67a8b 100644 --- a/crates/socket-patch-core/src/vex/discover/nuget.rs +++ b/crates/socket-patch-core/src/vex/discover/nuget.rs @@ -57,12 +57,16 @@ //! refuses is [`DIAG_LOCKFILE_UNPARSEABLE`]); a source listed in //! `` with `value="true"` wires nothing (diagnosed). //! +//! The lock is every lock the root config governs: the root +//! `packages.lock.json` plus, on disk, each lock a project under the root +//! restores into (member projects, `packages..lock.json`, a literal +//! `NuGetLockFilePath`; [`crate::formats::nuget::lock::governed_locks`], the +//! discovery both writers pin through). +//! //! Non-goals (documented, not guessed): parent-directory / user-level -//! configs and per-project locks below the root (the redirect rewriter only -//! edits the root pair too); `` inheritance semantics (neither -//! writer emits one); a non-Socket source that ALSO maps the exact id (the -//! lock's `contentHash` is what makes such a restore fail); custom -//! `NuGetLockFilePath` names. +//! configs; `` inheritance semantics (neither writer emits one); a +//! non-Socket source that ALSO maps the exact id (the lock's `contentHash` +//! is what makes such a restore fail). use std::collections::{BTreeMap, BTreeSet}; @@ -281,42 +285,63 @@ enum Lock { Parsed(BTreeMap>), } +/// Every lock the root config governs: the root `packages.lock.json`, and on +/// disk each lock a project under the root restores into (#353, #514; the +/// writers pin all of them). Their entries are merged: one version's +/// `contentHash` must agree across them, as within one lock. async fn load_lock(ctx: &DiscoverCtx<'_>, out: &mut Discovery) -> Lock { - if !ctx.exists(PACKAGES_LOCK).await { - return Lock::Absent; + let mut rels = vec![PACKAGES_LOCK.to_string()]; + if let Some(root) = ctx.disk_root() { + if let Ok(governed) = crate::vendor::nuget_config::governed_locks_on_disk(root) { + for rel in governed.locks { + if !rels.contains(&rel) { + rels.push(rel); + } + } + } } - let Some(bytes) = ctx.read_bytes(PACKAGES_LOCK, out).await else { - return Lock::Unusable; - }; - let doc: Value = match parse_json(PACKAGES_LOCK, &bytes) { - Ok(Value::Object(doc)) => Value::Object(doc), - Ok(_) => { - out.diag( - DIAG_LOCKFILE_UNPARSEABLE, - PACKAGES_LOCK, - format!("{PACKAGES_LOCK} is not a JSON object"), - ); - return Lock::Unusable; + let mut index: BTreeMap> = BTreeMap::new(); + let mut any = false; + for rel in &rels { + if !ctx.exists(rel).await { + continue; } - Err(detail) => { - out.diag(DIAG_LOCKFILE_UNPARSEABLE, PACKAGES_LOCK, detail); + any = true; + let Some(bytes) = ctx.read_bytes(rel, out).await else { return Lock::Unusable; - } - }; - let mut index: BTreeMap> = BTreeMap::new(); - for entry in nuget_lock_entries(&doc) { - let pins = index - .entry(entry.id.to_ascii_lowercase()) - .or_default() - .entry(entry.resolved.trim().to_string()) - .or_default(); - match entry.content_hash { - Some(hash) if !hash.trim().is_empty() => { - pins.hashes.insert(hash.trim().to_string()); + }; + let doc: Value = match parse_json(rel, &bytes) { + Ok(Value::Object(doc)) => Value::Object(doc), + Ok(_) => { + out.diag( + DIAG_LOCKFILE_UNPARSEABLE, + rel, + format!("{rel} is not a JSON object"), + ); + return Lock::Unusable; + } + Err(detail) => { + out.diag(DIAG_LOCKFILE_UNPARSEABLE, rel, detail); + return Lock::Unusable; + } + }; + for entry in nuget_lock_entries(&doc) { + let pins = index + .entry(entry.id.to_ascii_lowercase()) + .or_default() + .entry(entry.resolved.trim().to_string()) + .or_default(); + match entry.content_hash { + Some(hash) if !hash.trim().is_empty() => { + pins.hashes.insert(hash.trim().to_string()); + } + _ => pins.unpinned = true, } - _ => pins.unpinned = true, } } + if !any { + return Lock::Absent; + } Lock::Parsed(index) } @@ -1384,6 +1409,38 @@ mod tests { assert_eq!(diag_codes(&out), vec![DIAG_REF_INVALID]); } + /// #353 / #514: with no root lock, the version and pin come from the + /// member-project (or named) lock the root config governs. + #[tokio::test] + async fn hosted_version_and_pin_come_from_a_member_lock() { + let cfg = config( + &[(&socket_key(UUID_A), &index_url(UUID_A))], + &[(&socket_key(UUID_A), "Newtonsoft.Json")], + ); + for (project, lock_rel) in [ + ("src/App/App.csproj", "src/App/packages.lock.json"), + ("app.csproj", "packages.app.lock.json"), + ] { + let p = Project::new(); + p.write("nuget.config", &cfg); + p.write(project, ""); + p.write( + lock_rel, + lock(&[("net8.0", "Newtonsoft.Json", "13.0.1", Some(HASH))]), + ); + let out = run(&p).await; + assert_refs( + &out, + &[( + "pkg:nuget/newtonsoft.json@13.0.1", + UUID_A, + WiringMode::Hosted, + )], + ); + assert!(out.refs[0].lockfile_basis_ok(), "{lock_rel}"); + } + } + /// Two Socket sources mapping the same id are both emitted — precedence /// is the CLI's `wiring_conflict` gate, not the extractor's. #[tokio::test] From d99550f3734b33e8dcc0a9d583117a934953716e Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 14:42:23 -0400 Subject: [PATCH 07/10] Carry NuGet lock walk in a synthetic key The hosted engine put every project file's text into the candidate files so the rewriter could re-derive the locks; the confirmation probe then searched that text too, and nuget/rescan regressed 15% in the scan benchmark. The engine now hands the rewriter the walk's answer (lock paths, an unevaluable NuGetLockFilePath, or why the tree could not be listed) through a synthetic key instead. Review fixes (Bugbot on #1340): the stale-artifact rebuild puts back the locks it already re-pinned when a later one fails; VEX reports a tree whose locks it cannot all locate instead of reading the root lock alone; the project walk fails closed on a project or directory name that is not UTF-8 and on an unreadable entry, and reads a symlinked project file. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-core/src/hosted/engine.rs | 32 ++--- .../src/patch/redirect/mod.rs | 132 +++++++++--------- .../src/vendor/nuget_config.rs | 63 ++++++++- .../src/vendor/nuget_feed.rs | 7 + .../src/vex/discover/nuget.rs | 52 ++++++- 5 files changed, 195 insertions(+), 91 deletions(-) diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 746f4a616..66bda66f1 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -633,32 +633,32 @@ pub async fn read_candidate_files( } // NuGet: the root config routes every project under the root, so each - // project's lock is pinned with it (#353, #514). The project files ride - // along as advisory input (never rewritten) so the pure rewriter - // re-derives which keys are locks; a walk that cannot see the whole - // tree rides a synthetic key and the rewriter skips the redirect. - // NuGet is disk-only (the in-memory engine refuses it). + // project's lock is pinned with it (#353, #514). The walk's answer rides + // a synthetic key (the lock paths, an unevaluable NuGetLockFilePath, or + // why the tree could not be listed) and every lock is read. The project + // files themselves stay out of the candidate texts. NuGet is disk-only + // (the in-memory engine refuses it). if candidates.iter().any(|c| c.dep.ecosystem == "nuget") { if let Some(root) = view.disk_root() { - match crate::vendor::nuget_config::project_files(root) { - Ok(projects) => { - let governed = crate::formats::nuget::lock::governed_locks(&projects, |rel| { - crate::vendor::nuget_config::lock_present(root, rel) - }); - for (rel, text) in projects { - out.files.entry(rel).or_insert(text); + let mut lines: Vec = Vec::new(); + match crate::vendor::nuget_config::governed_locks_on_disk(root) { + Ok(governed) => { + for (project, detail) in &governed.unresolved { + lines.push(format!("unresolved\t{project}\t{detail}")); } for rel in governed.locks { if !out.files.contains_key(&rel) { out.read(view, unreadable, &rel).await; } + lines.push(format!("lock\t{rel}")); } } - Err(why) => { - out.files - .insert(crate::patch::redirect::NUGET_LOCK_WALK_KEY.to_string(), why); - } + Err(why) => lines.push(format!("error\t{why}")), } + out.files.insert( + crate::patch::redirect::NUGET_LOCKS_KEY.to_string(), + lines.join("\n"), + ); } } diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 98995721f..ca8e04943 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -5760,11 +5760,13 @@ fn nuget_xml_attribute(value: &str) -> String { .replace('\r', " ") } -/// The synthetic candidate key carrying why the engine could not list the -/// project's NuGet locks (an unreadable directory or project file): the -/// rewriter then skips the nuget redirect rather than leave a lock it never -/// saw on its upstream hash. Never a path (see [`sbt::SYNTHETIC_KEY_PREFIX`]). -pub const NUGET_LOCK_WALK_KEY: &str = ""; +/// The synthetic candidate key carrying the locks the engine found every +/// project under the root restoring into (#353, #514), one per line: +/// `lock\t`, `unresolved\t\t` for a `NuGetLockFilePath` +/// it cannot evaluate, or `error\t` when it could not list the tree. +/// Absent (the in-memory engine, unit tests), the root `packages.lock.json` +/// is the one lock. Never a path (see [`sbt::SYNTHETIC_KEY_PREFIX`]). +pub const NUGET_LOCKS_KEY: &str = ""; fn rewrite_nuget( files: &BTreeMap, @@ -5808,41 +5810,45 @@ fn rewrite_nuget( // routes them all, so each is pinned with it (#353, #514). The engine // reads the project files and their locks; the same pure discovery // re-derives which keys are locks here. - if let Some(why) = files.get(NUGET_LOCK_WALK_KEY) { - result.warnings.push(RewriteWarning { - code: "redirect_nuget_lock_unreadable".into(), - detail: format!( - "cannot list the project's NuGet locks ({why}); nuget redirect skipped" - ), - }); - return; - } - let projects: Vec<(String, String)> = files - .iter() - .filter(|(rel, _)| { - !sbt::is_synthetic_key(rel) - && crate::formats::nuget::lock::is_project_file( - rel.rsplit('/').next().unwrap_or(rel), - ) - }) - .map(|(rel, text)| (rel.clone(), text.clone())) - .collect(); - let governed = - crate::formats::nuget::lock::governed_locks(&projects, |rel| files.contains_key(rel)); - if let Some((project, detail)) = governed.unresolved.first() { - result.warnings.push(RewriteWarning { - code: "redirect_nuget_lock_path_unresolved".into(), - detail: format!( - "{project}: {detail}; the lock it restores into cannot be pinned, so nuget \ - redirect is skipped (set a literal NuGetLockFilePath, or remove it)" - ), - }); - return; - } + let lock_rels: Vec = match files.get(NUGET_LOCKS_KEY) { + None => vec![crate::formats::nuget::lock::PACKAGES_LOCK.to_string()], + Some(found) => { + let mut rels = Vec::new(); + for line in found.lines() { + let mut fields = line.splitn(3, '\t'); + match (fields.next(), fields.next(), fields.next()) { + (Some("lock"), Some(rel), None) => rels.push(rel.to_string()), + (Some("unresolved"), Some(project), Some(detail)) => { + result.warnings.push(RewriteWarning { + code: "redirect_nuget_lock_path_unresolved".into(), + detail: format!( + "{project}: {detail}; the lock it restores into cannot be \ + pinned, so nuget redirect is skipped (set a literal \ + NuGetLockFilePath, or remove it)" + ), + }); + return; + } + (Some("error"), Some(why), _) => { + result.warnings.push(RewriteWarning { + code: "redirect_nuget_lock_unreadable".into(), + detail: format!( + "cannot list the project's NuGet locks ({why}); nuget redirect \ + skipped" + ), + }); + return; + } + _ => {} + } + } + rels + } + }; // Read past a UTF-8 BOM the way dotnet does (#623); the write below // keeps it. let mut locks: Vec<(String, &String, Value, bool)> = Vec::new(); - for rel in governed.locks { + for rel in lock_rels { let Some(text) = files.get(&rel) else { continue; }; @@ -22644,21 +22650,24 @@ packages: } /// #353 / #514: member-project locks and named locks the root config - /// governs are re-pinned under their own paths with the config. + /// governs (the engine's walk, carried by [`NUGET_LOCKS_KEY`]) are + /// re-pinned under their own paths with the config. #[test] fn nuget_member_and_named_locks_are_repinned() { let mut files = BTreeMap::new(); files.insert("nuget.config".to_string(), default_nuget_config()); - files.insert("src/App/App.csproj".to_string(), "".to_string()); files.insert( "src/App/packages.lock.json".to_string(), simple_lock("ORIGINALHASH=="), ); - files.insert("src/Lib/Lib.csproj".to_string(), "".to_string()); files.insert( "src/Lib/packages.Lib.lock.json".to_string(), simple_lock("ORIGINALHASH=="), ); + files.insert( + NUGET_LOCKS_KEY.to_string(), + "lock\tsrc/App/packages.lock.json\nlock\tsrc/Lib/packages.Lib.lock.json".to_string(), + ); let r = rewrite_registry_redirect(&files, &[nuget_override()]); assert!(r.warnings.is_empty(), "{:?}", r.warnings); for rel in [ @@ -22671,7 +22680,6 @@ packages: "{rel}" ); } - assert!(!r.files.contains_key("src/App/App.csproj")); let lock_paths: Vec<&str> = r .edits .iter() @@ -22685,35 +22693,31 @@ packages: "src/Lib/packages.Lib.lock.json" ] ); + // Without the walk (in memory, unit tests) only the root lock is one: + // a member lock is never mistaken for one. + files.remove(NUGET_LOCKS_KEY); + let r = rewrite_registry_redirect(&files, &[nuget_override()]); + assert!(!r.files.contains_key("src/App/packages.lock.json")); } /// #514: an unresolvable `NuGetLockFilePath`, or a project tree the /// engine could not list, skips the nuget redirect with nothing written. #[test] fn nuget_unknowable_locks_skip_the_redirect() { - let mut files = BTreeMap::new(); - files.insert("nuget.config".to_string(), default_nuget_config()); - files.insert( - "app.csproj".to_string(), - "$(X)/l.json" - .to_string(), - ); - let r = rewrite_registry_redirect(&files, &[nuget_override()]); - assert!(r.files.is_empty() && r.edits.is_empty(), "{:?}", r.files); - assert_eq!( - warning_codes(&r), - vec!["redirect_nuget_lock_path_unresolved"] - ); - - let mut files = BTreeMap::new(); - files.insert("nuget.config".to_string(), default_nuget_config()); - files.insert( - NUGET_LOCK_WALK_KEY.to_string(), - "unreadable src".to_string(), - ); - let r = rewrite_registry_redirect(&files, &[nuget_override()]); - assert!(r.files.is_empty() && r.edits.is_empty(), "{:?}", r.files); - assert_eq!(warning_codes(&r), vec!["redirect_nuget_lock_unreadable"]); + for (walk, code) in [ + ( + "lock\tpackages.lock.json\nunresolved\tapp.csproj\tNuGetLockFilePath `$(X)/l.json` references MSBuild properties or items", + "redirect_nuget_lock_path_unresolved", + ), + ("error\tunreadable src", "redirect_nuget_lock_unreadable"), + ] { + let mut files = BTreeMap::new(); + files.insert("nuget.config".to_string(), default_nuget_config()); + files.insert(NUGET_LOCKS_KEY.to_string(), walk.to_string()); + let r = rewrite_registry_redirect(&files, &[nuget_override()]); + assert!(r.files.is_empty() && r.edits.is_empty(), "{:?}", r.files); + assert_eq!(warning_codes(&r), vec![code]); + } } /// #593: a multi-targeting lock resolving the patched id at another diff --git a/crates/socket-patch-core/src/vendor/nuget_config.rs b/crates/socket-patch-core/src/vendor/nuget_config.rs index c626dbe97..8085abfe1 100644 --- a/crates/socket-patch-core/src/vendor/nuget_config.rs +++ b/crates/socket-patch-core/src/vendor/nuget_config.rs @@ -86,7 +86,19 @@ pub(crate) fn project_files(root: &std::path::Path) -> Result Result MAX_PROJECT_BYTES) { return Err(format!("{} is too large to read", path.display())); @@ -167,4 +186,38 @@ mod tests { std::fs::create_dir(&dir).unwrap(); assert_eq!(same_file(&dir, &dir).await, cfg!(unix)); } + + /// The walk reads a symlinked project file, never enters build output + /// or hidden dirs, and finds projects at any depth. + #[cfg(unix)] + #[test] + fn project_walk_follows_project_links_and_skips_output() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + for dir in ["src/App/obj", "src/Lib", ".git", "shared"] { + std::fs::create_dir_all(root.join(dir)).unwrap(); + } + std::fs::write(root.join("src/App/App.csproj"), "").unwrap(); + std::fs::write(root.join("src/App/obj/Gen.csproj"), "").unwrap(); + std::fs::write(root.join(".git/X.csproj"), "").unwrap(); + std::fs::write(root.join("shared/Lib.csproj"), "lib").unwrap(); + std::os::unix::fs::symlink( + root.join("shared/Lib.csproj"), + root.join("src/Lib/Lib.csproj"), + ) + .unwrap(); + let found: Vec = super::project_files(root) + .unwrap() + .into_iter() + .map(|(rel, _)| rel) + .collect(); + assert_eq!( + found, + [ + "shared/Lib.csproj", + "src/App/App.csproj", + "src/Lib/Lib.csproj" + ] + ); + } } diff --git a/crates/socket-patch-core/src/vendor/nuget_feed.rs b/crates/socket-patch-core/src/vendor/nuget_feed.rs index 146f4c582..fc5ed1e69 100644 --- a/crates/socket-patch-core/src/vendor/nuget_feed.rs +++ b/crates/socket-patch-core/src/vendor/nuget_feed.rs @@ -425,6 +425,10 @@ pub async fn vendor_nuget( // re-attaches the untouched config records. let mut wiring: Vec = Vec::new(); let new_hash = sha512_base64_of(&bytes); + // Locks already re-pinned, put back if a later one fails: the + // projects must agree on one nupkg (the rebuilt artifact stays, + // the config routes to it). + let mut written: Vec<(&LockFile, &str)> = Vec::new(); for lock in &locks { match edit_lock(&lock.text, name, &version_norm, &new_hash) { Ok(Some(edit)) => { @@ -433,10 +437,12 @@ pub async fn vendor_nuget( atomic_write_bytes_preserving_mode(&lock.path, edit.text.as_bytes()) .await { + unwind_locks(&written).await; result.success = false; result.error = Some(format!("failed to rewrite {}: {e}", lock.rel)); return done(result, None, warnings); } + written.push((lock, lock.text.as_str())); wiring.push(WiringRecord { file: lock.rel.clone(), kind: LOCK_WIRING_KIND.to_string(), @@ -448,6 +454,7 @@ pub async fn vendor_nuget( } Ok(None) => {} Err(detail) => { + unwind_locks(&written).await; result.success = false; result.error = Some(format!("{}: {detail}", lock.rel)); return done(result, None, warnings); diff --git a/crates/socket-patch-core/src/vex/discover/nuget.rs b/crates/socket-patch-core/src/vex/discover/nuget.rs index a04d67a8b..b39528276 100644 --- a/crates/socket-patch-core/src/vex/discover/nuget.rs +++ b/crates/socket-patch-core/src/vex/discover/nuget.rs @@ -74,8 +74,8 @@ use serde_json::Value; use super::{ parse_json, simple_purl, socket_patch_name_uuid, vendor_ref, vendor_uuid_dir, DiscoverCtx, - Discovery, PatchedRef, UnlockedPin, WiringMode, DIAG_LOCKFILE_UNPARSEABLE, DIAG_REF_INVALID, - DIAG_REF_UNATTRIBUTABLE, + Discovery, PatchedRef, UnlockedPin, WiringMode, DIAG_LOCKFILE_UNPARSEABLE, + DIAG_LOCKFILE_UNREADABLE, DIAG_REF_INVALID, DIAG_REF_UNATTRIBUTABLE, }; use crate::formats::nuget::lock::nuget_lock_entries; use crate::formats::nuget::{parse_config, NugetConfig}; @@ -292,11 +292,32 @@ enum Lock { async fn load_lock(ctx: &DiscoverCtx<'_>, out: &mut Discovery) -> Lock { let mut rels = vec![PACKAGES_LOCK.to_string()]; if let Some(root) = ctx.disk_root() { - if let Ok(governed) = crate::vendor::nuget_config::governed_locks_on_disk(root) { - for rel in governed.locks { - if !rels.contains(&rel) { - rels.push(rel); + // The writers refuse a tree whose locks they cannot all find; a + // reader that fell back to the root lock alone would take a pinned + // member lock for no lock at all, so it is unusable here too. + match crate::vendor::nuget_config::governed_locks_on_disk(root) { + Ok(governed) => { + if let Some((project, detail)) = governed.unresolved.first() { + out.diag( + DIAG_LOCKFILE_UNREADABLE, + project, + format!("{project}: {detail}; its NuGet lock cannot be located"), + ); + return Lock::Unusable; } + for rel in governed.locks { + if !rels.contains(&rel) { + rels.push(rel); + } + } + } + Err(why) => { + out.diag( + DIAG_LOCKFILE_UNREADABLE, + PACKAGES_LOCK, + format!("cannot list the project's NuGet locks: {why}"), + ); + return Lock::Unusable; } } } @@ -1439,6 +1460,25 @@ mod tests { ); assert!(out.refs[0].lockfile_basis_ok(), "{lock_rel}"); } + // A project whose lock cannot be located: no ref is read off a + // partial lock set (the writers refuse such a tree too). + let p = Project::new(); + p.write("nuget.config", &cfg); + p.write( + "app.csproj", + "$(X).json", + ); + p.write( + "packages.lock.json", + lock(&[("net8.0", "Newtonsoft.Json", "13.0.1", Some(HASH))]), + ); + let out = run(&p).await; + assert_refs(&out, &[]); + assert!( + diag_codes(&out).contains(&DIAG_LOCKFILE_UNREADABLE), + "{:?}", + diag_codes(&out) + ); } /// Two Socket sources mapping the same id are both emitted — precedence From 473881ab09a124238c36896a78d7a205d0210228 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 15:26:25 -0400 Subject: [PATCH 08/10] Walk NuGet projects through the project view The hosted engine and VEX discovery asked the project view for its raw disk root to walk the project files. On a re-scan that ends the read cache's recording, so the discovery it guards was redone and nuget/rescan regressed about 14% in the scan benchmark. The walk now lists, reads and probes through the view (governed_locks_in), which fingerprints them like its own reads. Local perf compare against main: nuget/rescan +1.6% (noise), nuget/hosted +3.6%. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-core/src/hosted/engine.rs | 4 +- .../src/vendor/nuget_config.rs | 66 +++++++++++++++++++ .../src/vex/discover/nuget.rs | 9 ++- 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 66bda66f1..fad616e60 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -639,9 +639,9 @@ pub async fn read_candidate_files( // files themselves stay out of the candidate texts. NuGet is disk-only // (the in-memory engine refuses it). if candidates.iter().any(|c| c.dep.ecosystem == "nuget") { - if let Some(root) = view.disk_root() { + if !matches!(view, ProjectView::Memory(_)) { let mut lines: Vec = Vec::new(); - match crate::vendor::nuget_config::governed_locks_on_disk(root) { + match crate::vendor::nuget_config::governed_locks_in(view).await { Ok(governed) => { for (project, detail) in &governed.unresolved { lines.push(format!("unresolved\t{project}\t{detail}")); diff --git a/crates/socket-patch-core/src/vendor/nuget_config.rs b/crates/socket-patch-core/src/vendor/nuget_config.rs index 8085abfe1..fcd17923d 100644 --- a/crates/socket-patch-core/src/vendor/nuget_config.rs +++ b/crates/socket-patch-core/src/vendor/nuget_config.rs @@ -147,6 +147,72 @@ pub(crate) fn governed_locks_on_disk( )) } +/// [`governed_locks_on_disk`] through a [`ProjectView`]: every listing, read +/// and probe goes through the view, so a recording [`DiskSnapshot`] read +/// cache fingerprints them like its own reads (asking it for its raw root +/// would end its recording, and with it the re-scan's reuse of the +/// discovery it guards). Same walk rules as [`project_files`]. +/// +/// [`ProjectView`]: crate::vendor::lock_inventory::ProjectView +/// [`DiskSnapshot`]: crate::vendor::lock_inventory::DiskSnapshot +pub(crate) async fn governed_locks_in( + view: &crate::vendor::lock_inventory::ProjectView<'_>, +) -> Result { + use crate::formats::nuget::lock::{governed_locks, is_project_file}; + let mut projects: Vec<(String, String)> = Vec::new(); + let mut pending = vec![String::new()]; + let mut listed = 0usize; + while let Some(rel) = pending.pop() { + listed += 1; + if listed > WALK_DIR_BUDGET { + return Err(format!( + "more than {WALK_DIR_BUDGET} directories under the project root" + )); + } + let entries = view.list_dir(&rel).await.map_err(|e| { + format!( + "unreadable {}: {e}", + if rel.is_empty() { "." } else { &rel } + ) + })?; + for entry in entries { + let child = if rel.is_empty() { + entry.name.clone() + } else { + format!("{rel}/{}", entry.name) + }; + if entry.is_dir { + if !entry.name.starts_with('.') && !SKIPPED_DIRS.contains(&entry.name.as_str()) { + pending.push(child); + } + } else if is_project_file(&entry.name) { + let text = view + .read_text(&child) + .await + .map_err(|e| format!("unreadable {child}: {e}"))?; + if text.len() as u64 > MAX_PROJECT_BYTES { + return Err(format!("{child} is too large to read")); + } + projects.push((child, text)); + } + } + } + projects.sort(); + // Which lock paths the discovery asks about, then their answers. + let asked = std::cell::RefCell::new(Vec::::new()); + governed_locks(&projects, |rel| { + asked.borrow_mut().push(rel.to_string()); + false + }); + let mut present = std::collections::BTreeSet::new(); + for rel in asked.into_inner() { + if !present.contains(&rel) && view.exists_no_follow(&rel).await { + present.insert(rel); + } + } + Ok(governed_locks(&projects, |rel| present.contains(rel))) +} + /// Whether something other than a directory sits at `root/rel` (`lstat`): /// a FIFO or link under a lock name is then read, and refused, by the /// FIFO-safe reader rather than taken for an absent lock. diff --git a/crates/socket-patch-core/src/vex/discover/nuget.rs b/crates/socket-patch-core/src/vex/discover/nuget.rs index b39528276..584b727bb 100644 --- a/crates/socket-patch-core/src/vex/discover/nuget.rs +++ b/crates/socket-patch-core/src/vex/discover/nuget.rs @@ -291,11 +291,16 @@ enum Lock { /// `contentHash` must agree across them, as within one lock. async fn load_lock(ctx: &DiscoverCtx<'_>, out: &mut Discovery) -> Lock { let mut rels = vec![PACKAGES_LOCK.to_string()]; - if let Some(root) = ctx.disk_root() { + // On disk only (the in-memory engine refuses NuGet). Walked through the + // view, never its raw root: a re-scan's read cache keeps recording. + if !matches!( + ctx.view, + crate::vendor::lock_inventory::ProjectView::Memory(_) + ) { // The writers refuse a tree whose locks they cannot all find; a // reader that fell back to the root lock alone would take a pinned // member lock for no lock at all, so it is unusable here too. - match crate::vendor::nuget_config::governed_locks_on_disk(root) { + match crate::vendor::nuget_config::governed_locks_in(&ctx.view).await { Ok(governed) => { if let Some((project, detail)) = governed.unresolved.first() { out.diag( From 0ddeb5acc3b7eb47312b1065e1084a1cc025452b Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 16:12:06 -0400 Subject: [PATCH 09/10] Collapse the NuGet walk condition for clippy Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-core/src/hosted/engine.rs | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index fad616e60..efbf266bb 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -638,28 +638,28 @@ pub async fn read_candidate_files( // why the tree could not be listed) and every lock is read. The project // files themselves stay out of the candidate texts. NuGet is disk-only // (the in-memory engine refuses it). - if candidates.iter().any(|c| c.dep.ecosystem == "nuget") { - if !matches!(view, ProjectView::Memory(_)) { - let mut lines: Vec = Vec::new(); - match crate::vendor::nuget_config::governed_locks_in(view).await { - Ok(governed) => { - for (project, detail) in &governed.unresolved { - lines.push(format!("unresolved\t{project}\t{detail}")); - } - for rel in governed.locks { - if !out.files.contains_key(&rel) { - out.read(view, unreadable, &rel).await; - } - lines.push(format!("lock\t{rel}")); + if candidates.iter().any(|c| c.dep.ecosystem == "nuget") + && !matches!(view, ProjectView::Memory(_)) + { + let mut lines: Vec = Vec::new(); + match crate::vendor::nuget_config::governed_locks_in(view).await { + Ok(governed) => { + for (project, detail) in &governed.unresolved { + lines.push(format!("unresolved\t{project}\t{detail}")); + } + for rel in governed.locks { + if !out.files.contains_key(&rel) { + out.read(view, unreadable, &rel).await; } + lines.push(format!("lock\t{rel}")); } - Err(why) => lines.push(format!("error\t{why}")), } - out.files.insert( - crate::patch::redirect::NUGET_LOCKS_KEY.to_string(), - lines.join("\n"), - ); + Err(why) => lines.push(format!("error\t{why}")), } + out.files.insert( + crate::patch::redirect::NUGET_LOCKS_KEY.to_string(), + lines.join("\n"), + ); } for path in view.python_lock_paths() { From 4d04d476e032c87c5df4f75172a0155e1c03bbcf Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Sat, 10 Oct 2026 10:09:54 -0400 Subject: [PATCH 10/10] Fail the NuGet view walk closed on names and listings it cannot see governed_locks_in walked through ProjectView::list_dir, which skips non-UTF-8 names, file_type() errors and stops quietly on a mid-listing read error, while the disk walk (project_files) fails closed on all of them. A member project hidden that way dropped out of the governed-lock set for the hosted engine and VEX discovery. Add a strict listing to the view and use it for the NuGet project walk. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/vendor/lock_inventory/view.rs | 39 ++++++++++++++++--- .../src/vendor/nuget_config.rs | 20 +++++++++- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/view.rs b/crates/socket-patch-core/src/vendor/lock_inventory/view.rs index 44addc7a4..8723561c1 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/view.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/view.rs @@ -625,14 +625,32 @@ impl<'a> DiskSnapshot<'a> { } /// The UTF-8-named entries of directory `dir` on disk, sorted by name. -async fn list_disk_dir(dir: &Path) -> io::Result> { +/// `strict` fails the listing instead of skipping what it cannot see: a +/// non-UTF-8 name, a `file_type()` error, or a read error mid-listing. +async fn list_disk_dir(dir: &Path, strict: bool) -> io::Result> { let mut read = tokio::fs::read_dir(dir).await?; let mut out = Vec::new(); - while let Ok(Some(entry)) = read.next_entry().await { + loop { + let entry = match read.next_entry().await { + Ok(Some(entry)) => entry, + Ok(None) => break, + Err(e) if strict => return Err(e), + Err(_) => break, + }; let Some(name) = entry.file_name().to_str().map(str::to_string) else { + if strict { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("non-UTF-8 name {:?}", entry.file_name()), + )); + } continue; }; - let is_dir = entry.file_type().await.is_ok_and(|t| t.is_dir()); + let is_dir = match entry.file_type().await { + Ok(t) => t.is_dir(), + Err(e) if strict => return Err(e), + Err(_) => false, + }; out.push(DirEntryInfo { name, is_dir }); } out.sort_by(|a, b| a.name.cmp(&b.name)); @@ -882,14 +900,25 @@ impl<'a> ProjectView<'a> { /// The UTF-8-named entries of directory `rel`, sorted by name. pub async fn list_dir(&self, rel: &str) -> io::Result> { + self.list_dir_with(rel, false).await + } + + /// [`Self::list_dir`] for a walk that must see the whole tree: a + /// non-UTF-8 name, an unreadable entry type or a read error mid-listing + /// fails the listing instead of being skipped. + pub async fn list_dir_strict(&self, rel: &str) -> io::Result> { + self.list_dir_with(rel, true).await + } + + async fn list_dir_with(&self, rel: &str, strict: bool) -> io::Result> { if let ProjectView::Snapshot(snap) = self { snap.touch_listing(rel); } match self { - ProjectView::Disk(root) => list_disk_dir(&root.join(rel)).await, + ProjectView::Disk(root) => list_disk_dir(&root.join(rel), strict).await, ProjectView::Snapshot(snap) => { let created = snap.overlaid_children(rel); - let mut out = match list_disk_dir(&snap.root.join(rel)).await { + let mut out = match list_disk_dir(&snap.root.join(rel), strict).await { Ok(out) => out, Err(e) if e.kind() == io::ErrorKind::NotFound && !created.is_empty() => { Vec::new() diff --git a/crates/socket-patch-core/src/vendor/nuget_config.rs b/crates/socket-patch-core/src/vendor/nuget_config.rs index fcd17923d..747ea219a 100644 --- a/crates/socket-patch-core/src/vendor/nuget_config.rs +++ b/crates/socket-patch-core/src/vendor/nuget_config.rs @@ -169,7 +169,7 @@ pub(crate) async fn governed_locks_in( "more than {WALK_DIR_BUDGET} directories under the project root" )); } - let entries = view.list_dir(&rel).await.map_err(|e| { + let entries = view.list_dir_strict(&rel).await.map_err(|e| { format!( "unreadable {}: {e}", if rel.is_empty() { "." } else { &rel } @@ -286,4 +286,22 @@ mod tests { ] ); } + + /// The view walk fails closed on a directory name it cannot spell, like + /// the disk walk: a member project under it would otherwise drop out of + /// the governed locks. (Linux: APFS refuses non-UTF-8 names.) + #[cfg(target_os = "linux")] + #[tokio::test] + async fn view_walk_fails_closed_on_a_non_utf8_dir() { + use std::os::unix::ffi::OsStrExt; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let odd = root.join(std::ffi::OsStr::from_bytes(b"m\xffember")); + std::fs::create_dir_all(&odd).unwrap(); + std::fs::write(odd.join("M.csproj"), "").unwrap(); + std::fs::write(root.join("App.csproj"), "").unwrap(); + assert!(super::project_files(root).is_err()); + let view = crate::vendor::lock_inventory::ProjectView::Disk(root); + assert!(super::governed_locks_in(&view).await.is_err()); + } }