From 92630055336f74d898c59a1e2124f7cd907e171f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 15:00:36 +0000 Subject: [PATCH 1/9] 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 2/9] 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 c3070d28a02fa340bd244a8ad8c80df194740401 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 12:45:55 -0400 Subject: [PATCH 3/9] Start NuGet fix: nuget-mapping Draft placeholder while the fix is written. Co-Authored-By: Claude Opus 5.5 (1M context) From e5e3c5a4af9c337551c7292f528041c0b0871bad Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 14:13:36 -0400 Subject: [PATCH 4/9] Keep NuGet mappings exclusive and inherit-safe When vendored or hosted mode created a packageSourceMapping, its `*` catch-all named only the sources of the one nuget.config it edited. NuGet merges the user config and every parent directory's config, and once a mapping exists it drops every source no pattern names, so private feeds defined outside the project failed NU1101 (#354). A fresh config also re-added nuget.org a parent had cleared for a mirror. The catch-all now also names the sources NuGet inherits (user config, then parent directories, honoring ), and nuget.org is only seeded when those configs have it. Hosted gets them from the engine through a synthetic candidate key; the in-memory engine keeps the file-only reading. When another source already mapped the patched id exactly (Visual Studio's mapping UI writes such lists), the two tied and NuGet took the package from whichever feed answered first: NU1403 or silently unpatched (#462). That pattern is now commented out in a marker naming the Socket source while the patch is wired, and vendor --revert, remove and rollback put it back byte-exact. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/CLI_CONTRACT.md | 4 +- .../src/formats/nuget/mod.rs | 209 +++++++++++++ crates/socket-patch-core/src/hosted/engine.rs | 14 + .../src/patch/redirect/mod.rs | 184 ++++++++++- .../src/patch/redirect/upstream/nuget.rs | 45 ++- .../src/vendor/nuget_config.rs | 123 ++++++++ .../src/vendor/nuget_feed.rs | 287 ++++++++++++++++-- 7 files changed, 818 insertions(+), 48 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index bb4592f4b..bbf0878ce 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), `redirect_nuget_mapping_set_aside` (v5.0 #462: another source's `packageSourceMapping` also named the patched id exactly — Visual Studio's mapping UI writes such lists — which ties with the Socket source so NuGet would take the package from whichever feed answers first; that pattern, or its whole `` when it was the only one, is commented out in a `` comment while the patch is wired, and `remove` / `rollback` put it back byte-exact), `redirect_nuget_mapping_conflict` (such a pattern sits in markup that cannot go in a comment; the dep is skipped, nothing written). v5.0 #354: when the rewriter creates the `packageSourceMapping`, its `*` catch-all also names the sources NuGet merges in from the user config and every parent directory's config (honoring ``), since NuGet drops every source no pattern names; a fresh config only seeds `nuget.org` when those inherited configs have it (a parent that cleared it for a mirror keeps that choice), `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, including the ones NuGet inherits from the user config and parent directories' configs (v5.0 #354) — mapping is exclusive, NU1100 otherwise; another source's exact pattern for the id is set aside in a comment while vendored, `vendor_nuget_mapping_set_aside`, #462) **+** `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) | | 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/mod.rs b/crates/socket-patch-core/src/formats/nuget/mod.rs index 1499a27d2..1afc742ed 100644 --- a/crates/socket-patch-core/src/formats/nuget/mod.rs +++ b/crates/socket-patch-core/src/formats/nuget/mod.rs @@ -29,6 +29,14 @@ pub(crate) struct NugetConfig { pub(crate) mappings: Vec<(String, Vec)>, /// Keys `configuration/disabledPackageSources` turns off. pub(crate) disabled: BTreeSet, + /// `configuration/packageSources` holds a ``: the sources every + /// farther config (parent directories, the user config) defined are + /// dropped, and only the ones after it count. + pub(crate) sources_cleared: bool, + /// Where each `mappings` row sits (parallel to it): the whole + /// `` element and each of its pattern tags, so a writer can + /// set one aside and put it back byte-exact. + pub(crate) mapping_spans: Vec, /// Live XML locations for writers. Routing readers and writers share /// the same treatment of comments, quoted attributes and element scope. pub(crate) configuration: Option, @@ -39,6 +47,15 @@ pub(crate) struct NugetConfig { pub(crate) repeated_sections: bool, } +/// The byte ranges of one `packageSourceMapping/packageSource` element. +#[derive(Debug, Clone)] +pub(crate) struct MappingSpan { + /// The whole element, open tag through close tag. + pub(crate) element: Range, + /// Each `` tag, parallel to the row's patterns. + pub(crate) patterns: Vec>, +} + #[derive(Debug)] pub(crate) struct ConfigSection { pub(crate) open: Range, @@ -62,6 +79,11 @@ fn section_mut<'a>( } fn record_clear(cfg: &mut NugetConfig, parents: &[&str], end: usize) { + if parents == ["configuration", "packageSources"] { + cfg.sources_cleared = true; + // NuGet drops what the file itself defined before the ``. + cfg.sources.clear(); + } let section = match parents { ["configuration", "packageSources"] => cfg.package_sources.as_mut(), ["configuration", "packageSourceMapping"] => cfg.source_mapping.as_mut(), @@ -95,6 +117,8 @@ pub(crate) fn parse_config(text: &str) -> Option { let mut stack: Vec<&str> = Vec::new(); // Index into `cfg.mappings` of the open `` element. let mut open_mapping: Option = None; + // Index into `cfg.mapping_spans` of the open `` element. + let mut open_span: Option = None; let mut i = 0; while let Some(rel) = text[i..].find('<') { let at = i + rel; @@ -121,6 +145,11 @@ pub(crate) fn parse_config(text: &str) -> Option { if name == "clear" { record_clear(&mut cfg, &stack, i); } + if name == "packageSource" && stack[..] == ["configuration", "packageSourceMapping"] { + if let Some(idx) = open_span.take() { + cfg.mapping_spans[idx].element.end = i; + } + } } else { let (tag, consumed) = parse_open_tag(&rest[1..])?; i = at + 1 + consumed; @@ -137,7 +166,22 @@ pub(crate) fn parse_config(text: &str) -> Option { if tag.name == "clear" && tag.self_closing { record_clear(&mut cfg, &stack, i); } + let (rows, patterns) = ( + cfg.mappings.len(), + open_mapping.map(|idx| cfg.mappings[idx].1.len()), + ); visit(&stack, &tag, &mut cfg, &mut open_mapping); + if cfg.mappings.len() > rows { + cfg.mapping_spans.push(MappingSpan { + element: at..i, + patterns: Vec::new(), + }); + open_span = (!tag.self_closing).then(|| cfg.mapping_spans.len() - 1); + } else if let (Some(idx), Some(before)) = (open_mapping, patterns) { + if cfg.mappings[idx].1.len() > before { + cfg.mapping_spans[idx].patterns.push(at..i); + } + } if !tag.self_closing { if stack.len() >= MAX_XML_DEPTH { return None; @@ -291,6 +335,112 @@ fn decode_entities(raw: &str) -> String { out } +/// The package source keys NuGet merges from `chain`, farthest config first +/// (the user config, then each parent directory down to the nearest): a +/// config's `` drops every source a farther one defined. Keys keep +/// their first-seen order; a nearer redefinition keeps its place. +pub(crate) fn effective_source_keys<'a>( + chain: impl IntoIterator, +) -> Vec { + let mut keys: Vec = Vec::new(); + for cfg in chain { + if cfg.sources_cleared { + keys.clear(); + } + for (key, _) in &cfg.sources { + if !keys.contains(key) { + keys.push(key.clone()); + } + } + } + keys +} + +/// The comment a writer sets a competing mapping element aside in while the +/// Socket source `key` is wired: ``. +fn set_aside_open(key: &str) -> String { + format!(""; + +/// Set aside every OTHER source's exact pattern for `id` (#462). +/// +/// NuGet routes a package by its most specific pattern, and an exact id is +/// as specific as it gets: when another source also names `id` exactly +/// (Visual Studio's mapping UI writes such lists), the two tie and NuGet +/// takes the package from whichever answers first — the patched bytes or +/// the upstream ones. So while the Socket source `key` is wired, each such +/// pattern is commented out where it stands (the whole `` +/// when it was its only pattern: NuGet rejects an element with none), in a +/// comment naming `key` that [`restore_set_aside`] turns back into the +/// original bytes. Other Socket sources are left alone (their own wiring). +/// +/// `Ok((text, keys))` with the sources set aside (empty: nothing competed); +/// `Err` when an element cannot be put in a comment (it holds `--`). +pub(crate) fn set_aside_competing_patterns( + text: &str, + cfg: &NugetConfig, + key: &str, + id: &str, +) -> Result<(String, Vec), String> { + let mut cuts: Vec> = Vec::new(); + let mut keys: Vec = Vec::new(); + for ((source, patterns), span) in cfg.mappings.iter().zip(&cfg.mapping_spans) { + if source == key || source.starts_with("socket-patch-") { + continue; + } + let hits: Vec = (0..patterns.len()) + .filter(|&j| patterns[j].eq_ignore_ascii_case(id)) + .collect(); + if hits.is_empty() { + continue; + } + if hits.len() == patterns.len() { + cuts.push(span.element.clone()); + } else { + cuts.extend(hits.iter().map(|&j| span.patterns[j].clone())); + } + if !keys.contains(source) { + keys.push(source.clone()); + } + } + cuts.sort_by_key(|r| std::cmp::Reverse(r.start)); + let mut out = text.to_string(); + for cut in cuts { + let inner = &text[cut.clone()]; + if inner.contains("--") { + return Err(format!( + "nuget.config maps {id} to {} too, in markup that cannot be set aside in a comment", + keys.join(", ") + )); + } + out.replace_range( + cut, + &format!("{}{inner}{SET_ASIDE_CLOSE}", set_aside_open(key)), + ); + } + Ok((out, keys)) +} + +/// Undo [`set_aside_competing_patterns`] for `key`: every comment it wrote +/// becomes the original markup again, byte for byte. +pub(crate) fn restore_set_aside(text: &str, key: &str) -> String { + let open = set_aside_open(key); + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while let Some(at) = rest.find(&open) { + let after = &rest[at + open.len()..]; + let Some(end) = after.find(SET_ASIDE_CLOSE) else { + break; + }; + out.push_str(&rest[..at]); + out.push_str(&after[..end]); + rest = &after[end + SET_ASIDE_CLOSE.len()..]; + } + out.push_str(rest); + 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. @@ -313,4 +463,63 @@ mod tests { assert_eq!(super::decode_entities("&bogus;&"), "&bogus;&"); assert_eq!(super::decode_entities("�"), "�"); } + + #[test] + fn clear_drops_earlier_and_farther_sources() { + let cfg = super::parse_config( + "", + ) + .unwrap(); + assert!(cfg.sources_cleared); + assert_eq!(cfg.sources, [("new".to_string(), "y".to_string())]); + let parent = super::parse_config( + "", + ) + .unwrap(); + let plain = super::parse_config( + "", + ) + .unwrap(); + assert_eq!(super::effective_source_keys([&parent, &plain]), ["a", "b"]); + assert_eq!(super::effective_source_keys([&parent, &cfg]), ["new"]); + } + + const COMPETING: &str = "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n"; + + /// #462: another source's exact pattern for the id is set aside (the + /// whole element when it is its only pattern) and restored byte-exact. + #[test] + fn competing_exact_patterns_are_set_aside_and_restored() { + let cfg = super::parse_config(COMPETING).unwrap(); + assert_eq!(cfg.mapping_spans.len(), cfg.mappings.len()); + let (out, keys) = super::set_aside_competing_patterns( + COMPETING, + &cfg, + "socket-patch-u", + "NEWTONSOFT.JSON", + ) + .unwrap(); + assert_eq!(keys, ["nuget.org", "corp"]); + let after = super::parse_config(&out).unwrap(); + let exact: Vec<&str> = after + .mappings + .iter() + .filter(|(_, p)| p.iter().any(|p| p.eq_ignore_ascii_case("newtonsoft.json"))) + .map(|(k, _)| k.as_str()) + .collect(); + assert_eq!(exact, ["socket-patch-u"], "{out}"); + assert!(after + .mappings + .iter() + .any(|(k, p)| k == "nuget.org" && p == &["*"])); + assert_eq!(super::restore_set_aside(&out, "socket-patch-u"), COMPETING); + // Idempotent: nothing left to set aside. + let (again, keys) = + super::set_aside_competing_patterns(&out, &after, "socket-patch-u", "Newtonsoft.Json") + .unwrap(); + assert!(keys.is_empty()); + assert_eq!(again, out); + // Another key's markers are not ours to restore. + assert_eq!(super::restore_set_aside(&out, "socket-patch-v"), out); + } } diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index e1f023d25..9463b1709 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -632,6 +632,20 @@ pub async fn read_candidate_files( } } + // NuGet merges the user config and every parent directory's config + // under the project's own: a catch-all mapping the rewriter creates + // must name their sources too (#354). Disk only (the in-memory engine + // refuses NuGet, and could not see them). + if candidates.iter().any(|c| c.dep.ecosystem == "nuget") { + if let Some(root) = view.disk_root() { + let keys = crate::vendor::nuget_config::inherited_source_keys(root).await; + out.files.insert( + crate::patch::redirect::NUGET_INHERITED_SOURCES_KEY.to_string(), + keys.join("\n"), + ); + } + } + 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 7f007a03f..cea2485a2 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -5655,6 +5655,7 @@ const NUGET_ORG_URL: &str = "https://api.nuget.org/v3/index.json"; fn add_nuget_source( config: &str, parsed: &crate::formats::nuget::NugetConfig, + inherited: Option<&[String]>, reg: &str, index_url: &str, pkg_id: &str, @@ -5662,11 +5663,30 @@ fn add_nuget_source( // The same source identities restore and VEX read, before Socket is added. let mut pre_existing_keys: Vec<&str> = parsed.sources.iter().map(|(key, _)| key.as_str()).collect(); + let own_sources = !pre_existing_keys.is_empty(); + // The sources NuGet merges in from the configs below this one (#354): + // once a mapping exists every source no pattern names is dropped, so a + // created catch-all must name them too — unless this file ``s + // them. `None` (the in-memory engine, which cannot see them) keeps the + // file-only reading. + let inherited: &[String] = match inherited { + Some(keys) if !parsed.sources_cleared => keys, + _ => &[], + }; + for key in inherited { + if !pre_existing_keys.contains(&key.as_str()) { + pre_existing_keys.push(key); + } + } let creating_mapping = parsed .source_mapping .as_ref() .is_none_or(|section| section.close_start.is_none()); - let seed_nuget_org = creating_mapping && pre_existing_keys.is_empty(); + // nuget.org is only seeded when the inherited configs have it (or + // nothing): a parent that cleared it for a mirror keeps that choice. + let seed_nuget_org = creating_mapping + && !own_sources + && (inherited.is_empty() || inherited.iter().any(|k| k == NUGET_ORG_KEY)); let reg = nuget_xml_attribute(reg); let mut source_lines = format!( " ", @@ -5678,7 +5698,9 @@ fn add_nuget_source( source_lines.push_str(&format!( "\n " )); - pre_existing_keys.push(NUGET_ORG_KEY); + if !pre_existing_keys.contains(&NUGET_ORG_KEY) { + pre_existing_keys.push(NUGET_ORG_KEY); + } } let out = if let Some(section) = &parsed.package_sources { insert_nuget_children(config, section, "packageSources", &source_lines) @@ -5759,6 +5781,16 @@ fn nuget_xml_attribute(value: &str) -> String { .replace('\r', " ") } +/// The synthetic candidate key carrying the package source keys the configs +/// below the project's own NuGet merges in (the user config, parent +/// directories), one per line ([`crate::vendor::nuget_config::inherited_source_keys`]). +/// Never a path (see [`sbt::SYNTHETIC_KEY_PREFIX`]). +pub const NUGET_INHERITED_SOURCES_KEY: &str = ""; + +/// A config with no sources, the base of a fresh one when the inherited +/// configs dropped nuget.org. +const EMPTY_NUGET_CONFIG: &str = "\n\n \n \n\n"; + fn rewrite_nuget( files: &BTreeMap, overrides: &[DepOverride], @@ -5777,10 +5809,21 @@ fn rewrite_nuget( .into_iter() .find(|name| files.contains_key(*name)) .unwrap_or(NUGET_CONFIG_FILE_NAMES[0]); - let mut config = files - .get(config_path) - .cloned() - .unwrap_or_else(default_nuget_config); + // The source keys the configs below this one define (the engine reads + // them from disk; absent in memory). + let inherited: Option> = files + .get(NUGET_INHERITED_SOURCES_KEY) + .map(|keys| keys.lines().map(str::to_string).collect()); + let mut config = files.get(config_path).cloned().unwrap_or_else(|| { + match &inherited { + // A parent cleared nuget.org (a mirror instead): a fresh config + // must not add it back (#354). + Some(keys) if !keys.is_empty() && !keys.iter().any(|k| k == NUGET_ORG_KEY) => { + EMPTY_NUGET_CONFIG.to_string() + } + _ => default_nuget_config(), + } + }); // A config this run authors from scratch records its source edits as // `added` — the spelling every other rewriter uses for a created file. let source_action = if files.contains_key(config_path) { @@ -5855,19 +5898,65 @@ fn rewrite_nuget( result.warnings.push(unwritable()); continue; }; - if !parsed.sources.iter().any(|(key, _)| key == ®) { + let wired = parsed.sources.iter().any(|(key, _)| key == ®); + let base = if wired { + config.clone() + } else { // A failed insert skips the WHOLE dep (no edit record, no lock // re-pin): a mapping without its source routes the patched id to // a source that was never defined, and a lock pinned at the // patched contentHash over an upstream fetch fails NU1403 — both // while the ledger would claim the redirect landed. - let Some(updated) = add_nuget_source(&config, &parsed, ®, &ov.index_url, &dep.name) - else { + let Some(updated) = add_nuget_source( + &config, + &parsed, + inherited.as_deref(), + ®, + &ov.index_url, + &dep.name, + ) else { result.warnings.push(unwritable()); continue; }; - config = updated; + updated + }; + // Another source naming the id exactly ties with ours, and NuGet + // takes the package from whichever answers first (#462): set that + // pattern aside while the patch is wired (the upstream restore puts + // it back), or skip the dep when it cannot be. + let Some(based) = crate::formats::nuget::parse_config(&base) else { + result.warnings.push(unwritable()); + continue; + }; + let (aside, moved) = match crate::formats::nuget::set_aside_competing_patterns( + &base, &based, ®, &dep.name, + ) { + Ok(done) => done, + Err(why) => { + result.warnings.push(RewriteWarning { + code: "redirect_nuget_mapping_conflict".into(), + detail: format!("{why}; {} not redirected", dep.name), + }); + continue; + } + }; + if !moved.is_empty() { + result.warnings.push(RewriteWarning { + code: "redirect_nuget_mapping_set_aside".into(), + detail: format!( + "{config_path} also mapped {} to {}; that pattern is commented out while the \ + patch is wired, so the Socket source alone serves it (remove / rollback \ + restore it)", + dep.name, + moved.join(", ") + ), + }); + } + if aside != config { + config = aside; config_changed = true; + } + if !wired { result.edits.push(FileEdit { path: config_path.into(), kind: "redirect_nuget_source".into(), @@ -22532,6 +22621,81 @@ packages: assert!(r.warnings.is_empty(), "{:?}", r.warnings); } + /// #462: the issue's config (an exact `Newtonsoft.Json` pattern under + /// nuget.org beside `*`): hosted sets that pattern aside so the Socket + /// source alone routes the id, and says so. + #[test] + fn nuget_competing_exact_pattern_is_set_aside() { + let config = "\n \n \n \n \n \n \n \n \n \n \n\n"; + let mut files = BTreeMap::new(); + files.insert("nuget.config".to_string(), config.to_string()); + let r = rewrite_registry_redirect(&files, &[nuget_override()]); + assert_eq!(warning_codes(&r), vec!["redirect_nuget_mapping_set_aside"]); + let out = &r.files["nuget.config"]; + let parsed = crate::formats::nuget::parse_config(out).unwrap(); + let exact: Vec<&str> = parsed + .mappings + .iter() + .filter(|(_, p)| p.iter().any(|p| p.eq_ignore_ascii_case("newtonsoft.json"))) + .map(|(k, _)| k.as_str()) + .collect(); + assert_eq!(exact, ["socket-patch-uuid"], "{out}"); + // The re-run over its own output is a no-op. + let mut again = BTreeMap::new(); + again.insert("nuget.config".to_string(), out.clone()); + let r = rewrite_registry_redirect(&again, &[nuget_override()]); + assert!( + r.files.is_empty() && r.warnings.is_empty(), + "{:?}", + r.warnings + ); + } + + fn nuget_catch_all(config: &str) -> Vec { + crate::formats::nuget::parse_config(config) + .unwrap() + .mappings + .into_iter() + .filter(|(_, p)| p == &["*"]) + .map(|(k, _)| k) + .collect() + } + + /// #354: on disk the engine hands the rewriter the source keys NuGet + /// inherits; a created catch-all names them too, and a fresh config + /// does not re-add nuget.org a parent cleared. + #[test] + fn nuget_created_catch_all_names_inherited_sources() { + let mut files = BTreeMap::new(); + files.insert("nuget.config".to_string(), default_nuget_config()); + files.insert( + NUGET_INHERITED_SOURCES_KEY.to_string(), + "nuget.org\ncorp".to_string(), + ); + let r = rewrite_registry_redirect(&files, &[nuget_override()]); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert_eq!( + nuget_catch_all(&r.files["nuget.config"]), + ["nuget.org", "corp"] + ); + + let mut files = BTreeMap::new(); + files.insert( + NUGET_INHERITED_SOURCES_KEY.to_string(), + "mirror".to_string(), + ); + let r = rewrite_registry_redirect(&files, &[nuget_override()]); + let config = &r.files["nuget.config"]; + assert_eq!(nuget_catch_all(config), ["mirror"], "{config}"); + assert!(!config.contains("nuget.org"), "{config}"); + + // Without the key (the in-memory engine) the file alone decides. + let mut files = BTreeMap::new(); + files.insert("nuget.config".to_string(), default_nuget_config()); + let r = rewrite_registry_redirect(&files, &[nuget_override()]); + assert_eq!(nuget_catch_all(&r.files["nuget.config"]), ["nuget.org"]); + } + /// A PRESENT but unparseable packages.lock.json refuses the whole nuget /// redirect up front: landing the source + mapping while the lock kept /// the upstream contentHash would NU1403 every restore, with the ledger 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..ad548c338 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/nuget.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/nuget.rs @@ -105,7 +105,9 @@ fn remove_source(config: &str, uuid: &str, id: &str, ctx: &Ctx<'_>) -> Result` that no longer routes anything: empty, -/// or a `*` fan-out for exactly every remaining source. +/// or a `*` fan-out naming every remaining source of the file (and, as the +/// writers author it on disk, the sources inherited from the user and +/// parent configs, #354). fn drop_fanout_mapping(config: &str) -> String { let Some(cfg) = parse_config(config) else { return config.to_string(); @@ -118,7 +120,8 @@ fn drop_fanout_mapping(config: &str) -> String { .mappings .iter() .all(|(_, patterns)| matches!(&patterns[..], [p] if p == "*")); - if !(cfg.mappings.is_empty() || (fanout_only && mapped == sources)) { + let covers_sources = sources.iter().all(|s| mapped.contains(s)); + if !(cfg.mappings.is_empty() || (fanout_only && covers_sources)) { return config.to_string(); } let re = Regex::new(r"(?s).*?") @@ -232,7 +235,11 @@ pub(crate) async fn restore( result.refuse(&pin.uuid, format!("{} is not a NuGet purl", pin.purl)); continue; }; - match remove_source(&text, &pin.uuid, &id, ctx) { + // Put back the patterns the rewriter set aside first: their + // comments name the Socket source (#462). + let key = crate::patch::redirect::generation::hosted_pin_name(&pin.uuid); + let unaside = crate::formats::nuget::restore_set_aside(&text, &key); + match remove_source(&unaside, &pin.uuid, &id, ctx) { Ok(next) => { text = next; restored.push((pin, id, version)); @@ -324,7 +331,8 @@ pub(crate) async fn restore( { continue; } - if text == super::super::default_nuget_config() { + if text == super::super::default_nuget_config() || text == super::super::EMPTY_NUGET_CONFIG + { result.warnings.push(( "nuget_default_config_left", format!( @@ -441,6 +449,7 @@ mod tests { super::super::super::add_nuget_source( config, &parse_config(config).unwrap(), + None, &format!("socket-patch-{UUID}"), &index_url(), "Newtonsoft.Json", @@ -519,6 +528,34 @@ mod tests { assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); } + /// #462: the pattern the rewriter set aside comes back byte-exact. + #[tokio::test] + #[serial_test::serial] + async fn a_set_aside_pattern_is_restored() { + let user = USER_MAPPING.replace( + " \n", + " \n \n", + ); + let hosted = hosted_config(&user); + let key = format!("socket-patch-{UUID}"); + let (aside, moved) = crate::formats::nuget::set_aside_competing_patterns( + &hosted, + &parse_config(&hosted).unwrap(), + &key, + "Newtonsoft.Json", + ) + .unwrap(); + assert_eq!(moved, ["nuget.org"]); + let (outcome, config, _) = run(&aside, false).await; + assert_eq!( + outcome.pins[0].status, + PinStatus::Restored, + "{:?}", + outcome.pins + ); + assert_eq!(config, user); + } + #[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..11c06f79c 100644 --- a/crates/socket-patch-core/src/vendor/nuget_config.rs +++ b/crates/socket-patch-core/src/vendor/nuget_config.rs @@ -47,6 +47,88 @@ fn regular_file(path: &std::path::Path) -> bool { std::fs::symlink_metadata(path).is_ok_and(|meta| meta.file_type().is_file()) } +// ── inherited sources ── + +/// The user-level `NuGet.Config` NuGet merges under every project config: +/// `%APPDATA%\\NuGet\\NuGet.Config` on Windows, else +/// `/.nuget/NuGet/NuGet.Config`, where home is `DOTNET_CLI_HOME` +/// when set (the dotnet CLI's override) and `HOME` otherwise. +fn user_config_path() -> Option { + #[cfg(test)] + { + // Unit tests never read the developer's own user config. + tests_support::USER_CONFIG.with(|c| c.borrow().clone()) + } + #[cfg(not(test))] + { + let var = |k: &str| std::env::var_os(k).filter(|v| !v.is_empty()); + if cfg!(windows) { + return var("APPDATA").map(|d| { + std::path::PathBuf::from(d) + .join("NuGet") + .join("NuGet.Config") + }); + } + var("DOTNET_CLI_HOME") + .or_else(|| var("HOME")) + .map(|h| std::path::PathBuf::from(h).join(".nuget/NuGet/NuGet.Config")) + } +} + +#[cfg(test)] +pub(crate) mod tests_support { + thread_local! { + /// The user config [`super::user_config_path`] answers in unit + /// tests (`None`: none, so NuGet's implicit default). + pub(crate) static USER_CONFIG: std::cell::RefCell> = + const { std::cell::RefCell::new(None) }; + } +} + +/// The package source keys every config NuGet merges BELOW the one in +/// `project_root` defines (#354): the user config (absent, NuGet's implicit +/// default — the `nuget.org` source it writes on first run), then each +/// parent directory's config from the filesystem root down, each `` +/// dropping the farther ones. A file that cannot be read or parsed is +/// skipped: it contributes nothing NuGet could use either. +pub(crate) async fn inherited_source_keys(project_root: &std::path::Path) -> Vec { + use crate::formats::nuget::{effective_source_keys, parse_config, NugetConfig}; + let mut chain: Vec = Vec::new(); + let user = match user_config_path() { + Some(path) => crate::utils::fs::read_regular_to_string(&path) + .await + .ok() + .and_then(|t| parse_config(crate::formats::text::strip_bom(&t))), + None => None, + }; + chain.push(user.unwrap_or_else(|| NugetConfig { + sources: vec![( + "nuget.org".to_string(), + "https://api.nuget.org/v3/index.json".to_string(), + )], + ..NugetConfig::default() + })); + let mut ancestors: Vec<&std::path::Path> = project_root.ancestors().skip(1).collect(); + ancestors.reverse(); + for dir in ancestors { + for name in CONFIG_NAMES { + let path = dir.join(name); + if !crate::utils::fs::file_exists(&path).await { + continue; + } + if let Some(cfg) = crate::utils::fs::read_regular_to_string(&path) + .await + .ok() + .and_then(|t| parse_config(crate::formats::text::strip_bom(&t))) + { + chain.push(cfg); + } + break; + } + } + effective_source_keys(&chain) +} + #[cfg(test)] mod tests { use super::same_file; @@ -79,4 +161,45 @@ mod tests { std::fs::create_dir(&dir).unwrap(); assert_eq!(same_file(&dir, &dir).await, cfg!(unix)); } + + /// #354: the user config, then each parent directory's config from the + /// root down; a `` drops the farther ones. + #[tokio::test] + async fn inherited_sources_follow_nugets_merge_order() { + use super::{inherited_source_keys, tests_support::USER_CONFIG}; + let tmp = tempfile::tempdir().unwrap(); + // Not an ancestor of the project: the user config is only read as one. + std::fs::create_dir_all(tmp.path().join("home")).unwrap(); + let user = tmp.path().join("home/NuGet.Config"); + std::fs::write( + &user, + "\u{feff}", + ) + .unwrap(); + let project = tmp.path().join("repo/app"); + std::fs::create_dir_all(&project).unwrap(); + USER_CONFIG.with(|c| *c.borrow_mut() = Some(user.clone())); + assert_eq!(inherited_source_keys(&project).await, ["nuget.org", "corp"]); + // A repo-root config adds its own feed. + std::fs::write( + tmp.path().join("repo/NuGet.Config"), + "", + ) + .unwrap(); + assert_eq!( + inherited_source_keys(&project).await, + ["nuget.org", "corp", "team"] + ); + // ...or clears the user's for a mirror. + std::fs::write( + tmp.path().join("repo/NuGet.Config"), + "", + ) + .unwrap(); + assert_eq!(inherited_source_keys(&project).await, ["mirror"]); + // No user config at all: NuGet's implicit default. + USER_CONFIG.with(|c| *c.borrow_mut() = None); + std::fs::remove_file(tmp.path().join("repo/NuGet.Config")).unwrap(); + assert_eq!(inherited_source_keys(&project).await, ["nuget.org"]); + } } diff --git a/crates/socket-patch-core/src/vendor/nuget_feed.rs b/crates/socket-patch-core/src/vendor/nuget_feed.rs index 13c335489..374e77cd4 100644 --- a/crates/socket-patch-core/src/vendor/nuget_feed.rs +++ b/crates/socket-patch-core/src/vendor/nuget_feed.rs @@ -516,17 +516,22 @@ pub async fn vendor_nuget( let new_hash = sha512_base64_of(&nupkg_bytes); // ── nuget.config wiring (runs after the artifact) ───────────────────── - let config_edit = - match build_config_edit(config_text.as_deref(), &source_key, &uuid_dir_rel, name) { - Ok(edit) => edit, - Err(detail) => { - let _ = remove_tree(&uuid_dir).await; - prune_empty_vendor_levels(&uuid_dir).await; - result.success = false; - result.error = Some(detail); - return done(result, None, warnings); - } - }; + let config_edit = match build_config_edit_with( + config_text.as_deref(), + &super::nuget_config::inherited_source_keys(project_root).await, + &source_key, + &uuid_dir_rel, + name, + ) { + Ok(edit) => edit, + Err(detail) => { + let _ = remove_tree(&uuid_dir).await; + prune_empty_vendor_levels(&uuid_dir).await; + result.success = false; + result.error = Some(detail); + return done(result, None, warnings); + } + }; let config_target = config_path .clone() .unwrap_or_else(|| project_root.join("nuget.config")); @@ -540,6 +545,18 @@ pub async fn vendor_nuget( return done(result, None, warnings); } + if !config_edit.set_aside.is_empty() { + warnings.push(VendorWarning::new( + "vendor_nuget_mapping_set_aside", + format!( + "nuget.config also mapped {name} to {}; that pattern is commented out while the \ + package is vendored, so the vendored feed alone serves it (vendor --revert \ + restores it)", + config_edit.set_aside.join(", ") + ), + )); + } + // ── packages.lock.json pinning (a failure here unwinds the config) ──── let mut lock_record: Option = None; if let Some(text) = &lock_text { @@ -835,6 +852,8 @@ async fn write_nupkg(uuid_dir: &Path, nupkg_path: &Path, bytes: &[u8]) -> Result struct ConfigEdit { new_text: String, mapping_fragment: String, + /// Sources whose exact pattern for the id was set aside (#462). + set_aside: Vec, } /// Resolve the existing config in NuGet's own probe order, or `None` when the @@ -855,40 +874,72 @@ async fn existing_config_path(project_root: &Path) -> Option { /// nuget.org source so the load-bearing catch-all has a target; editing an /// existing file inserts our source (and, only when no `packageSourceMapping` /// existed, the catch-all over its pre-existing sources). +#[cfg(test)] fn build_config_edit( original: Option<&str>, source_key: &str, source_rel: &str, patched_id: &str, +) -> Result { + // No inherited configs: the file-only reading the writer had before + // #354 (its own tests cover the inherited sources). + build_config_edit_with(original, &[], source_key, source_rel, patched_id) +} + +/// [`build_config_edit`] over `inherited`: the source keys the configs NuGet +/// merges below this one define ([`super::nuget_config::inherited_source_keys`]). +/// A mapping created here must fan `*` out to them too — once any mapping +/// exists NuGet drops every source no pattern names, inherited ones +/// included (#354) — and nuget.org is only seeded when the inherited set +/// has it (or nothing): a parent that cleared nuget.org for a mirror keeps +/// that choice. +fn build_config_edit_with( + original: Option<&str>, + inherited: &[String], + source_key: &str, + source_rel: &str, + patched_id: &str, ) -> Result { let mapping_fragment = format!( " \n \n \n" ); + let seed_allowed = inherited.is_empty() || inherited.iter().any(|k| k == NUGET_ORG_SOURCE_KEY); match original { None => { - // Fresh config: nuget.org (the implicit default) is seeded as the - // catch-all target, our source added, and the mapping routes the - // patched id to us while `*` keeps everything else on nuget.org. + // Fresh config: our source, and a mapping that routes the + // patched id to us while `*` keeps everything else on the + // sources NuGet inherits. nuget.org (the implicit default) is + // seeded unless the inherited configs dropped it. + let mut catch_all: Vec = inherited.to_vec(); + let mut sources = String::new(); + if seed_allowed { + sources.push_str(&format!( + " \n" + )); + if !catch_all.iter().any(|k| k == NUGET_ORG_SOURCE_KEY) { + catch_all.insert(0, NUGET_ORG_SOURCE_KEY.to_string()); + } + } + sources.push_str(&format!( + " \n" + )); + let mut mapping = String::new(); + for key in &catch_all { + mapping.push_str(&format!( + " \n \n \n", + crate::formats::nuget::xml_attribute(key) + )); + } + mapping.push_str(&mapping_fragment); let text = format!( - "\n\ - \n\ - \x20 \n\ - \x20 \n\ - \x20 \n\ - \x20 \n\ - \x20 \n\ - \x20 \n\ - \x20 \n\ - \x20 \n\ - \x20 \n\ - \x20 \n\ - \x20 \n\ - \x20 \n\ - \n" + "\n\n \n\ + {sources} \n \n{mapping} \ + \n\n" ); Ok(ConfigEdit { new_text: text, mapping_fragment, + set_aside: Vec::new(), }) } Some(text) => { @@ -926,7 +977,22 @@ fn build_config_edit( catch_all_keys.push(key.clone()); } } - let seed_nuget_org = creating_mapping && catch_all_keys.is_empty(); + let own_sources = !catch_all_keys.is_empty(); + // NuGet merges the inherited sources under this file's unless it + // ``s them; the catch-all must name them too (#354). + let inherited: &[String] = if parsed.sources_cleared { + &[] + } else { + inherited + }; + for key in inherited { + if !catch_all_keys.contains(key) { + catch_all_keys.push(key.clone()); + } + } + let seed_nuget_org = creating_mapping + && !own_sources + && (inherited.is_empty() || inherited.iter().any(|k| k == NUGET_ORG_SOURCE_KEY)); let source_add = format!(" \n"); let org_add = format!( @@ -935,7 +1001,9 @@ fn build_config_edit( // The sources we inject: the seeded nuget.org (when needed) then our // vendored source. let injected_sources = if seed_nuget_org { - catch_all_keys.push(NUGET_ORG_SOURCE_KEY.to_string()); + if !catch_all_keys.iter().any(|k| k == NUGET_ORG_SOURCE_KEY) { + catch_all_keys.push(NUGET_ORG_SOURCE_KEY.to_string()); + } format!("{org_add}{source_add}") } else { source_add @@ -998,9 +1066,18 @@ fn build_config_edit( } } }; + // Another source naming the id exactly ties with ours, and NuGet + // takes the package from whichever answers first (#462): that + // pattern is set aside in a comment while we are wired. The + // whole-file revert restores it, and so does the excision. + let wired = parse_wirable_config(&new_text)?; + let (new_text, set_aside) = crate::formats::nuget::set_aside_competing_patterns( + &new_text, &wired, source_key, patched_id, + )?; Ok(ConfigEdit { new_text, mapping_fragment, + set_aside, }) } } @@ -1144,7 +1221,9 @@ async fn revert_config_record( if dry_run { return Ok(true); } - let mut out = live.replacen(&source_add, "", 1); + // The patterns vendor set aside go back first (#462). + let mut out = + crate::formats::nuget::restore_set_aside(&live, source_key).replacen(&source_add, "", 1); if let Some(block) = mapping_block { out = out.replacen(&block, "", 1); } @@ -4251,6 +4330,150 @@ mod tests { } } + fn catch_all_of(text: &str) -> Vec { + crate::formats::nuget::parse_config(text) + .unwrap() + .mappings + .into_iter() + .filter(|(_, p)| p == &["*"]) + .map(|(k, _)| k) + .collect() + } + + fn wire_inheriting(original: Option<&str>, inherited: &[&str]) -> String { + let inherited: Vec = inherited.iter().map(|k| k.to_string()).collect(); + build_config_edit_with( + original, + &inherited, + &source_key(), + &format!(".socket/vendor/nuget/{UUID}"), + "Newtonsoft.Json", + ) + .unwrap() + .new_text + } + + /// #354: a mapping created here also fans `*` out to the sources NuGet + /// inherits (user config, parent directories) — NuGet drops every + /// source no pattern names. + #[test] + fn created_catch_all_names_inherited_sources() { + let own = "\n \n \n \n\n"; + let t = wire_inheriting(Some(own), &["nuget.org", "corp"]); + assert_eq!(catch_all_of(&t), ["local", "nuget.org", "corp"], "{t}"); + // A file that ``s them inherits nothing. + let cleared = own.replace("\n", "\n \n"); + let t = wire_inheriting(Some(&cleared), &["nuget.org", "corp"]); + assert_eq!(catch_all_of(&t), ["local"], "{t}"); + // An existing mapping is the user's: nothing is fanned out. + let mapped = own.replace( + "", + " \n \n \n \n \n", + ); + let t = wire_inheriting(Some(&mapped), &["nuget.org", "corp"]); + assert_eq!(catch_all_of(&t), ["local"], "{t}"); + } + + /// #354: a fresh config maps `*` to every inherited source, and only + /// seeds nuget.org when the inherited configs have it: a parent that + /// cleared nuget.org for a mirror keeps that choice. + #[test] + fn fresh_config_follows_the_inherited_sources() { + let t = wire_inheriting(None, &["nuget.org", "corp"]); + assert_eq!(catch_all_of(&t), ["nuget.org", "corp"], "{t}"); + assert!(t.contains("\n\n \n \n \n \n \n \n \n \n \n \n\n"; + let (dir, blobs, installed, record) = fixture(true, Some(cfg)).await; + let root = dir.path(); + 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_mapping_set_aside" + && w.detail.contains("nuget.org")), + "{warnings:?}" + ); + let wired = tokio::fs::read_to_string(root.join("nuget.config")) + .await + .unwrap(); + let parsed = crate::formats::nuget::parse_config(&wired).unwrap(); + let exact: Vec<&str> = parsed + .mappings + .iter() + .filter(|(_, p)| p.iter().any(|p| p == "Newtonsoft.Json")) + .map(|(k, _)| k.as_str()) + .collect(); + assert_eq!(exact, [source_key().as_str()], "{wired}"); + // A sibling edit forces the excision path; it restores the pattern too. + tokio::fs::write( + root.join("nuget.config"), + wired.replace("", "\n"), + ) + .await + .unwrap(); + let reverted = revert_nuget(&entry.unwrap(), root, false).await; + assert!(reverted.success, "{:?}", reverted.error); + let after = tokio::fs::read_to_string(root.join("nuget.config")) + .await + .unwrap(); + assert_eq!( + after, + cfg.replace("", "\n") + ); + } + + /// #354 end to end: the project sits under a directory whose + /// nuget.config defines a private feed; vendoring creates a config whose + /// catch-all keeps that feed (and the implicit nuget.org) eligible. + #[tokio::test] + async fn vendor_keeps_a_parent_directorys_feed_routable() { + let (dir, blobs, installed, record) = fixture(true, None).await; + let outer = dir.path(); + tokio::fs::write( + outer.join("nuget.config"), + "\n \n \n \n\n", + ) + .await + .unwrap(); + // The project is a subdirectory: copy the fixture's lock into it. + let root = outer.join("app"); + tokio::fs::create_dir_all(&root).await.unwrap(); + tokio::fs::rename(outer.join(PACKAGES_LOCK), root.join(PACKAGES_LOCK)) + .await + .unwrap(); + let (result, _entry, _w) = + unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); + assert!(result.success, "{:?}", result.error); + let t = tokio::fs::read_to_string(root.join("nuget.config")) + .await + .unwrap(); + assert_eq!(catch_all_of(&t), ["nuget.org", "corp"], "{t}"); + } + /// A key listed twice gets one catch-all, and an `` without a key /// gets none. #[test] From c696181a104f9daf1fc2b25d1b2641bcdc7e2aff Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 14:42:25 -0400 Subject: [PATCH 5/9] Set aside a NuGet package element through its close tag A pattern written with a close tag ( ) was set aside from its open tag only, leaving a dangling that made nuget.config unparseable while the patch was wired (Bugbot on #1341). The pattern span now runs through the close tag. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/formats/nuget/mod.rs | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/crates/socket-patch-core/src/formats/nuget/mod.rs b/crates/socket-patch-core/src/formats/nuget/mod.rs index 1afc742ed..15089c699 100644 --- a/crates/socket-patch-core/src/formats/nuget/mod.rs +++ b/crates/socket-patch-core/src/formats/nuget/mod.rs @@ -119,6 +119,9 @@ pub(crate) fn parse_config(text: &str) -> Option { let mut open_mapping: Option = None; // Index into `cfg.mapping_spans` of the open `` element. let mut open_span: Option = None; + // `(span, pattern)` of an open (not self-closing) `` element: + // its span runs through its close tag. + let mut open_pattern: Option<(usize, usize)> = None; let mut i = 0; while let Some(rel) = text[i..].find('<') { let at = i + rel; @@ -145,6 +148,13 @@ pub(crate) fn parse_config(text: &str) -> Option { if name == "clear" { record_clear(&mut cfg, &stack, i); } + if name == "package" + && stack[..] == ["configuration", "packageSourceMapping", "packageSource"] + { + if let Some((span, pattern)) = open_pattern.take() { + cfg.mapping_spans[span].patterns[pattern].end = i; + } + } if name == "packageSource" && stack[..] == ["configuration", "packageSourceMapping"] { if let Some(idx) = open_span.take() { cfg.mapping_spans[idx].element.end = i; @@ -180,6 +190,9 @@ pub(crate) fn parse_config(text: &str) -> Option { } else if let (Some(idx), Some(before)) = (open_mapping, patterns) { if cfg.mappings[idx].1.len() > before { cfg.mapping_spans[idx].patterns.push(at..i); + if !tag.self_closing { + open_pattern = Some((idx, cfg.mapping_spans[idx].patterns.len() - 1)); + } } } if !tag.self_closing { @@ -519,6 +532,30 @@ mod tests { .unwrap(); assert!(keys.is_empty()); assert_eq!(again, out); + // A `` written with a close tag is set aside whole. + let open_close = COMPETING + .replace( + "", + "", + ) + .replacen( + "", + "\n ", + 1, + ); + let cfg2 = super::parse_config(&open_close).unwrap(); + let (out2, _) = super::set_aside_competing_patterns( + &open_close, + &cfg2, + "socket-patch-u", + "Newtonsoft.Json", + ) + .unwrap(); + assert!(super::parse_config(&out2).is_some(), "{out2}"); + assert_eq!( + super::restore_set_aside(&out2, "socket-patch-u"), + open_close + ); // Another key's markers are not ours to restore. assert_eq!(super::restore_set_aside(&out, "socket-patch-v"), out); } From fae65ca24aa28631ec7ef7c3de2dd71be8b3697b Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 15:28:42 -0400 Subject: [PATCH 6/9] Record inherited NuGet config reads with the view The hosted engine asked the project view for its raw root to read the user and parent-directory NuGet configs, which ends a re-scan read cache's recording. It now takes the root without that, reads the configs beside the view, and hands the view every path it probed so the cache fingerprints them like its own reads. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-core/src/hosted/engine.rs | 13 +++++++--- .../src/vendor/nuget_config.rs | 25 ++++++++++++++----- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 9463b1709..876140ccc 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -636,9 +636,16 @@ pub async fn read_candidate_files( // under the project's own: a catch-all mapping the rewriter creates // must name their sources too (#354). Disk only (the in-memory engine // refuses NuGet, and could not see them). - if candidates.iter().any(|c| c.dep.ecosystem == "nuget") { - if let Some(root) = view.disk_root() { - let keys = crate::vendor::nuget_config::inherited_source_keys(root).await; + if candidates.iter().any(|c| c.dep.ecosystem == "nuget") + && !matches!(view, ProjectView::Memory(_)) + { + // The configs live outside the project: read beside the view, then + // handed to it as such reads (asking for its raw root would end a + // re-scan read cache's recording). + if let Some(root) = view.disk_root_reading(std::iter::empty::<&str>()) { + let (keys, touched) = + crate::vendor::nuget_config::inherited_source_keys_traced(root).await; + view.disk_root_reading(&touched); out.files.insert( crate::patch::redirect::NUGET_INHERITED_SOURCES_KEY.to_string(), keys.join("\n"), diff --git a/crates/socket-patch-core/src/vendor/nuget_config.rs b/crates/socket-patch-core/src/vendor/nuget_config.rs index 11c06f79c..2029dd38d 100644 --- a/crates/socket-patch-core/src/vendor/nuget_config.rs +++ b/crates/socket-patch-core/src/vendor/nuget_config.rs @@ -92,13 +92,24 @@ pub(crate) mod tests_support { /// dropping the farther ones. A file that cannot be read or parsed is /// skipped: it contributes nothing NuGet could use either. pub(crate) async fn inherited_source_keys(project_root: &std::path::Path) -> Vec { + inherited_source_keys_traced(project_root).await.0 +} + +/// [`inherited_source_keys`] plus every path it probed or read (absolute), +/// for a read cache that fingerprints reads it did not mediate. +pub(crate) async fn inherited_source_keys_traced( + project_root: &std::path::Path, +) -> (Vec, Vec) { use crate::formats::nuget::{effective_source_keys, parse_config, NugetConfig}; + let mut touched: Vec = Vec::new(); let mut chain: Vec = Vec::new(); let user = match user_config_path() { - Some(path) => crate::utils::fs::read_regular_to_string(&path) - .await - .ok() - .and_then(|t| parse_config(crate::formats::text::strip_bom(&t))), + Some(path) => { + let read = crate::utils::fs::read_regular_to_string(&path).await; + touched.push(path); + read.ok() + .and_then(|t| parse_config(crate::formats::text::strip_bom(&t))) + } None => None, }; chain.push(user.unwrap_or_else(|| NugetConfig { @@ -113,7 +124,9 @@ pub(crate) async fn inherited_source_keys(project_root: &std::path::Path) -> Vec for dir in ancestors { for name in CONFIG_NAMES { let path = dir.join(name); - if !crate::utils::fs::file_exists(&path).await { + let exists = crate::utils::fs::file_exists(&path).await; + touched.push(path.clone()); + if !exists { continue; } if let Some(cfg) = crate::utils::fs::read_regular_to_string(&path) @@ -126,7 +139,7 @@ pub(crate) async fn inherited_source_keys(project_root: &std::path::Path) -> Vec break; } } - effective_source_keys(&chain) + (effective_source_keys(&chain), touched) } #[cfg(test)] From 8e1d8e4018aa55a0ca83b8e4770aab3885a7a406 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 16:26:32 -0400 Subject: [PATCH 7/9] Respect inherited NuGet mappings; set aside lookalikes NuGet merges packageSourceMapping across the config chain too. When a parent or user config already maps packages, a `*` catch-all written into the project's config widened a source the parent restricts (review on #1341). The writers now write no catch-all then, only the Socket pattern: the inherited patterns already route everything else. The exclusivity set-aside skipped every socket-patch-* key, so a lookalike key (or a stale uuid) pointing at any feed stayed tied with the Socket source (security review on #1341). Only the source this run wires is exempt now. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/formats/nuget/mod.rs | 18 ++++++- crates/socket-patch-core/src/hosted/engine.rs | 10 +++- .../src/patch/redirect/mod.rs | 22 ++++++++ .../src/patch/redirect/upstream/nuget.rs | 1 + .../src/vendor/nuget_config.rs | 46 +++++++++++++++- .../src/vendor/nuget_feed.rs | 52 +++++++++++++++++-- 6 files changed, 138 insertions(+), 11 deletions(-) diff --git a/crates/socket-patch-core/src/formats/nuget/mod.rs b/crates/socket-patch-core/src/formats/nuget/mod.rs index 15089c699..03262f335 100644 --- a/crates/socket-patch-core/src/formats/nuget/mod.rs +++ b/crates/socket-patch-core/src/formats/nuget/mod.rs @@ -386,7 +386,9 @@ const SET_ASIDE_CLOSE: &str = " -->"; /// pattern is commented out where it stands (the whole `` /// when it was its only pattern: NuGet rejects an element with none), in a /// comment naming `key` that [`restore_set_aside`] turns back into the -/// original bytes. Other Socket sources are left alone (their own wiring). +/// original bytes. A `socket-patch-*` key is no exception: the key alone +/// proves nothing about the feed behind it (a stale uuid, or any URL under a +/// Socket-looking name), and only the source this run wires may serve `id`. /// /// `Ok((text, keys))` with the sources set aside (empty: nothing competed); /// `Err` when an element cannot be put in a comment (it holds `--`). @@ -399,7 +401,7 @@ pub(crate) fn set_aside_competing_patterns( let mut cuts: Vec> = Vec::new(); let mut keys: Vec = Vec::new(); for ((source, patterns), span) in cfg.mappings.iter().zip(&cfg.mapping_spans) { - if source == key || source.starts_with("socket-patch-") { + if source == key { continue; } let hits: Vec = (0..patterns.len()) @@ -556,6 +558,18 @@ mod tests { super::restore_set_aside(&out2, "socket-patch-u"), open_close ); + // A Socket-looking key is a competitor like any other. + let lookalike = COMPETING.replace("key=\"corp\"", "key=\"socket-patch-evil\""); + let cfg3 = super::parse_config(&lookalike).unwrap(); + let (out3, keys3) = super::set_aside_competing_patterns( + &lookalike, + &cfg3, + "socket-patch-u", + "Newtonsoft.Json", + ) + .unwrap(); + assert_eq!(keys3, ["nuget.org", "socket-patch-evil"]); + assert_eq!(super::restore_set_aside(&out3, "socket-patch-u"), lookalike); // Another key's markers are not ours to restore. assert_eq!(super::restore_set_aside(&out, "socket-patch-v"), out); } diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 876140ccc..59f5f778a 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -643,13 +643,19 @@ pub async fn read_candidate_files( // handed to it as such reads (asking for its raw root would end a // re-scan read cache's recording). if let Some(root) = view.disk_root_reading(std::iter::empty::<&str>()) { - let (keys, touched) = + let (inherited, touched) = crate::vendor::nuget_config::inherited_source_keys_traced(root).await; view.disk_root_reading(&touched); out.files.insert( crate::patch::redirect::NUGET_INHERITED_SOURCES_KEY.to_string(), - keys.join("\n"), + inherited.keys.join("\n"), ); + if inherited.mapped { + out.files.insert( + crate::patch::redirect::NUGET_INHERITED_MAPPING_KEY.to_string(), + String::new(), + ); + } } } diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index d9275fc93..2a8b7462a 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -5656,6 +5656,7 @@ fn add_nuget_source( config: &str, parsed: &crate::formats::nuget::NugetConfig, inherited: Option<&[String]>, + inherited_mapped: bool, reg: &str, index_url: &str, pkg_id: &str, @@ -5684,9 +5685,13 @@ fn add_nuget_source( .is_none_or(|section| section.close_start.is_none()); // nuget.org is only seeded when the inherited configs have it (or // nothing): a parent that cleared it for a mirror keeps that choice. + // An inherited mapping already routes everything else (NuGet merges + // mappings too): no catch-all, which would widen a source it restricts. let seed_nuget_org = creating_mapping && !own_sources + && !inherited_mapped && (inherited.is_empty() || inherited.iter().any(|k| k == NUGET_ORG_KEY)); + let creating_mapping = creating_mapping && !inherited_mapped; let reg = nuget_xml_attribute(reg); let mut source_lines = format!( " ", @@ -5787,6 +5792,10 @@ fn nuget_xml_attribute(value: &str) -> String { /// Never a path (see [`sbt::SYNTHETIC_KEY_PREFIX`]). pub const NUGET_INHERITED_SOURCES_KEY: &str = ""; +/// The synthetic candidate key present when one of those configs maps +/// packages already (`packageSourceMapping`, which NuGet merges too). +pub const NUGET_INHERITED_MAPPING_KEY: &str = ""; + /// A config with no sources, the base of a fresh one when the inherited /// configs dropped nuget.org. const EMPTY_NUGET_CONFIG: &str = "\n\n \n \n\n"; @@ -5911,6 +5920,7 @@ fn rewrite_nuget( &config, &parsed, inherited.as_deref(), + files.contains_key(NUGET_INHERITED_MAPPING_KEY), ®, &ov.index_url, &dep.name, @@ -22730,6 +22740,18 @@ packages: assert_eq!(nuget_catch_all(config), ["mirror"], "{config}"); assert!(!config.contains("nuget.org"), "{config}"); + // An inherited mapping already routes everything else: only the + // Socket pattern is written (#354 review). + let mut files = BTreeMap::new(); + files.insert("nuget.config".to_string(), default_nuget_config()); + files.insert( + NUGET_INHERITED_SOURCES_KEY.to_string(), + "nuget.org\ncorp".to_string(), + ); + files.insert(NUGET_INHERITED_MAPPING_KEY.to_string(), String::new()); + let r = rewrite_registry_redirect(&files, &[nuget_override()]); + assert!(nuget_catch_all(&r.files["nuget.config"]).is_empty()); + // Without the key (the in-memory engine) the file alone decides. let mut files = BTreeMap::new(); files.insert("nuget.config".to_string(), default_nuget_config()); 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 ad548c338..68b0debf9 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/nuget.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/nuget.rs @@ -450,6 +450,7 @@ mod tests { config, &parse_config(config).unwrap(), None, + false, &format!("socket-patch-{UUID}"), &index_url(), "Newtonsoft.Json", diff --git a/crates/socket-patch-core/src/vendor/nuget_config.rs b/crates/socket-patch-core/src/vendor/nuget_config.rs index 2029dd38d..0cf64a123 100644 --- a/crates/socket-patch-core/src/vendor/nuget_config.rs +++ b/crates/socket-patch-core/src/vendor/nuget_config.rs @@ -91,7 +91,25 @@ pub(crate) mod tests_support { /// parent directory's config from the filesystem root down, each `` /// dropping the farther ones. A file that cannot be read or parsed is /// skipped: it contributes nothing NuGet could use either. +#[cfg(test)] pub(crate) async fn inherited_source_keys(project_root: &std::path::Path) -> Vec { + inherited_source_keys_traced(project_root).await.0.keys +} + +/// What the configs NuGet merges below the project's own contribute. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub(crate) struct Inherited { + /// Their package source keys ([`inherited_source_keys`]). + pub(crate) keys: Vec, + /// One of them maps packages already: NuGet merges + /// `packageSourceMapping` across the chain too, so their patterns + /// already route every other package, and a `*` catch-all written here + /// would WIDEN a source they restrict. + pub(crate) mapped: bool, +} + +/// [`Inherited`] for `project_root`. +pub(crate) async fn inherited_sources(project_root: &std::path::Path) -> Inherited { inherited_source_keys_traced(project_root).await.0 } @@ -99,7 +117,7 @@ pub(crate) async fn inherited_source_keys(project_root: &std::path::Path) -> Vec /// for a read cache that fingerprints reads it did not mediate. pub(crate) async fn inherited_source_keys_traced( project_root: &std::path::Path, -) -> (Vec, Vec) { +) -> (Inherited, Vec) { use crate::formats::nuget::{effective_source_keys, parse_config, NugetConfig}; let mut touched: Vec = Vec::new(); let mut chain: Vec = Vec::new(); @@ -139,7 +157,14 @@ pub(crate) async fn inherited_source_keys_traced( break; } } - (effective_source_keys(&chain), touched) + let mapped = chain.iter().any(|cfg| !cfg.mappings.is_empty()); + ( + Inherited { + keys: effective_source_keys(&chain), + mapped, + }, + touched, + ) } #[cfg(test)] @@ -215,4 +240,21 @@ mod tests { std::fs::remove_file(tmp.path().join("repo/NuGet.Config")).unwrap(); assert_eq!(inherited_source_keys(&project).await, ["nuget.org"]); } + + /// An inherited `packageSourceMapping` is reported (NuGet merges it). + #[tokio::test] + async fn inherited_mapping_is_reported() { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path().join("repo/app"); + std::fs::create_dir_all(&project).unwrap(); + assert!(!super::inherited_sources(&project).await.mapped); + std::fs::write( + tmp.path().join("repo/nuget.config"), + "", + ) + .unwrap(); + let got = super::inherited_sources(&project).await; + assert!(got.mapped); + assert_eq!(got.keys, ["nuget.org", "corp"]); + } } diff --git a/crates/socket-patch-core/src/vendor/nuget_feed.rs b/crates/socket-patch-core/src/vendor/nuget_feed.rs index 374e77cd4..26193c2a5 100644 --- a/crates/socket-patch-core/src/vendor/nuget_feed.rs +++ b/crates/socket-patch-core/src/vendor/nuget_feed.rs @@ -516,9 +516,11 @@ pub async fn vendor_nuget( let new_hash = sha512_base64_of(&nupkg_bytes); // ── nuget.config wiring (runs after the artifact) ───────────────────── + let inherited = super::nuget_config::inherited_sources(project_root).await; let config_edit = match build_config_edit_with( config_text.as_deref(), - &super::nuget_config::inherited_source_keys(project_root).await, + &inherited.keys, + inherited.mapped, &source_key, &uuid_dir_rel, name, @@ -883,7 +885,7 @@ fn build_config_edit( ) -> Result { // No inherited configs: the file-only reading the writer had before // #354 (its own tests cover the inherited sources). - build_config_edit_with(original, &[], source_key, source_rel, patched_id) + build_config_edit_with(original, &[], false, source_key, source_rel, patched_id) } /// [`build_config_edit`] over `inherited`: the source keys the configs NuGet @@ -892,10 +894,14 @@ fn build_config_edit( /// exists NuGet drops every source no pattern names, inherited ones /// included (#354) — and nuget.org is only seeded when the inherited set /// has it (or nothing): a parent that cleared nuget.org for a mirror keeps -/// that choice. +/// that choice. When an inherited config maps packages already +/// (`inherited_mapped`), no catch-all is written at all: NuGet merges its +/// patterns, which already route everything else, and a `*` here would widen +/// a source it restricts. fn build_config_edit_with( original: Option<&str>, inherited: &[String], + inherited_mapped: bool, source_key: &str, source_rel: &str, patched_id: &str, @@ -910,9 +916,13 @@ fn build_config_edit_with( // patched id to us while `*` keeps everything else on the // sources NuGet inherits. nuget.org (the implicit default) is // seeded unless the inherited configs dropped it. - let mut catch_all: Vec = inherited.to_vec(); + let mut catch_all: Vec = if inherited_mapped { + Vec::new() + } else { + inherited.to_vec() + }; let mut sources = String::new(); - if seed_allowed { + if seed_allowed && !inherited_mapped { sources.push_str(&format!( " \n" )); @@ -992,7 +1002,11 @@ fn build_config_edit_with( } let seed_nuget_org = creating_mapping && !own_sources + && !inherited_mapped && (inherited.is_empty() || inherited.iter().any(|k| k == NUGET_ORG_SOURCE_KEY)); + if inherited_mapped { + catch_all_keys.clear(); + } let source_add = format!(" \n"); let org_add = format!( @@ -4345,6 +4359,7 @@ mod tests { build_config_edit_with( original, &inherited, + false, &source_key(), &format!(".socket/vendor/nuget/{UUID}"), "Newtonsoft.Json", @@ -4446,6 +4461,33 @@ mod tests { ); } + /// #354 review: an inherited config that maps packages already routes + /// everything else (NuGet merges mappings too), so no `*` catch-all is + /// written — it would widen a source the parent restricts. + #[test] + fn inherited_mapping_gets_no_catch_all() { + let own = "\n \n \n \n\n"; + for original in [None, Some(own)] { + let t = build_config_edit_with( + original, + &["nuget.org".to_string(), "corp".to_string()], + true, + &source_key(), + &format!(".socket/vendor/nuget/{UUID}"), + "Newtonsoft.Json", + ) + .unwrap() + .new_text; + assert!(catch_all_of(&t).is_empty(), "{t}"); + let parsed = crate::formats::nuget::parse_config(&t).unwrap(); + assert_eq!( + parsed.mappings, + [(source_key(), vec!["Newtonsoft.Json".to_string()])], + "{t}" + ); + } + } + /// #354 end to end: the project sits under a directory whose /// nuget.config defines a private feed; vendoring creates a config whose /// catch-all keeps that feed (and the implicit nuget.org) eligible. From f8494683bc10aabaf8670dae50735e0f43246a73 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 16:26:42 -0400 Subject: [PATCH 8/9] Document inherited NuGet mapping handling Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/CLI_CONTRACT.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 8bc453207..4df44dc9f 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_nuget_mapping_set_aside` (v5.0 #462: another source's `packageSourceMapping` also named the patched id exactly — Visual Studio's mapping UI writes such lists — which ties with the Socket source so NuGet would take the package from whichever feed answers first; that pattern, or its whole `` when it was the only one, is commented out in a `` comment while the patch is wired, and `remove` / `rollback` put it back byte-exact), `redirect_nuget_mapping_conflict` (such a pattern sits in markup that cannot go in a comment; the dep is skipped, nothing written). v5.0 #354: when the rewriter creates the `packageSourceMapping`, its `*` catch-all also names the sources NuGet merges in from the user config and every parent directory's config (honoring ``), since NuGet drops every source no pattern names; a fresh config only seeds `nuget.org` when those inherited configs have it (a parent that cleared it for a mirror keeps that choice), `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), `redirect_nuget_mapping_set_aside` (v5.0 #462: another source's `packageSourceMapping` also named the patched id exactly — Visual Studio's mapping UI writes such lists — which ties with the Socket source so NuGet would take the package from whichever feed answers first; that pattern, or its whole `` when it was the only one, is commented out in a `` comment while the patch is wired, and `remove` / `rollback` put it back byte-exact), `redirect_nuget_mapping_conflict` (such a pattern sits in markup that cannot go in a comment; the dep is skipped, nothing written). v5.0 #354: when the rewriter creates the `packageSourceMapping`, its `*` catch-all also names the sources NuGet merges in from the user config and every parent directory's config (honoring ``), since NuGet drops every source no pattern names; a fresh config only seeds `nuget.org` when those inherited configs have it (a parent that cleared it for a mirror keeps that choice); when an inherited config already has a `packageSourceMapping` (NuGet merges those too), no catch-all is written at all — only the Socket pattern — so a source the parent restricts is never widened; the exclusivity set-aside exempts only the source the run wires, so a `socket-patch-*` lookalike key or a stale uuid naming the id is set aside too, `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, including the ones NuGet inherits from the user config and parent directories' configs (v5.0 #354) — mapping is exclusive, NU1100 otherwise; another source's exact pattern for the id is set aside in a comment while vendored, `vendor_nuget_mapping_set_aside`, #462) **+** `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, including the ones NuGet inherits from the user config and parent directories' configs (v5.0 #354), none at all when an inherited config already maps packages — mapping is exclusive, NU1100 otherwise; another source's exact pattern for the id is set aside in a comment while vendored, `vendor_nuget_mapping_set_aside`, #462) **+** `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) | | 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 From 5b00e587d46194ac2d16ccda272b923c77685dbe Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Sat, 10 Oct 2026 10:12:12 -0400 Subject: [PATCH 9/9] Refuse NuGet patch uuids that could carry config markup The hosted NuGet rewrite interpolates the patch uuid into the source key, the mapping and the set-aside comment prefix. A uuid holding `-->`, a quote or `<` could close that comment and write live nuget.config markup. Skip such a dependency (redirect_nuget_invalid_uuid), and have set_aside_competing_patterns refuse a key containing `--`. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/formats/nuget/mod.rs | 14 +++++++ .../src/patch/redirect/mod.rs | 42 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/crates/socket-patch-core/src/formats/nuget/mod.rs b/crates/socket-patch-core/src/formats/nuget/mod.rs index 095b27b5b..fcfee128c 100644 --- a/crates/socket-patch-core/src/formats/nuget/mod.rs +++ b/crates/socket-patch-core/src/formats/nuget/mod.rs @@ -400,6 +400,12 @@ pub(crate) fn set_aside_competing_patterns( key: &str, id: &str, ) -> Result<(String, Vec), String> { + // The key opens the comment: `--` in it could close the comment early. + if key.contains("--") { + return Err(format!( + "source key {key} cannot name a set-aside comment (it holds `--`)" + )); + } let mut cuts: Vec> = Vec::new(); let mut keys: Vec = Vec::new(); for ((source, patterns), span) in cfg.mappings.iter().zip(&cfg.mapping_spans) { @@ -560,6 +566,14 @@ mod tests { super::restore_set_aside(&out2, "socket-patch-u"), open_close ); + // A key that could close the comment is refused. + assert!(super::set_aside_competing_patterns( + COMPETING, + &cfg, + "socket-patch-x-->", + "Newtonsoft.Json" + ) + .is_err()); // A Socket-looking key is a competitor like any other. let lookalike = COMPETING.replace("key=\"corp\"", "key=\"socket-patch-evil\""); let cfg3 = super::parse_config(&lookalike).unwrap(); diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 0dff6e4ab..0d06248ad 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -6020,6 +6020,24 @@ fn rewrite_nuget( let mut lock_changed = false; for dep in &nuget { + // The uuid lands in the source key, the mapping and the set-aside + // comment: one carrying markup (`-->`, a quote, `<`) could write live + // nuget.config elements. Only ASCII alphanumerics and single hyphens + // pass (every canonical uuid does). + let uuid = &dep.patch_uuid; + if uuid.is_empty() + || uuid.contains("--") + || !uuid.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') + { + result.warnings.push(RewriteWarning { + code: "redirect_nuget_invalid_uuid".into(), + detail: format!( + "{} has a malformed patch uuid; dependency skipped", + dep.name + ), + }); + continue; + } let Some(ov) = registry_override_of_kind(dep, "nuget-v3") else { result.warnings.push(RewriteWarning { code: "redirect_nuget_missing_override".into(), @@ -9138,6 +9156,30 @@ mod tests { } } + #[test] + fn nuget_markup_in_the_patch_uuid_is_refused() { + let config = "\n \n \ + \n \ + \n \n \ + \n \ + \n\n"; + let files = BTreeMap::from([("nuget.config".into(), config.to_string())]); + for uuid in [ + "x -->