Skip to content

Fix NuGet mapping exclusivity and inherited sources (#354, #462) - #1341

Open
Mikola Lysenko (mikolalysenko) wants to merge 12 commits into
mainfrom
agent/v5-nuget-mapping
Open

Mikola Lysenko (mikolalysenko) wants to merge 12 commits into
mainfrom
agent/v5-nuget-mapping

Conversation

@mikolalysenko

@mikolalysenko Mikola Lysenko (mikolalysenko) commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

LLM Description written by Claude Code:claude-opus-5-5

Fixes #354
Fixes #462

Summary

The packageSourceMapping that vendored and hosted NuGet write is now both inherit-safe (#354) and exclusive (#462).

Root cause

Both writers computed the mapping from the single nuget.config they edit, and assumed "most specific pattern wins":

Fix

  • formats::nuget: the reader records sources_cleared. A <clear /> drops the sources the file defined before it. effective_source_keys merges a chain of configs, farthest first. It also records mapping_spans (byte ranges of each <packageSource> and its pattern tags).
  • vendor::nuget_config::inherited_source_keys reads the chain: the user config (DOTNET_CLI_HOME or HOME / .nuget/NuGet/NuGet.Config, %APPDATA% on Windows; when absent, NuGet's implicit nuget.org), then each parent directory's config from the root down.
  • NuGet nuget.config authored by vendored/hosted mode maps '*' only to nuget.org, cutting off sources inherited from parent or user-level configs (NU1101) #354: a created catch-all also names the inherited sources, unless the file itself <clear />s them. nuget.org is seeded only when the inherited set has it or is empty. In the common case (the user config has only nuget.org) the output is byte-identical to before. The hosted engine passes the inherited keys to the pure rewriter through a synthetic candidate key (as sbt does). The in-memory engine keeps the file-only reading. The upstream restore drops a pure * fan-out that covers the file's sources (it may name inherited ones too).
  • Hosted/vendored NuGet mapping isn't exclusive when nuget.config already maps the exact package id to another source, so restore races the Socket feed against nuget.org (NU1403 with a lock, silently unpatched without one) #462: set_aside_competing_patterns comments out every other (non-Socket) source's exact pattern for the id, in <!-- socket-patch-<uuid> moved: … -->. When that pattern was the element's only one, the whole <packageSource> is commented, since NuGet rejects an element with no pattern. restore_set_aside turns the comments back into the original bytes. Hosted warns redirect_nuget_mapping_set_aside, and vendored warns vendor_nuget_mapping_set_aside. Markup that can't go in a comment is skipped with redirect_nuget_mapping_conflict (vendored fails the package). The vendored whole-file revert and its excision path, and the hosted upstream restore (remove / rollback), all restore the pattern. VEX already treats only an exclusive mapping as live, so it now sees the wiring as exclusive.
  • CLI_CONTRACT.md documents the new codes and behavior.

Per-issue tests

Red→green: before the fix, the inherited-source tests get a catch-all of the file's keys only, and the #462 tests leave two sources with the exact id.

Commands run

  • cargo test -p socket-patch-core --no-fail-fast: green
  • cargo test -p socket-patch-cli --all-features --test e2e_nuget (21) and --test e2e_nuget_dotnet_build -- --ignored (2, local SDK 8.0.129): green
  • cargo clippy --workspace --all-features -- -D warnings: clean. cargo fmt --check: clean for touched files.

Review follow-ups

  • An inherited config that already has a packageSourceMapping gets no * catch-all, only the Socket pattern, because NuGet merges inherited mappings and a catch-all would widen a source they restrict.
  • The set-aside exempts only the source this run wires. A socket-patch-* lookalike key or a stale uuid that names the id is set aside like any other competitor (security review).
  • A <package> element written with a close tag is set aside whole (Bugbot).
  • The hosted engine reads the user and parent configs outside the project view and records each probed path with the view, so a re-scan's read cache keeps recording.

Notes for review

  • Machine-wide configs (/etc/opt/NuGet/Config, %ProgramFiles(x86)%\NuGet\Config) and ~/.nuget/config/*.config aren't read. A source added only at restore time on another machine (e.g. CI's dotnet nuget add source under a name the scanning machine lacks) still needs a mapping entry.
  • Includes Wire vendored nuget.config through formats::nuget (#594) #1288's commits (approved, about to merge).

🤖 Generated with Claude Code


Note

Medium Risk
Changes how nuget.config and hosted redirects are authored—incorrect mapping could break restores or route packages to the wrong feed—but behavior is fail-closed with explicit warnings and broad unit/e2e coverage.

Overview
NuGet packageSourceMapping is now written to match how NuGet actually merges configs and resolves patterns, for both vendored feeds and hosted redirects.

When creating a catch-all * mapping, the writers now include package sources inherited from the user config and parent-directory NuGet.Config files (honoring <clear />), and only seed nuget.org when that inherited set still has it—so private feeds above the project stay routable and mirrors that cleared nuget.org are not reintroduced. Hosted scan loads those keys from disk via a synthetic candidate; vendoring uses the same inherited_source_keys helper.

When another feed already maps the patched package id exactly (a common Visual Studio pattern), that competing pattern is temporarily commented out in <!-- socket-patch-<uuid> moved: … --> while Socket wiring is active, with restore on rollback/remove/vendor revert. Un-commentable markup skips the redirect with redirect_nuget_mapping_conflict; successful set-asides warn as redirect_nuget_mapping_set_aside / vendor_nuget_mapping_set_aside.

The shared formats::nuget parser now tracks sources_cleared, mapping byte spans, and replaces vendored config editing’s ad-hoc XML scanning with the same tokenizer hosted/restore/VEX use. CLI_CONTRACT.md documents the new redirect codes and vendored NuGet behavior.

Reviewed by Cursor Bugbot for commit e5e3c5a. Configure here.

Claude (claude) and others added 5 commits October 9, 2026 15:00
Assisted-by: Claude Code:claude-opus-5-5
Vendored NuGet now reads the source keys and finds the
<packageSources>, <packageSourceMapping> and <configuration> 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 (</packageSources >) is now the
  section that gets extended; vendor used to append a second section
  NuGet ignores, so restore failed NU1100/NU1403 (#685).
- An empty <packageSourceMapping /> 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
Draft placeholder while the fix is written.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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 <clear />), 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) <noreply@anthropic.com>
@mikolalysenko
Mikola Lysenko (mikolalysenko) marked this pull request as ready for review October 9, 2026 18:14
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

BugBot review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix is ON. A cloud agent has been kicked off to fix the reported issue.

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit e5e3c5a. Configure here.

} 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);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Set-aside omits package close tags

Medium Severity

Pattern spans record only the open tag of a <package> element. When that tag is not self-closing, set_aside_competing_patterns comments the opener and leaves a dangling </package>, which makes the config unparseable while the patch is wired.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e5e3c5a. Configure here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[agent] Fixed in c696181: the pattern span now runs through </package> for a non-self-closing element, so the whole element is set aside and restored byte-exact (test extended with the open/close spelling).

A <package> pattern written with a close tag (<package pattern="x">
</package>) was set aside from its open tag only, leaving a dangling
</package> 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) <noreply@anthropic.com>
} else {
inherited
};
for key in inherited {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[agent] This fans the created * catch-all out to every inherited source key, but nuget_config::inherited_source_keys (nuget_config.rs:94) only collects inherited sources, not inherited <packageSourceMapping>. NuGet merges mappings across the config chain, so if a parent/user NuGet.Config already maps e.g. nuget.org to Newtonsoft.* only and * to corp, the child's new <packageSource key="nuget.org"><package pattern="*" /> widens nuget.org to every package — re-opening the dependency-confusion path the parent mapping closes. Fix: when an inherited mapping exists, don't fan * to keys it already covers (or leave the inherited mapping governing non-socket packages and only add the socket pattern).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[agent] Fixed in 8e1d8e4. (1) Inherited mappings: inherited_sources now also reports whether any inherited config maps packages; when one does, neither writer writes a * catch-all (only the Socket pattern), since NuGet merges the inherited patterns and a catch-all here would widen a restricted source. Tests: inherited_mapping_gets_no_catch_all, inherited_mapping_is_reported, hosted nuget_created_catch_all_names_inherited_sources. (2) Set-aside: only the key this run wires is exempt; socket-patch-* lookalikes and stale uuids are set aside like any other competitor (test in competing_exact_patterns_are_set_aside_and_restored).

Comment thread crates/socket-patch-core/src/formats/nuget/mod.rs Outdated
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) <noreply@anthropic.com>
# Conflicts:
#	crates/socket-patch-core/src/formats/nuget/mod.rs
#	crates/socket-patch-core/src/vendor/nuget_feed.rs
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) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

/// The comment a writer sets a competing mapping element aside in while the
/// Socket source `key` is wired: `<!-- {key} moved: {element} -->`.
fn set_aside_open(key: &str) -> String {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Agentic Security Review

Severity: MEDIUM

Description: Hosted NuGet set-aside writes the patch-service uuid into an XML comment with no canonical-uuid or -- check. A uuid containing --> closes that comment and the remainder is committed as live nuget.config markup. Attribute encoding on the <add key> / <packageSource key> writes does not cover this prefix.

Impact: A hosted NuGet grant is only supposed to add one source and an exact-id mapping. On a file that already has another source's exact pattern, a non-canonical patch_uuid can rewrite that packageSourceMapping in place: <clear /> plus a <packageSource> whose key is not a real source makes restore skip every configured feed for every package id. NuGet keeps only the first packageSources and first packageSourceMapping element, so a second <packageSources><add> after an existing sources section is ignored and does not become an extra feed. When packageSourceMapping is ordered before packageSources, the same breakout can emit the first packageSources element (clear plus an attacker URL) and empty the mapping, so that URL is the only feed restore uses. That is a larger blast radius than the single package the grant was allowed to redirect. Cargo rewrite and vendored NuGet already reject a non-canonical uuid before writing config.

Remediation: In rewrite_nuget, skip the dependency unless patch_uuid passes is_canonical_uuid, matching rewrite_cargo and vendor_nuget. Also fail set_aside_competing_patterns when the generated key contains --, so the comment prefix cannot close itself.

Resolve conflicts:
- redirect/mod.rs: keep the PR's inherited-source/mapping synthetic keys
  and EMPTY_NUGET_CONFIG alongside main's PACKAGES_LOCK import.
- CLI_CONTRACT.md nuget row: keep the PR's exclusive/inherited mapping
  text and main's lock-at-other-version refusal
  (vendor_nuget_lock_other_version).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Picks up #1336 (Hatch pylock.toml lock-only rewrite); no conflicts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants