From 43fd5aa0354c8a1cd9477e5e6871ace26ac6a200 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 4 Nov 2026 11:03:21 -0600 Subject: [PATCH 01/22] Match ledger liveness uuids by origin, not substring Redirect-ledger liveness step 1 kept a record alive when any lock inventory entry's resolved url merely CONTAINED the patch uuid, so a url on any host spelling the uuid (or a Socket url for another patch whose grant token is that uuid) kept a dead hosted record alive. Step 1 now classifies the url with the one origin classifier, `patch::redirect::hosted_patch_uuid`, over Socket's host and the run's patch-server origins, and requires the classified uuid to equal the record's. Discovery records the origins it was run with (`Discovery::patch_server_origins`, set in `discover_with_ctx`), so liveness classifies with exactly the origins discovery did; every `LedgerLiveness` caller already holds a discovery built from the run's `DiscoverOptions`. Socket-host and configured-origin urls for the record's uuid stay live; anything else falls to step 2 (resolves elsewhere: dead). The uv script-lock regression test relied on the substring match for an unconfigured internal host; it now shows that host dead without the origin and live with it. Co-Authored-By: Claude Opus 5.5 --- .../socket-patch-core/src/vex/discover/mod.rs | 121 +++++++++++++++--- .../src/vex/discover/testing/golden.rs | 2 + 2 files changed, 108 insertions(+), 15 deletions(-) diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index 815d4644d..6f7398d55 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -760,6 +760,12 @@ pub struct Discovery { /// (#1445) ([`StaleSelector`]). Not refs and not contested wiring: the /// management commands retire them. Sorted, deduped. pub stale_selectors: Vec, + /// The operator's patch-server origins ([`DiscoverOptions::patch_server_origins`]) + /// this discovery classified urls with. Ledger liveness + /// ([`Discovery::redirect_record_live`]) classifies the lock inventory's + /// urls with the same origins, so a record discovery does not claim is + /// judged by the one origin rule ([`crate::patch::redirect::hosted_patch_uuid`]). + pub patch_server_origins: Vec, } /// A Socket-hosted yarn berry `resolutions` selector whose lock entry is @@ -1544,6 +1550,7 @@ async fn discover_with_ctx(mut ctx: DiscoverCtx<'_>) -> Discovery { out.unattest_unwired_copies(); out.recognized.extend(ctx.take_recognized()); out.read.extend(ctx.take_read()); + out.patch_server_origins = ctx.patch_server_origins.to_vec(); out.finalize(); out } @@ -2755,8 +2762,11 @@ impl Discovery { /// `source "" do` block still declares the gem /// ([`gem::gemfile_source_block_pins`]): live — bundler re-resolves /// from it whatever the lock says; - /// 1. a lock inventory entry for this purl whose resolved url carries - /// the uuid: live; + /// 1. a lock inventory entry for this purl whose resolved url is a + /// Socket-hosted url for THIS uuid + /// ([`crate::patch::redirect::hosted_patch_uuid`] over Socket's host + /// and [`Discovery::patch_server_origins`]): live. A url on any other + /// host that merely contains the uuid is not a patch reference; /// 2. a lock inventory entry that resolves the purl from somewhere ELSE /// (a registry url, a crates.io-checksummed Cargo.lock source): dead /// — the lock positively says the build fetches the unpatched @@ -2823,10 +2833,12 @@ impl Discovery { .iter() .filter(|e| lookup(std::slice::from_ref(*e), purl).is_some()) .collect(); - if entries - .iter() - .any(|e| e.resolved.as_deref().is_some_and(|r| r.contains(uuid))) - { + if entries.iter().any(|e| { + e.resolved.as_deref().is_some_and(|r| { + crate::patch::redirect::hosted_patch_uuid(r, &self.patch_server_origins).as_deref() + == Some(uuid) + }) + }) { return true; } if entries.iter().any(|e| resolves_elsewhere(e)) { @@ -2929,9 +2941,10 @@ impl<'a> LedgerLiveness<'a> { } } -/// A lock inventory entry (already known NOT to carry the patch uuid in its -/// resolved url) that positively resolves from a non-patch source: an -/// explicit artifact url, or a crates.io Cargo.lock source (the registry +/// A lock inventory entry (already known NOT to resolve from the record's +/// patch: its resolved url, if any, is no Socket-hosted url for that uuid) +/// that positively resolves from somewhere else: an explicit artifact url +/// (another host, or another patch), or a crates.io Cargo.lock source (the registry /// view's [`SourceKind::CratesIo`]; Socket sparse sources carry no url in /// the inventory and so prove nothing either way). An entry with no url is /// the conventional registry for most formats but is also how the inventory @@ -3900,12 +3913,13 @@ mod tests { } /// REGRESSION: step 2's negative evidence reads EVERY lock's instance. - /// `uv.lock` routes six to a hosted wheel on a patch host outside the - /// discovery allowlist (no `hosted_claim`), and a PEP 723 script lock - /// that sorts first resolves the same six from PyPI. The collapsed - /// inventory keeps only the script lock's instance, which made the - /// record look resolved-elsewhere (dead) although the project lock still - /// installs the patch. + /// `uv.lock` routes six to a hosted wheel on the operator's configured + /// patch server, which a discovery that holds no claim on the uuid + /// classifies with the run's origins, and a PEP 723 script lock that + /// sorts first resolves the same six from PyPI. The collapsed inventory + /// keeps only the script lock's instance, which made the record look + /// resolved-elsewhere (dead) although the project lock still installs + /// the patch. #[tokio::test] async fn a_script_lock_registry_instance_does_not_veto_the_hosted_project_lock() { let p = Project::new(); @@ -3964,6 +3978,19 @@ mod tests { None, "host not allowlisted" ); + // Without the origin, the internal host's url is no patch reference + // however much of the uuid it spells: dead. + let mut inventory = None; + assert!( + !discovery + .redirect_record_live(root, purl, UUID_A, &["uv.lock"], &mut inventory) + .await, + "a uuid on an unconfigured host keeps nothing alive" + ); + let discovery = Discovery { + patch_server_origins: vec!["https://patches.internal.example".to_string()], + ..Discovery::default() + }; let mut inventory = None; assert!( discovery @@ -4096,6 +4123,70 @@ mod tests { assert!(!hosted_wiring_in_files(root, &["package-lock.json"], "not-a-uuid").await); } + /// REGRESSION (P6): step 1 classifies a lock entry's resolved url with + /// the one origin rule ([`crate::patch::redirect::hosted_patch_uuid`]), + /// never by substring. A url on another host that merely spells the + /// record's uuid — even in Socket's own path shape — resolves the + /// package from somewhere else (step 2: dead), as does a Socket url for + /// ANOTHER patch whose path also carries the uuid (as its grant + /// token). A Socket-host url and a configured origin's url for the + /// record's uuid stay live. + #[tokio::test] + async fn liveness_matches_the_resolved_url_by_origin_not_substring() { + let origin = "http://127.0.0.1:4545"; + let purl = "pkg:npm/x@1.0.0"; + let elsewhere = + format!("https://evil.example/patch/npm/x/1.0.0/{TOKEN}/{UUID_A}/x-1.0.0.tgz"); + let other_patch = + format!("https://patch.socket.dev/patch/npm/x/1.0.0/{UUID_A}/{UUID_B}/x-1.0.0.tgz"); + let socket = hosted_url("npm", "x", "1.0.0", UUID_A, "x-1.0.0.tgz"); + let configured = format!("{origin}/patch/npm/x/1.0.0/{TOKEN}/{UUID_A}/x-1.0.0.tgz"); + let with_origin = Discovery { + patch_server_origins: vec![origin.to_string()], + ..Discovery::default() + }; + for (resolved, origins_discovery, live) in [ + (&elsewhere, &with_origin, false), + (&other_patch, &with_origin, false), + (&configured, &Discovery::default(), false), + (&socket, &Discovery::default(), true), + (&configured, &with_origin, true), + ] { + let p = Project::new(); + p.write( + "package-lock.json", + format!( + r#"{{"lockfileVersion":3,"packages":{{"":{{}},"node_modules/x":{{"version":"1.0.0","resolved":"{resolved}"}}}}}}"# + ), + ); + let mut inventory = None; + assert_eq!( + origins_discovery + .redirect_record_live( + p.root(), + purl, + UUID_A, + &["package-lock.json"], + &mut inventory, + ) + .await, + live, + "{resolved} (origins {:?})", + origins_discovery.patch_server_origins + ); + } + } + + /// Discovery records the origins it classified with, for liveness. + #[tokio::test] + async fn discovery_carries_its_patch_server_origins() { + let p = Project::new().with_origin("http://127.0.0.1:4545"); + assert_eq!( + p.discover().await.patch_server_origins, + vec!["http://127.0.0.1:4545".to_string()] + ); + } + /// [`LedgerLiveness`]: the redirect ledger's edit files are sorted and /// deduplicated, verdicts are the [`Discovery`] rule's, and the lock /// inventory loads only when a verdict first needs it and then once per diff --git a/crates/socket-patch-core/src/vex/discover/testing/golden.rs b/crates/socket-patch-core/src/vex/discover/testing/golden.rs index 54509be0e..6b14d0c4a 100644 --- a/crates/socket-patch-core/src/vex/discover/testing/golden.rs +++ b/crates/socket-patch-core/src/vex/discover/testing/golden.rs @@ -137,6 +137,8 @@ fn render(out: &Discovery, root: &Path) -> Value { unwired_copies: _, // Already folded into `unpatched_copies` by the time a run returns. unidentified_copies: _, + // The run's input (the golden runs with none), not a finding. + patch_server_origins: _, } = out; let render_ref = |r: &PatchedRef| { let PatchedRef { From 1a043ece081ee467611c4f314beddfe3e64b7890 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 4 Nov 2026 11:03:42 -0600 Subject: [PATCH 02/22] Read Cargo workspace members in VEX discovery Hosted mode pins a patched crate in the root Cargo.toml AND every workspace-member manifest that declares it, and vendored mode's live-redirect refusal reads member manifests too. Discovery read only the root manifest, so a crate the root does not declare was "the lock is the only word": a member whose declaration was reverted to crates.io (no `registry = "socket-patch-"`) left a ref attested although cargo re-resolves that dependency from crates.io. Discovery now enumerates the members the way the hosted rewriter does (`cargo_workspace::member_manifests_in`, filtered by the rewriter's own `is_cargo_member_manifest_key`, so nothing under `.socket/` or `target/`) and applies its manifest-declaration check across root + members. Each declaration carries its file: diagnostics, the conflict-pair Cargo.toml ref and lockless `UnlockedPin`s name the member manifest that holds the pin. `[patch]` stays root-only (cargo ignores a member's). The member walk over a `DiskSnapshot` now notes every manifest it reads and directory it probes or lists with the snapshot instead of asking for its raw root, so a discovery run under a read-set recording (the re-scan cache) keeps the recording usable. Co-Authored-By: Claude Opus 5.5 --- .../src/patch/redirect/mod.rs | 2 +- .../src/utils/cargo_workspace.rs | 77 +++- .../src/vendor/lock_inventory/view.rs | 6 +- .../src/vex/discover/cargo.rs | 345 +++++++++++++++--- 4 files changed, 374 insertions(+), 56 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 0c778ac74..f78b4e356 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -2415,7 +2415,7 @@ fn crlf_to_lf(text: &str) -> Option { /// A workspace-member manifest key the caller supplied: `/Cargo.toml`, /// a plain repo-relative path (never absolute, never `..`, never under the /// ledger's `.socket/` or a build `target/`). -fn is_cargo_member_manifest_key(key: &str) -> bool { +pub(crate) fn is_cargo_member_manifest_key(key: &str) -> bool { let Some(dir) = key.strip_suffix("/Cargo.toml") else { return false; }; diff --git a/crates/socket-patch-core/src/utils/cargo_workspace.rs b/crates/socket-patch-core/src/utils/cargo_workspace.rs index db99948a0..a7ec90826 100644 --- a/crates/socket-patch-core/src/utils/cargo_workspace.rs +++ b/crates/socket-patch-core/src/utils/cargo_workspace.rs @@ -18,7 +18,7 @@ use std::sync::Arc; use toml_edit::{DocumentMut, Item, Table}; use crate::utils::workspace_globs::segment_glob_matches; -use crate::vendor::lock_inventory::view::{MemoryProject, ProjectView}; +use crate::vendor::lock_inventory::view::{DiskSnapshot, MemoryProject, ProjectView}; use crate::vendor::parse_memo::ParseMemo; /// Upper bound on discovered manifests — a runaway glob (or a hostile tree) @@ -36,16 +36,47 @@ pub fn member_manifests(root: &Path) -> Vec { /// reads the manifests the host supplied and expands member globs against /// the supplied paths (a directory exists when some supplied path lives /// under it; symbolic links are never directories). +/// +/// Over a [`DiskSnapshot`] the walk reads the disk, but notes every +/// manifest it reads and every directory it probes or lists with the +/// snapshot, so a recording read set covers the walk (asking for the +/// snapshot's raw root would end the recording). pub fn member_manifests_in(view: &ProjectView<'_>) -> Vec { match view { - ProjectView::Disk(_) | ProjectView::Snapshot(_) => { - let root = view.disk_root().expect("a disk view has a root"); - member_manifests(root) - } + ProjectView::Disk(root) => member_manifests(root), + ProjectView::Snapshot(snap) => member_manifests_with(&SnapshotTree(snap)), ProjectView::Memory(project) => member_manifests_with(&MemoryTree(project)), } } +/// The disk under a [`DiskSnapshot`], each access noted with it first. +struct SnapshotTree<'a, 'b>(&'a DiskSnapshot<'b>); + +impl SnapshotTree<'_, '_> { + fn disk(&self) -> DiskTree<'_> { + DiskTree(self.0.root_reading(std::iter::empty::<&str>())) + } +} + +impl Tree for SnapshotTree<'_, '_> { + fn read_manifest(&self, rel: &str) -> Option> { + self.0.touch(rel); + self.disk().read_manifest(rel) + } +} + +impl DirTree for SnapshotTree<'_, '_> { + fn is_real_dir(&self, rel: &str) -> bool { + self.0.touch(rel); + self.disk().is_real_dir(rel) + } + + fn child_dirs(&self, rel: &str) -> Option> { + self.0.touch_listing(rel); + self.disk().child_dirs(rel) + } +} + /// The two directory questions a glob walk asks, keyed by `/`-separated /// root-relative paths (`""` is the root). Shared with the pnpm workspace /// member walk ([`crate::utils::pnpm_workspace`]). @@ -478,6 +509,42 @@ mod tests { format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\n") } + /// Over a tracked snapshot the walk answers as on disk and keeps the + /// recording usable: it notes what it reads and lists, so a member + /// added under a glob afterwards changes the read set. + #[test] + fn snapshot_walk_records_what_it_reads() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write( + root, + "Cargo.toml", + "[workspace]\nmembers = [\"crates/*\"]\n", + ); + write(root, "crates/a/Cargo.toml", &pkg("a")); + // Files stamped just now are racy: the walk reads around the + // snapshot's content cache, so such a read set never holds. + let old = std::time::SystemTime::now() - std::time::Duration::from_secs(3600); + for rel in ["Cargo.toml", "crates/a/Cargo.toml"] { + std::fs::File::options() + .write(true) + .open(root.join(rel)) + .unwrap() + .set_modified(old) + .unwrap(); + } + let snap = DiskSnapshot::tracked(root); + snap.begin_recording(); + assert_eq!( + member_manifests_in(&ProjectView::Snapshot(&snap)), + member_manifests(root) + ); + let reads = snap.end_recording().expect("the walk keeps the recording"); + assert!(reads.unchanged()); + write(root, "crates/b/Cargo.toml", &pkg("b")); + assert!(!reads.unchanged(), "a new member dir changes the listing"); + } + #[test] fn members_globs_excludes_and_path_dependencies() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/view.rs b/crates/socket-patch-core/src/vendor/lock_inventory/view.rs index c55ec4549..0383d870f 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/view.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/view.rs @@ -501,12 +501,14 @@ impl<'a> DiskSnapshot<'a> { } /// Note that `rel` is about to be read or probed (see [`Self::tracked`]). - fn touch(&self, rel: &str) { + /// Crate-visible for a walk the view does not mediate that reads only + /// what it touches (the cargo workspace member walk). + pub(crate) fn touch(&self, rel: &str) { self.touch_as(rel, Access::Probe); } /// Note that directory `rel` is about to be listed. - fn touch_listing(&self, rel: &str) { + pub(crate) fn touch_listing(&self, rel: &str) { self.touch_as(rel, Access::List); } diff --git a/crates/socket-patch-core/src/vex/discover/cargo.rs b/crates/socket-patch-core/src/vex/discover/cargo.rs index 40e6ab008..81e6a1032 100644 --- a/crates/socket-patch-core/src/vex/discover/cargo.rs +++ b/crates/socket-patch-core/src/vex/discover/cargo.rs @@ -29,12 +29,17 @@ //! (cargo < 1.41) files it in `[metadata]` as `"checksum //! ()"` instead — read as the same pin. //! -//! `Cargo.toml` decides whether cargo still USES that entry (rule 10): cargo +//! The manifests decide whether cargo still USES that entry (rule 10): cargo //! re-resolves a locked package whose source no longer matches the -//! manifest's declaration. So for the lock entry's crate: +//! manifest's declaration. The manifests are the root `Cargo.toml` and every +//! workspace member's (`/Cargo.toml`, enumerated as the hosted rewriter +//! enumerates the manifests it pins: +//! [`crate::utils::cargo_workspace::member_manifests_in`], minus `.socket/` +//! and `target/`), whose declarations count alike. So for the lock entry's +//! crate: //! -//! * not declared in the root `Cargo.toml` at all (a workspace member's dep -//! the root lock describes; no / unparseable manifest) → ref; +//! * not declared in any of them (a transitive-only crate; no / unparseable +//! root manifest) → ref; //! * some declaration pins `socket-patch-` (the same uuid) → ref; //! * declarations pin only OTHER `socket-patch-` registries → the lock //! ref AND a `Cargo.toml` ref for `` at the locked version (no pin): @@ -147,27 +152,42 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { let lock = load_lock(ctx, out).await; let manifest = read_toml(ctx, CARGO_TOML, out).await; let config = read_config(ctx, out).await; + // The root manifest, then every workspace member's — the manifests the + // hosted rewriter pins a crate in (each that declares it). + let manifests: Vec<(String, DocumentMut)> = match &manifest { + Some(root) => { + let mut manifests = vec![(CARGO_TOML.to_string(), root.clone())]; + manifests.extend(read_members(ctx, out).await); + manifests + } + None => Vec::new(), + }; - // Crate name → the `registry` value of each root-manifest declaration - // (`None` = declared with no registry key). Absent map = no usable - // manifest. + // Crate name → each declaration of it in the root or a member manifest + // (its file and `registry` value; `None` = declared with no registry + // key). Absent map = no usable root manifest. let decls = manifest.as_ref().map(|doc| { - let mut decls = manifest_declarations(doc); + let mut decls = manifest_declarations(&manifests); + // `[patch]` is read from the root manifest only: cargo ignores (and + // warns about) a member's. for patch in patch_entries(doc) { if let Some(reg) = patch.registry { let name = patch.name.to_string(); - decls.entry(name).or_default().push(Some(reg.to_string())); + decls + .entry(name) + .or_default() + .push(Decl::new(CARGO_TOML, Some(reg))); } } decls }); // The config's `[patch]` registry pins are also declarations cargo - // honors (only matters for crates the manifest also declares). + // honors (only matters for crates the manifests also declare). let decls = decls.map(|mut decls| { - if let Some((_, doc)) = &config { + if let Some((file, doc)) = &config { for patch in patch_entries(doc) { if let (Some(reg), Some(existing)) = (patch.registry, decls.get_mut(patch.name)) { - existing.push(Some(reg.to_string())); + existing.push(Decl::new(file, Some(reg))); } } } @@ -180,8 +200,8 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { hosted_from_lock(ctx, &lock, decls.as_ref(), &definitions, out); unpatched_twins(ctx, &lock, out); - if let (Some(decls), Some(doc)) = (&decls, &manifest) { - unresolved_manifest_pins(ctx, &lock, decls, doc, &definitions, out); + if let Some(decls) = &decls { + unresolved_manifest_pins(ctx, &lock, decls, &manifests, &definitions, out); } if let Some(doc) = &manifest { // Cargo drops a manifest crates.io `[patch]` item that the project @@ -311,6 +331,25 @@ async fn read_toml(ctx: &DiscoverCtx<'_>, file: &str, out: &mut Discovery) -> Op parse_toml(file, &text, out) } +/// Every workspace member manifest (`/Cargo.toml`) of the root one, +/// parsed — the set the hosted rewriter pins in +/// ([`crate::utils::cargo_workspace::member_manifests_in`], keeping the +/// keys it accepts: never under `.socket/` — a vendored copy is Socket's, +/// not a declaration — or a build `target/`). Missing / unparseable ones +/// are skipped (the latter diagnosed). +async fn read_members(ctx: &DiscoverCtx<'_>, out: &mut Discovery) -> Vec<(String, DocumentMut)> { + let mut members = Vec::new(); + for rel in crate::utils::cargo_workspace::member_manifests_in(&ctx.view) { + if !crate::patch::redirect::is_cargo_member_manifest_key(&rel) { + continue; + } + if let Some(doc) = read_toml(ctx, &rel, out).await { + members.push((rel, doc)); + } + } + members +} + fn parse_toml(file: &str, text: &str, out: &mut Discovery) -> Option { toml_or_diag(file, text, TomlDiag::TrimEnd, out).map(toml_edit::Document::into_mut) } @@ -355,19 +394,54 @@ async fn read_config( // ── manifest / config shapes ───────────────────────────────────────────── -/// Every dependency declaration in a manifest, keyed by the crate it names -/// (`package = "…"` when renamed, else the key): the `registry` value of -/// each (`None` = no registry key). `workspace = true` inheritors are -/// skipped — the `[workspace.dependencies]` entry they inherit is itself -/// collected. -fn manifest_declarations(doc: &DocumentMut) -> BTreeMap>> { - let mut decls: BTreeMap>> = BTreeMap::new(); - for dep in dependency_entries(doc) { - decls.entry(dep.name).or_default().push(dep.registry); +/// One declaration of a crate: the root-relative file holding it and its +/// `registry` value (`None` = no registry key). +#[derive(Debug, Clone)] +struct Decl { + file: String, + registry: Option, +} + +impl Decl { + fn new(file: &str, registry: Option<&str>) -> Self { + Decl { + file: file.to_string(), + registry: registry.map(str::to_string), + } + } +} + +/// Crate name → its declarations ([`Decl`]). +type Decls = BTreeMap>; + +/// Every dependency declaration in `manifests` (root first, then members), +/// keyed by the crate it names (`package = "…"` when renamed, else the +/// key). `workspace = true` inheritors are skipped — the root's +/// `[workspace.dependencies]` entry they inherit is itself collected. +fn manifest_declarations(manifests: &[(String, DocumentMut)]) -> Decls { + let mut decls = Decls::new(); + for (file, doc) in manifests { + for dep in dependency_entries(doc) { + decls + .entry(dep.name) + .or_default() + .push(Decl::new(file, dep.registry.as_deref())); + } } decls } +/// The distinct files among `decls`, in order, joined for a message. +fn decl_files(decls: &[Decl]) -> String { + let mut files: Vec<&str> = Vec::new(); + for d in decls { + if !files.contains(&d.file.as_str()) { + files.push(&d.file); + } + } + files.join(", ") +} + /// One manifest dependency declaration (see [`manifest_declarations`]). struct DepEntry { name: String, @@ -437,7 +511,7 @@ fn source_uuid(ctx: &DiscoverCtx<'_>, source: &str) -> Option { fn hosted_from_lock( ctx: &DiscoverCtx<'_>, lock: &Lock, - decls: Option<&BTreeMap>>>, + decls: Option<&Decls>, definitions: &BTreeMap, out: &mut Discovery, ) { @@ -488,43 +562,53 @@ fn hosted_from_lock( true, ); let Some(regs) = decls.and_then(|d| d.get(&pkg.name)) else { - // Not declared by the root manifest (a member's dependency the - // root lock describes), or no usable manifest: the lock is the - // only word. + // Declared by no manifest (a transitive-only crate), or no + // usable root manifest: the lock is the only word. out.push(lock_ref); continue; }; - if regs.iter().any(|r| r.as_deref() == Some(reg.as_str())) { + if regs + .iter() + .any(|d| d.registry.as_deref() == Some(reg.as_str())) + { out.push(lock_ref); continue; } - let others: Vec = regs + let others: Vec<(&str, String)> = regs .iter() - .flatten() - .filter_map(|r| socket_patch_name_uuid(r, false)) + .filter_map(|d| { + let other = socket_patch_name_uuid(d.registry.as_deref()?, false)?; + Some((d.file.as_str(), other)) + }) .collect(); if others.is_empty() { + let files = decl_files(regs); + let verb = if files.contains(", ") { + "declare" + } else { + "declares" + }; out.diag( DIAG_REF_INVALID, CARGO_LOCK, format!( - "{CARGO_LOCK}: {purl} resolves from Socket patch {uuid}, but {CARGO_TOML} \ - declares {} without registry = \"{reg}\" — cargo re-resolves it from the \ + "{CARGO_LOCK}: {purl} resolves from Socket patch {uuid}, but {files} \ + {verb} {} without registry = \"{reg}\" — cargo re-resolves it from the \ declared source; not counted", pkg.name ), ); continue; } - // The manifest routes the crate to a DIFFERENT Socket patch than the + // A manifest routes the crate to a DIFFERENT Socket patch than the // lock pins: emit both wirings and let the CLI's conflict gate refuse // to pick one. out.push(lock_ref); - for other in others { + for (file, other) in others { out.push(PatchedRef::hosted( purl.clone(), other, - CARGO_TOML, + file, None, None, true, @@ -592,30 +676,37 @@ fn unpatched_twins(ctx: &DiscoverCtx<'_>, lock: &Lock, out: &mut Discovery) { /// declaration of it routes to `socket-patch-`, the project config defines /// that registry on the Socket host for the same `U`, and that registry /// serves only the patched version. It is recorded as an [`UnlockedPin`] -/// carrying the manifest's version requirements, so a redirect-ledger record +/// carrying the manifests' version requirements, so a redirect-ledger record /// whose exact version satisfies them stays live /// ([`Discovery::hosted_claim`]); it is still not a ref (no version to /// attest on its own). fn unresolved_manifest_pins( ctx: &DiscoverCtx<'_>, lock: &Lock, - decls: &BTreeMap>>, - manifest: &DocumentMut, + decls: &Decls, + manifests: &[(String, DocumentMut)], definitions: &BTreeMap, out: &mut Discovery, ) { - let deps = dependency_entries(manifest); + let deps: Vec = manifests + .iter() + .flat_map(|(_, doc)| dependency_entries(doc)) + .collect(); for (name, regs) in decls { - for reg in regs.iter().flatten() { + for decl in regs { + let Some(reg) = decl.registry.as_ref() else { + continue; + }; + let file = decl.file.as_str(); if !reg.trim().starts_with(PIN_NAME_PREFIX) { continue; } let Some(uuid) = socket_patch_name_uuid(reg, false) else { out.diag( DIAG_REF_INVALID, - CARGO_TOML, + file, format!( - "{CARGO_TOML}: {name} is pinned to registry {reg:?}, which is not a \ + "{file}: {name} is pinned to registry {reg:?}, which is not a \ socket-patch- name" ), ); @@ -634,14 +725,14 @@ fn unresolved_manifest_pins( let why = match lock { Lock::Unparseable => continue, // already diagnosed Lock::Absent => { - let only_this_registry = regs.iter().all(|r| r.as_deref() == Some(reg)); + let only_this_registry = regs.iter().all(|d| d.registry.as_ref() == Some(reg)); let defined_here = definitions .get(reg.as_str()) .and_then(|index| source_uuid(ctx, index)) .is_some_and(|u| u == uuid); let version_reqs: Vec = deps .iter() - .filter(|d| d.name == *name && d.registry.as_deref() == Some(reg)) + .filter(|d| d.name == *name && d.registry.as_ref() == Some(reg)) .filter_map(|d| d.version.clone()) .collect(); if only_this_registry && defined_here && !version_reqs.is_empty() { @@ -649,7 +740,7 @@ fn unresolved_manifest_pins( ecosystem: "cargo".to_string(), name: name.clone(), uuid: uuid.clone(), - file: CARGO_TOML.into(), + file: file.into(), version_reqs, index_url: definitions.get(reg.as_str()).cloned(), }); @@ -664,8 +755,8 @@ fn unresolved_manifest_pins( }; out.diag( DIAG_REF_UNATTRIBUTABLE, - CARGO_TOML, - format!("{CARGO_TOML}: {name} is pinned to Socket patch {uuid}, but {why}"), + file, + format!("{file}: {name} is pinned to Socket patch {uuid}, but {why}"), ); } } @@ -1362,6 +1453,164 @@ mod tests { } } + /// A workspace root that declares nothing itself, with one member. + fn workspace_with_member(p: &Project, member_deps: &str) { + p.write("Cargo.toml", "[workspace]\nmembers = [\"crates/*\"]\n"); + p.write( + "crates/a/Cargo.toml", + format!( + "[package]\nname = \"a\"\nversion = \"0.1.0\"\n\n[dependencies]\n{member_deps}\n" + ), + ); + } + + /// REGRESSION (C2): the hosted rewriter pins the crate in every member + /// manifest that declares it, so a member's declaration decides like the + /// root's. A member reverted to crates.io (the root declares nothing) + /// makes cargo re-resolve from crates.io: no ref, diagnosed against the + /// member, and the stale lock entry's patch is recognized (a ledger + /// record for it is dead). + #[tokio::test] + async fn reverted_member_manifest_pin_drops_the_stale_lock_entry() { + for decl in [ + "serde = \"1.0.1\"".to_string(), + "serde = { version = \"1\", registry = \"my-corp\" }".to_string(), + ] { + let p = Project::new(); + workspace_with_member(&p, &decl); + p.write( + "Cargo.lock", + lock(&[("serde", "1.0.1", Some(&index(UUID_A)), Some(CKSUM))]), + ); + p.write(".cargo/config.toml", registry_block(UUID_A)); + let out = run(&p).await; + assert_refs(&out, &[]); + assert_eq!(diag_codes(&out), vec![DIAG_REF_INVALID], "{decl}"); + assert!( + out.diagnostics[0].detail.contains("crates/a/Cargo.toml"), + "{:#?}", + out.diagnostics + ); + assert_eq!( + out.hosted_claim("pkg:cargo/serde@1.0.1", UUID_A), + Some(false), + "{decl}" + ); + } + } + + /// The member's own pin keeps the lock entry a ref; a member pin for + /// ANOTHER patch emits the conflict pair, attributed to the member. + #[tokio::test] + async fn member_manifest_pins_count_like_the_roots() { + let p = Project::new(); + workspace_with_member(&p, &pinned("serde", "1", UUID_A)); + p.write( + "Cargo.lock", + lock(&[("serde", "1.0.1", Some(&index(UUID_A)), Some(CKSUM))]), + ); + let out = run(&p).await; + assert_refs( + &out, + &[("pkg:cargo/serde@1.0.1", UUID_A, WiringMode::Hosted)], + ); + assert!(out.diagnostics.is_empty(), "{:#?}", out.diagnostics); + + let p = Project::new(); + workspace_with_member(&p, &pinned("serde", "1", UUID_B)); + p.write( + "Cargo.lock", + lock(&[("serde", "1.0.1", Some(&index(UUID_A)), Some(CKSUM))]), + ); + let out = run(&p).await; + assert_refs( + &out, + &[ + ("pkg:cargo/serde@1.0.1", UUID_A, WiringMode::Hosted), + ("pkg:cargo/serde@1.0.1", UUID_B, WiringMode::Hosted), + ], + ); + let toml_ref = out.refs.iter().find(|r| r.uuid == UUID_B).unwrap(); + assert_eq!( + toml_ref.source_file, + std::path::PathBuf::from("crates/a/Cargo.toml") + ); + } + + /// The root's pin still decides when a member declares the crate under + /// another version requirement the pin does not reach (the rewriter + /// leaves such a declaration alone): some declaration pins the patch. + #[tokio::test] + async fn a_root_pin_with_an_unpinned_member_declaration_is_a_ref() { + let p = Project::new(); + p.write( + "Cargo.toml", + format!( + "[workspace]\nmembers = [\"crates/*\"]\n\n[package]\nname = \"app\"\n\ + version = \"0.1.0\"\n\n[dependencies]\n{}\n", + pinned("serde", "1", UUID_A) + ), + ); + p.write( + "crates/a/Cargo.toml", + "[package]\nname = \"a\"\nversion = \"0.1.0\"\n\n[dependencies]\nserde = \"0.9\"\n", + ); + p.write( + "Cargo.lock", + lock(&[("serde", "1.0.1", Some(&index(UUID_A)), Some(CKSUM))]), + ); + let out = run(&p).await; + assert_refs( + &out, + &[("pkg:cargo/serde@1.0.1", UUID_A, WiringMode::Hosted)], + ); + } + + /// A manifest under `.socket/` (a path dependency into Socket's own + /// tree, as a vendored copy is) is not a declaration the hosted + /// rewriter pins, so it never decides: the lock stays the only word. + #[tokio::test] + async fn manifests_under_dot_socket_are_not_declarations() { + let p = Project::new(); + p.write( + "Cargo.toml", + manifest("local = { path = \".socket/local\" }"), + ); + p.write( + ".socket/local/Cargo.toml", + "[package]\nname = \"local\"\nversion = \"0.1.0\"\n\n[dependencies]\nserde = \"1\"\n", + ); + p.write( + "Cargo.lock", + lock(&[("serde", "1.0.1", Some(&index(UUID_A)), Some(CKSUM))]), + ); + let out = run(&p).await; + assert_refs( + &out, + &[("pkg:cargo/serde@1.0.1", UUID_A, WiringMode::Hosted)], + ); + } + + /// With no lock, a member's pin is an unlocked pin carrying the member's + /// requirement, attributed to the member manifest. + #[tokio::test] + async fn lockless_member_pin_is_an_unlocked_pin() { + let p = Project::new(); + workspace_with_member(&p, &pinned("serde", "1", UUID_A)); + p.write(".cargo/config.toml", registry_block(UUID_A)); + let out = run(&p).await; + assert_refs(&out, &[]); + assert_eq!(out.unlocked_pins.len(), 1, "{:#?}", out.unlocked_pins); + let pin = &out.unlocked_pins[0]; + assert_eq!(pin.file, std::path::PathBuf::from("crates/a/Cargo.toml")); + assert_eq!(pin.version_reqs, vec!["1".to_string()]); + assert_eq!(diag_codes(&out), vec![DIAG_REF_UNATTRIBUTABLE]); + assert_eq!( + out.diagnostics[0].file, + std::path::PathBuf::from("crates/a/Cargo.toml") + ); + } + /// A rotated grant token in the definition is the same patch. #[tokio::test] async fn rotated_token_in_the_definition_still_agrees() { From 650143ef4d0264e04a97a2168a39d2680f4ad90f Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 5 Nov 2026 13:46:05 -0600 Subject: [PATCH 03/22] Address review: view-mediated member walk, lockless pins, undecided liveness Cargo member walk (review 4, 6, 7): - `cargo_workspace::member_manifests_in` is now async over the ProjectView: manifests are read with `read_text` (concurrently, bounded, ordered), globs list with `list_dir`, and directory probes use the view's own mediated `is_dir` / `is_symlink`. A snapshot's overlays and content cache apply and its recording notes the walk, so fresh manifests (a `.socket/` copy followed through `[patch]` included) no longer leave a racy read set. `DiskSnapshot::touch` / `touch_listing` are private again; `SnapshotTree` is gone. The in-memory engine keeps the sync walk (`member_manifests_in_memory`); disk-only callers keep `member_manifests`. A test pins the view walk to the disk walk over globs, `**`, excludes, path dependencies, `[patch]` copies, dot / `target` dirs and a symlinked member. Discovery (review 5, 8): - Members are walked only when they can matter: a lock with a Socket-sourced package, or no lock (then parsed only when some manifest carries a `socket-patch-` pin). The walk's texts are adopted into the ctx's read log and rule-11 recognition instead of being read again. - The root `DocumentMut` is moved into the manifest list, not cloned. Lockless pins (review 1, 3): - `UnlockedPin` gains `excluded_reqs`: the requirements of the crate's unpinned declarations. The rewriter leaves a declaration alone only when its requirement excludes the patched version, so a member's `serde = "0.9"` beside a pinned `serde = "1"` no longer suppresses the pin; a ledger version must satisfy every pinned requirement and no excluded one. An unpinned declaration with no requirement still blocks. - One pin and one diagnostic per (crate, patch): the union of the pinning declarations' requirements, every pinning manifest listed once. Liveness (review 2): - A lock entry whose url has the hosted shape for the record's uuid on an origin this run was not given is `RecordLiveness::Undecided`: never attested (`redirect_record_live` / `LedgerLiveness::redirect_record` stay attestation-grade) and never judged dead. `scan`'s takeover classifier and hosted-wiring-retained advisory use the new `LedgerLiveness::redirect_record_retained`, so a record written against a patch server a later run is not pointed at keeps its wiring. A url that merely contains the uuid (file name, query) is still dead. Co-Authored-By: Claude Opus 5.5 --- .../socket-patch-cli/src/commands/scan/mod.rs | 15 +- crates/socket-patch-core/src/hosted/engine.rs | 2 +- .../src/hosted/memory/mod.rs | 4 +- .../src/utils/cargo_workspace.rs | 373 ++++++++++++---- .../src/vendor/lock_inventory/view.rs | 6 +- .../src/vex/discover/cargo.rs | 398 ++++++++++++++---- .../socket-patch-core/src/vex/discover/mod.rs | 231 ++++++++-- .../src/vex/discover/nuget.rs | 1 + .../src/vex/discover/testing/golden.rs | 11 +- 9 files changed, 827 insertions(+), 214 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index dc2ed2974..168c75e75 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -981,8 +981,10 @@ fn overlap_from_states( /// them to right now — the truth source for takeover direction. /// /// Both directions are proved by lockfile discovery with the same liveness -/// rules `vex` gates attestations on (core `Discovery::redirect_record_live` -/// / `Discovery::vendor_entry_live`). `redirect` holds the overlap PURLs the +/// rules `vex` gates attestations on (core `Discovery::redirect_record_verdict` +/// / `Discovery::vendor_entry_live`); a hosted record on a patch server this +/// run was not given (`RecordLiveness::Undecided`) counts as wired, since the +/// wiring is still there. `redirect` holds the overlap PURLs the /// lock routes to the hosted patch server (the vendored ledger entry is /// stale); `vendored` holds those it routes to a committed /// `.socket/vendor//` artifact. @@ -1067,7 +1069,7 @@ pub(super) async fn classify_overlap_takeover_with( let mut liveness = LedgerLiveness::new(cwd, discovery, None); for purl in overlap { let hosted_live = match redirect_uuid_by_purl.get(&PurlKey::new(&purl)) { - Some(uuid) => liveness.redirect_record(&purl, uuid).await, + Some(uuid) => liveness.redirect_record_retained(&purl, uuid).await, None => discovery.wires_package(&purl, WiringMode::Hosted), }; let vendored_live = match vendor_by_purl.get(&PurlKey::new(&purl)) { @@ -1391,8 +1393,9 @@ async fn gradle_scan( /// pin names the purl (`redirect_state`, the lockfiles' hosted state — see /// [`crate::commands::hosted_state_from_pins`]) AND lockfile discovery /// proves the current lockfile still routes it to that hosted patch — core -/// `Discovery::redirect_record_live`, the same liveness rule `vex` gates -/// hosted attestations on. +/// `Discovery::redirect_record_verdict`, the same liveness rule `vex` gates +/// hosted attestations on, keeping a record on a patch server this run was +/// not given (`RecordLiveness::Undecided`): its wiring is still retained. /// /// Deliberately NOT routed through [`classify_overlap_takeover`]: that /// classifier keys on purls present in BOTH ledgers, so hosted-only wiring @@ -1435,7 +1438,7 @@ pub(super) async fn hosted_wiring_retained_purls( let mut liveness = LedgerLiveness::new(cwd, &discovery, None); let mut out = Vec::new(); for (purl, uuid) in candidates { - if liveness.redirect_record(&purl, uuid).await { + if liveness.redirect_record_retained(&purl, uuid).await { out.push(purl); } } diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 09901663b..b6baa2aef 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -751,7 +751,7 @@ pub async fn read_candidate_files( // unsatisfiable. Keyed `/Cargo.toml` for the cargo rewriter. if out.files.contains_key("Cargo.toml") && candidates.iter().any(|c| c.dep.ecosystem == "cargo") { - for rel in crate::utils::cargo_workspace::member_manifests_in(view) { + for rel in crate::utils::cargo_workspace::member_manifests_in(view).await { out.read(view, unreadable, &rel).await; } } diff --git a/crates/socket-patch-core/src/hosted/memory/mod.rs b/crates/socket-patch-core/src/hosted/memory/mod.rs index 4c40d8845..8553adf4d 100644 --- a/crates/socket-patch-core/src/hosted/memory/mod.rs +++ b/crates/socket-patch-core/src/hosted/memory/mod.rs @@ -53,7 +53,7 @@ use std::time::Instant; use crate::api::client::PatchApi; use crate::api::types::{PatchResponse, PatchSearchResult}; use crate::crawlers::Ecosystem; -use crate::utils::cargo_workspace::member_manifests_in; +use crate::utils::cargo_workspace::member_manifests_in_memory; use crate::vendor::lock_inventory::{ inventory_project_diagnosed_in, MemoryEntry, MemoryProject, ProjectView, }; @@ -279,7 +279,7 @@ fn demote_cargo_members(states: &mut [RootState], warnings: &mut Vec Vec { member_manifests_with(&DiskTree(root)) } -/// [`member_manifests`] over a [`ProjectView`]: the in-memory variant -/// reads the manifests the host supplied and expands member globs against -/// the supplied paths (a directory exists when some supplied path lives -/// under it; symbolic links are never directories). -/// -/// Over a [`DiskSnapshot`] the walk reads the disk, but notes every -/// manifest it reads and every directory it probes or lists with the -/// snapshot, so a recording read set covers the walk (asking for the -/// snapshot's raw root would end the recording). -pub fn member_manifests_in(view: &ProjectView<'_>) -> Vec { - match view { - ProjectView::Disk(root) => member_manifests(root), - ProjectView::Snapshot(snap) => member_manifests_with(&SnapshotTree(snap)), - ProjectView::Memory(project) => member_manifests_with(&MemoryTree(project)), - } +/// [`member_manifests`] of an in-memory project: the manifests the host +/// supplied, member globs expanded against the supplied paths (a directory +/// exists when some supplied path lives under it; symbolic links are never +/// directories). +pub fn member_manifests_in_memory(project: &MemoryProject) -> Vec { + member_manifests_with(&MemoryTree(project)) +} + +/// [`member_manifests`] over a [`ProjectView`], every read, probe and +/// listing going through the view: a snapshot's overlays and content cache +/// apply and its recording notes the walk; in memory it reads the supplied +/// files. The manifests are read concurrently (bounded). +pub async fn member_manifests_in(view: &ProjectView<'_>) -> Vec { + member_manifest_texts_in(view) + .await + .into_iter() + .map(|(rel, _)| rel) + .collect() } -/// The disk under a [`DiskSnapshot`], each access noted with it first. -struct SnapshotTree<'a, 'b>(&'a DiskSnapshot<'b>); +/// In-flight manifest reads of one [`member_manifest_texts_in`] level. +const MEMBER_READ_CONCURRENCY: usize = 16; -impl SnapshotTree<'_, '_> { - fn disk(&self) -> DiskTree<'_> { - DiskTree(self.0.root_reading(std::iter::empty::<&str>())) +/// [`member_manifests_in`], each key with the text the walk read through +/// the view (so a caller need not read it again). +pub(crate) async fn member_manifest_texts_in(view: &ProjectView<'_>) -> Vec<(String, String)> { + let Some((facts, _)) = view_manifest(view, "Cargo.toml").await else { + return Vec::new(); + }; + let mut frontier: Vec = Vec::new(); + if let Some((members, exclude)) = &facts.workspace { + let mut excluded: BTreeSet = BTreeSet::new(); + for pattern in exclude { + excluded.extend(expand_glob_in(view, pattern, CARGO_SKIP).await); + } + for pattern in members { + for dir in expand_glob_in(view, pattern, CARGO_SKIP).await { + if !excluded.contains(&dir) { + frontier.push(dir); + } + } + } } + frontier.extend( + facts + .path_deps + .iter() + .filter_map(|dep| normalize_rel("", dep)), + ); + let mut found: BTreeMap = BTreeMap::new(); + let mut tried: BTreeSet = BTreeSet::new(); + while !frontier.is_empty() { + let mut level: Vec = Vec::new(); + for dir in std::mem::take(&mut frontier) { + if found.len() + level.len() >= MAX_MANIFESTS { + break; + } + if !walkable(&dir) || tried.contains(&dir) { + continue; + } + tried.insert(dir.clone()); + if every_segment(&dir, |at| view_is_real_dir(view, at)) { + level.push(dir); + } + } + let read: Vec<(String, Option<(Arc, String)>)> = + crate::utils::concurrent::ordered_concurrent( + level, + MEMBER_READ_CONCURRENCY, + |dir| async move { + let manifest = view_manifest(view, &format!("{dir}/Cargo.toml")).await; + (dir, manifest) + }, + ) + .collect() + .await; + for (dir, manifest) in read { + let Some((facts, text)) = manifest else { + continue; + }; + frontier.extend( + facts + .path_deps + .iter() + .filter_map(|dep| normalize_rel(&dir, dep)), + ); + found.insert(dir, text); + } + } + found + .into_iter() + .map(|(dir, text)| (format!("{dir}/Cargo.toml"), text)) + .collect() } -impl Tree for SnapshotTree<'_, '_> { - fn read_manifest(&self, rel: &str) -> Option> { - self.0.touch(rel); - self.disk().read_manifest(rel) +/// A regular (non-symlink) manifest read through the view: its walk facts +/// and its text. +async fn view_manifest(view: &ProjectView<'_>, rel: &str) -> Option<(Arc, String)> { + if view.is_symlink(rel) { + return None; } + let text = view.read_text(rel).await.ok()?; + let facts = FACTS_MEMO + .parse(text.as_bytes(), || { + text.parse::() + .map(|doc| ManifestFacts::of(&doc)) + }) + .ok()?; + Some((facts, text)) } -impl DirTree for SnapshotTree<'_, '_> { - fn is_real_dir(&self, rel: &str) -> bool { - self.0.touch(rel); - self.disk().is_real_dir(rel) - } +/// [`DirTree::is_real_dir`] through the view's own mediated probes (a +/// snapshot notes each and sees its overlays). +fn view_is_real_dir(view: &ProjectView<'_>, rel: &str) -> bool { + view.is_dir(rel) && !view.is_symlink(rel) +} - fn child_dirs(&self, rel: &str) -> Option> { - self.0.touch_listing(rel); - self.disk().child_dirs(rel) +/// [`expand_glob`] over a [`ProjectView`]: the same walk, listing through +/// the view's `list_dir` (a real directory's real sub-directories, as +/// [`DirTree::child_dirs`] answers). +async fn expand_glob_in(view: &ProjectView<'_>, pattern: &str, skip: &[&str]) -> Vec { + let Some(normalized) = normalize_rel("", pattern.trim_end_matches('/')) else { + return Vec::new(); + }; + let segments: Vec<&str> = normalized.split('/').filter(|s| !s.is_empty()).collect(); + let mut out = Vec::new(); + // [`expand_from`]'s recursion as an explicit stack, children pushed in + // reverse so they are visited in the same order. + let mut stack: Vec<(String, usize)> = vec![(String::new(), 0)]; + while let Some((at, i)) = stack.pop() { + if out.len() >= MAX_MANIFESTS { + break; + } + let Some(seg) = segments.get(i) else { + out.push(at); + continue; + }; + if !segment_is_pattern(seg) { + let next = join_rel(&at, seg); + if view_is_real_dir(view, &next) { + stack.push((next, i + 1)); + } + continue; + } + let Ok(entries) = view.list_dir(&at).await else { + continue; + }; + let children = glob_children( + entries.into_iter().filter(|e| e.is_dir).map(|e| e.name), + seg, + skip, + ); + let mut next: Vec<(String, usize)> = Vec::new(); + if *seg == "**" { + next.push((at.clone(), i + 1)); + next.extend(children.iter().map(|child| (join_rel(&at, child), i))); + } else { + let pattern = cargo_segment(seg); + for child in &children { + let name: Vec = child.chars().collect(); + if segment_glob_matches(&pattern, &name) { + next.push((join_rel(&at, child), i + 1)); + } + } + } + stack.extend(next.into_iter().rev()); } + out.sort(); + out.dedup(); + out } /// The two directory questions a glob walk asks, keyed by `/`-separated @@ -289,28 +417,36 @@ fn is_real_dir(path: &Path) -> bool { /// Every component of repo-relative `dir` is a real directory under /// the root — none is a symbolic link (which may lead outside the root). pub(crate) fn is_real_dir_path(tree: &dyn DirTree, dir: &str) -> bool { + every_segment(dir, |at| tree.is_real_dir(at)) +} + +/// Whether `real` holds for every ancestor-or-self prefix of `dir`. +fn every_segment(dir: &str, real: impl Fn(&str) -> bool) -> bool { let mut at = String::new(); dir.split('/').all(|seg| { at = join_rel(&at, seg); - tree.is_real_dir(&at) + real(&at) }) } +/// Whether the walk may enter `dir` at all. `target` is cargo's build +/// directory at every level: the glob walk already skips it, and a literal +/// `members = ["target/gen"]` or a path dependency into it names a manifest +/// the next `cargo clean` deletes — pinning it would record a hosted edit +/// whose file can vanish, blocking the rollback of every other cargo edit. +fn walkable(dir: &str) -> bool { + !dir.is_empty() && !dir.split('/').any(|seg| seg == "target") +} + fn enqueue( tree: &dyn Tree, dir: String, dirs: &mut BTreeSet, queue: &mut Vec<(String, Arc)>, ) { - // `target` is cargo's build directory at every level: the glob walk - // already skips it, and a literal `members = ["target/gen"]` or a path - // dependency into it names a manifest the next `cargo clean` deletes — - // pinning it would record a hosted edit whose file can vanish, blocking - // the rollback of every other cargo edit. - if dir.is_empty() + if !walkable(&dir) || dirs.len() >= MAX_MANIFESTS || dirs.contains(&dir) - || dir.split('/').any(|seg| seg == "target") || !is_real_dir_path(tree, &dir) { return; @@ -435,6 +571,23 @@ fn cargo_segment(seg: &str) -> Vec { seg.replace("[^", "[\0^").chars().collect() } +/// The sub-directories a wildcard segment `seg` may match, sorted: never a +/// dot-directory (unless `seg` itself starts with `.`) or a name in `skip`. +fn glob_children( + entries: impl IntoIterator, + seg: &str, + skip: &[&str], +) -> Vec { + let mut children: Vec = entries + .into_iter() + .filter(|name| { + (!name.starts_with('.') || seg.starts_with('.')) && !skip.contains(&name.as_str()) + }) + .collect(); + children.sort(); + children +} + fn expand_from( tree: &dyn DirTree, at: String, @@ -459,13 +612,7 @@ fn expand_from( let Some(entries) = tree.child_dirs(&at) else { return; }; - let mut children: Vec = entries - .into_iter() - .filter(|name| { - (!name.starts_with('.') || seg.starts_with('.')) && !skip.contains(&name.as_str()) - }) - .collect(); - children.sort(); + let children = glob_children(entries, seg, skip); if *seg == "**" { expand_from(tree, at.clone(), tail, skip, out); for child in children { @@ -509,42 +656,124 @@ mod tests { format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\n") } - /// Over a tracked snapshot the walk answers as on disk and keeps the - /// recording usable: it notes what it reads and lists, so a member - /// added under a glob afterwards changes the read set. - #[test] - fn snapshot_walk_records_what_it_reads() { + /// Over a tracked snapshot the walk reads through the view: its reads + /// fill the content cache, so files written just now (racy by stat + /// alone) — including a `.socket/` copy's manifest the walk follows + /// through `[patch]` — still leave a read set that holds; a member + /// added under a glob afterwards breaks it (the listing was recorded). + #[tokio::test] + async fn snapshot_walk_records_what_it_reads() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); write( root, "Cargo.toml", - "[workspace]\nmembers = [\"crates/*\"]\n", + "[workspace]\nmembers = [\"crates/*\"]\n\n\ + [patch.crates-io]\nserde = { path = \".socket/vendor/cargo/u1/serde-1.0.0\" }\n", ); write(root, "crates/a/Cargo.toml", &pkg("a")); - // Files stamped just now are racy: the walk reads around the - // snapshot's content cache, so such a read set never holds. - let old = std::time::SystemTime::now() - std::time::Duration::from_secs(3600); - for rel in ["Cargo.toml", "crates/a/Cargo.toml"] { - std::fs::File::options() - .write(true) - .open(root.join(rel)) - .unwrap() - .set_modified(old) - .unwrap(); - } - let snap = DiskSnapshot::tracked(root); - snap.begin_recording(); - assert_eq!( - member_manifests_in(&ProjectView::Snapshot(&snap)), - member_manifests(root) + write( + root, + ".socket/vendor/cargo/u1/serde-1.0.0/Cargo.toml", + &pkg("serde"), ); + let snap = crate::vendor::lock_inventory::DiskSnapshot::tracked(root); + snap.begin_recording(); + let walked = member_manifests_in(&ProjectView::Snapshot(&snap)).await; + assert_eq!(walked, member_manifests(root)); + assert!(walked.contains(&".socket/vendor/cargo/u1/serde-1.0.0/Cargo.toml".to_string())); let reads = snap.end_recording().expect("the walk keeps the recording"); - assert!(reads.unchanged()); + assert!(reads.unchanged(), "fresh files read through the view hold"); write(root, "crates/b/Cargo.toml", &pkg("b")); assert!(!reads.unchanged(), "a new member dir changes the listing"); } + /// A snapshot's overlays are part of the walk: an overlaid member + /// manifest (a pending write) is a member before it reaches the disk. + #[tokio::test] + async fn snapshot_walk_sees_overlays() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write( + root, + "Cargo.toml", + "[workspace]\nmembers = [\"crates/*\"]\n", + ); + write(root, "crates/a/Cargo.toml", &pkg("a")); + let snap = crate::vendor::lock_inventory::DiskSnapshot::new(root); + snap.overlay("crates/c/Cargo.toml", pkg("c").as_bytes()); + assert_eq!( + member_manifests_in(&ProjectView::Snapshot(&snap)).await, + vec!["crates/a/Cargo.toml", "crates/c/Cargo.toml"] + ); + assert_eq!(member_manifests(root), vec!["crates/a/Cargo.toml"]); + } + + /// The view walk answers exactly as the disk walk over every shape the + /// memo test covers (globs, `**`, excludes, nested path dependencies, + /// `[patch]` copies, unparseable manifests) plus a symlinked member + /// directory, and returns the text it read for each. + #[tokio::test] + async fn view_walk_matches_the_disk_walk() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write( + root, + "Cargo.toml", + "[workspace]\nmembers = [\"crates/*\", \"tools/**\", \"lit\"]\n\ + exclude = [\"crates/x\"]\n\ + [patch.crates-io]\nserde = { path = \".socket/vendor/cargo/u1/serde-1.0.0\" }\n\ + [workspace.dependencies]\nshared = { path = \"libs/shared\" }\n", + ); + write( + root, + "crates/a/Cargo.toml", + &format!("{}[dependencies.b]\npath = \"../b\"\n", pkg("a")), + ); + write(root, "crates/b/Cargo.toml", &pkg("b")); + write(root, "crates/x/Cargo.toml", &pkg("x")); + write(root, "crates/.hidden/Cargo.toml", &pkg("hidden")); + write(root, "crates/target/Cargo.toml", &pkg("target")); + write(root, "crates/bad/Cargo.toml", "not = [toml"); + write(root, "tools/deep/t/Cargo.toml", &pkg("t")); + write(root, "lit/Cargo.toml", &pkg("lit")); + write(root, "libs/shared/Cargo.toml", &pkg("shared")); + write( + root, + ".socket/vendor/cargo/u1/serde-1.0.0/Cargo.toml", + &format!( + "{}[dependencies]\nderive = {{ path = \"../derive\" }}\n", + pkg("serde") + ), + ); + write( + root, + ".socket/vendor/cargo/u1/derive/Cargo.toml", + &pkg("derive"), + ); + #[cfg(unix)] + { + write(root, "elsewhere/l/Cargo.toml", &pkg("l")); + std::os::unix::fs::symlink(root.join("elsewhere"), root.join("crates/link")).unwrap(); + } + let disk = member_manifests(root); + assert!(disk.contains(&"tools/deep/t/Cargo.toml".to_string())); + assert!(!disk.iter().any(|k| k.starts_with("crates/link"))); + assert_eq!(member_manifests_in(&ProjectView::Disk(root)).await, disk); + let snap = crate::vendor::lock_inventory::DiskSnapshot::new(root); + assert_eq!( + member_manifests_in(&ProjectView::Snapshot(&snap)).await, + disk + ); + for (rel, text) in member_manifest_texts_in(&ProjectView::Disk(root)).await { + assert_eq!( + text, + std::fs::read_to_string(root.join(&rel)).unwrap(), + "{rel}" + ); + } + } + #[test] fn members_globs_excludes_and_path_dependencies() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/view.rs b/crates/socket-patch-core/src/vendor/lock_inventory/view.rs index 0383d870f..c55ec4549 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/view.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/view.rs @@ -501,14 +501,12 @@ impl<'a> DiskSnapshot<'a> { } /// Note that `rel` is about to be read or probed (see [`Self::tracked`]). - /// Crate-visible for a walk the view does not mediate that reads only - /// what it touches (the cargo workspace member walk). - pub(crate) fn touch(&self, rel: &str) { + fn touch(&self, rel: &str) { self.touch_as(rel, Access::Probe); } /// Note that directory `rel` is about to be listed. - pub(crate) fn touch_listing(&self, rel: &str) { + fn touch_listing(&self, rel: &str) { self.touch_as(rel, Access::List); } diff --git a/crates/socket-patch-core/src/vex/discover/cargo.rs b/crates/socket-patch-core/src/vex/discover/cargo.rs index 81e6a1032..3139998ec 100644 --- a/crates/socket-patch-core/src/vex/discover/cargo.rs +++ b/crates/socket-patch-core/src/vex/discover/cargo.rs @@ -150,23 +150,22 @@ const DEP_TABLES: [&str; 5] = [ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { let lock = load_lock(ctx, out).await; - let manifest = read_toml(ctx, CARGO_TOML, out).await; + let root = read_toml(ctx, CARGO_TOML, out).await; let config = read_config(ctx, out).await; // The root manifest, then every workspace member's — the manifests the // hosted rewriter pins a crate in (each that declares it). - let manifests: Vec<(String, DocumentMut)> = match &manifest { - Some(root) => { - let mut manifests = vec![(CARGO_TOML.to_string(), root.clone())]; - manifests.extend(read_members(ctx, out).await); - manifests - } - None => Vec::new(), - }; + let mut manifests: Vec<(String, DocumentMut)> = Vec::new(); + if let Some(doc) = root { + let root_pins = manifest_has_socket_pin(&doc); + manifests.push((CARGO_TOML.to_string(), doc)); + manifests.extend(read_members(ctx, &lock, root_pins, out).await); + } + let manifest: Option<&DocumentMut> = manifests.first().map(|(_, doc)| doc); // Crate name → each declaration of it in the root or a member manifest - // (its file and `registry` value; `None` = declared with no registry - // key). Absent map = no usable root manifest. - let decls = manifest.as_ref().map(|doc| { + // (its file, `registry` value — `None` = declared with no registry key — + // and version requirement). Absent map = no usable root manifest. + let decls = manifest.map(|doc| { let mut decls = manifest_declarations(&manifests); // `[patch]` is read from the root manifest only: cargo ignores (and // warns about) a member's. @@ -176,7 +175,7 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { decls .entry(name) .or_default() - .push(Decl::new(CARGO_TOML, Some(reg))); + .push(Decl::new(CARGO_TOML, Some(reg), None)); } } decls @@ -187,7 +186,7 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { if let Some((file, doc)) = &config { for patch in patch_entries(doc) { if let (Some(reg), Some(existing)) = (patch.registry, decls.get_mut(patch.name)) { - existing.push(Decl::new(file, Some(reg))); + existing.push(Decl::new(file, Some(reg), None)); } } } @@ -201,9 +200,9 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { hosted_from_lock(ctx, &lock, decls.as_ref(), &definitions, out); unpatched_twins(ctx, &lock, out); if let Some(decls) = &decls { - unresolved_manifest_pins(ctx, &lock, decls, &manifests, &definitions, out); + unresolved_manifest_pins(ctx, &lock, decls, &definitions, out); } - if let Some(doc) = &manifest { + if let Some(doc) = manifest { // Cargo drops a manifest crates.io `[patch]` item that the project // config redefines under the same key (any version), and a // `[patch.""]` table replaces `[patch.crates-io]` @@ -243,7 +242,7 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { vendored_from_patches(ctx, file, doc, &lock, &|_| None, out).await; } let wired: Vec = manifest - .iter() + .into_iter() .chain(config.iter().map(|(_, doc)| doc)) .flat_map(socket_copies) .collect(); @@ -335,21 +334,64 @@ async fn read_toml(ctx: &DiscoverCtx<'_>, file: &str, out: &mut Discovery) -> Op /// parsed — the set the hosted rewriter pins in /// ([`crate::utils::cargo_workspace::member_manifests_in`], keeping the /// keys it accepts: never under `.socket/` — a vendored copy is Socket's, -/// not a declaration — or a build `target/`). Missing / unparseable ones -/// are skipped (the latter diagnosed). -async fn read_members(ctx: &DiscoverCtx<'_>, out: &mut Discovery) -> Vec<(String, DocumentMut)> { +/// not a declaration — or a build `target/`). Unparseable ones are skipped +/// (diagnosed). +/// +/// Members only ever decide about Socket wiring, so the walk is skipped +/// when there is none they could decide about: a parsed lock with no +/// Socket-sourced package, or an unparseable one (no ref either way). With +/// no lock, a member matters only when some manifest carries a +/// `socket-patch-` pin (a lockless pin, [`UnlockedPin`]); the walk has the +/// texts, so the parse is skipped otherwise. +async fn read_members( + ctx: &DiscoverCtx<'_>, + lock: &Lock, + root_pins: bool, + out: &mut Discovery, +) -> Vec<(String, DocumentMut)> { + let lockless = match lock { + Lock::Parsed(_) if has_socket_sourced(ctx, lock) => false, + Lock::Parsed(_) | Lock::Unparseable => return Vec::new(), + Lock::Absent => true, + }; + let texts: Vec<(String, String)> = + crate::utils::cargo_workspace::member_manifest_texts_in(&ctx.view) + .await + .into_iter() + .filter(|(rel, _)| crate::patch::redirect::is_cargo_member_manifest_key(rel)) + .collect(); + if lockless && !root_pins && !texts.iter().any(|(_, t)| t.contains(PIN_NAME_PREFIX)) { + return Vec::new(); + } let mut members = Vec::new(); - for rel in crate::utils::cargo_workspace::member_manifests_in(&ctx.view) { - if !crate::patch::redirect::is_cargo_member_manifest_key(&rel) { - continue; - } - if let Some(doc) = read_toml(ctx, &rel, out).await { + for (rel, text) in texts { + ctx.adopt_read(&rel, &text); + if let Some(doc) = parse_toml(&rel, &text, out) { members.push((rel, doc)); } } members } +/// Whether some dependency declaration of `doc` names a `socket-patch-` +/// registry. +fn manifest_has_socket_pin(doc: &DocumentMut) -> bool { + dependency_entries(doc).iter().any(|d| { + d.registry + .as_deref() + .is_some_and(|r| r.trim().starts_with(PIN_NAME_PREFIX)) + }) +} + +/// Whether the lock resolves some package from a Socket patch server. +fn has_socket_sourced(ctx: &DiscoverCtx<'_>, lock: &Lock) -> bool { + lock.packages().iter().any(|p| { + p.source + .as_deref() + .is_some_and(|s| source_uuid(ctx, s).is_some()) + }) +} + fn parse_toml(file: &str, text: &str, out: &mut Discovery) -> Option { toml_or_diag(file, text, TomlDiag::TrimEnd, out).map(toml_edit::Document::into_mut) } @@ -400,15 +442,32 @@ async fn read_config( struct Decl { file: String, registry: Option, + /// The `version` requirement (`None` for a `[patch]` registry pin, or + /// a declaration without one). + version: Option, } impl Decl { - fn new(file: &str, registry: Option<&str>) -> Self { + fn new(file: &str, registry: Option<&str>, version: Option<&str>) -> Self { Decl { file: file.to_string(), registry: registry.map(str::to_string), + version: version.map(str::to_string), } } + + /// The Socket patch uuid a well-formed `socket-patch-` registry + /// names. + fn socket_uuid(&self) -> Option { + socket_patch_name_uuid(self.registry.as_deref()?, false) + } + + /// Whether the registry is `socket-patch-…`-shaped (well-formed or not). + fn socket_shaped(&self) -> bool { + self.registry + .as_deref() + .is_some_and(|r| r.trim().starts_with(PIN_NAME_PREFIX)) + } } /// Crate name → its declarations ([`Decl`]). @@ -422,17 +481,29 @@ fn manifest_declarations(manifests: &[(String, DocumentMut)]) -> Decls { let mut decls = Decls::new(); for (file, doc) in manifests { for dep in dependency_entries(doc) { - decls - .entry(dep.name) - .or_default() - .push(Decl::new(file, dep.registry.as_deref())); + decls.entry(dep.name).or_default().push(Decl::new( + file, + dep.registry.as_deref(), + dep.version.as_deref(), + )); } } decls } +/// The distinct present values, in first-seen order. +fn distinct(values: impl IntoIterator>) -> Vec { + let mut out: Vec = Vec::new(); + for value in values.into_iter().flatten() { + if !out.contains(&value) { + out.push(value); + } + } + out +} + /// The distinct files among `decls`, in order, joined for a message. -fn decl_files(decls: &[Decl]) -> String { +fn decl_files<'a>(decls: impl IntoIterator) -> String { let mut files: Vec<&str> = Vec::new(); for d in decls { if !files.contains(&d.file.as_str()) { @@ -668,81 +739,111 @@ fn unpatched_twins(ctx: &DiscoverCtx<'_>, lock: &Lock, out: &mut Discovery) { } /// Diagnose `Cargo.toml` Socket-registry pins no lock entry turns into a ref -/// (no exact version to attest), and malformed `socket-patch-*` names. +/// (no exact version to attest), and malformed `socket-patch-*` names. One +/// diagnostic per crate and patch, naming every manifest that pins it. /// /// With NO `Cargo.lock` at all (a library that gitignores it; `rewrite_cargo` /// supports that state — `LockCommit::Absent` — and writes only the pin and -/// the registry definition) a pin is still live wiring for the crate: every -/// declaration of it routes to `socket-patch-`, the project config defines -/// that registry on the Socket host for the same `U`, and that registry -/// serves only the patched version. It is recorded as an [`UnlockedPin`] -/// carrying the manifests' version requirements, so a redirect-ledger record -/// whose exact version satisfies them stays live -/// ([`Discovery::hosted_claim`]); it is still not a ref (no version to -/// attest on its own). +/// the registry definition) a pin is still live wiring for the crate: no +/// declaration routes it to another Socket registry, the project config +/// defines that registry on the Socket host for the same `U`, and that +/// registry serves only the patched version. It is recorded as ONE +/// [`UnlockedPin`] per crate and patch, carrying the pinning declarations' +/// version requirements (from the root and every member) and, as +/// `excluded_reqs`, those of the declarations without a pin: the rewriter +/// leaves a declaration alone only when its requirement excludes the +/// patched version, so a redirect-ledger record stays live +/// ([`Discovery::hosted_claim`]) when its exact version satisfies every +/// pinned requirement and no unpinned one. An unpinned declaration with no +/// version requirement (git, path) may resolve any version: no pin. It is +/// still not a ref (no version to attest on its own). fn unresolved_manifest_pins( ctx: &DiscoverCtx<'_>, lock: &Lock, decls: &Decls, - manifests: &[(String, DocumentMut)], definitions: &BTreeMap, out: &mut Discovery, ) { - let deps: Vec = manifests - .iter() - .flat_map(|(_, doc)| dependency_entries(doc)) - .collect(); for (name, regs) in decls { - for decl in regs { - let Some(reg) = decl.registry.as_ref() else { - continue; - }; - let file = decl.file.as_str(); - if !reg.trim().starts_with(PIN_NAME_PREFIX) { - continue; - } - let Some(uuid) = socket_patch_name_uuid(reg, false) else { - out.diag( - DIAG_REF_INVALID, - file, - format!( - "{file}: {name} is pinned to registry {reg:?}, which is not a \ - socket-patch- name" - ), - ); - continue; - }; - let socket_sourced = lock.packages().iter().any(|p| { - p.name == *name - && p.source - .as_deref() - .and_then(|s| source_uuid(ctx, s)) - .is_some() - }); - if socket_sourced { - continue; // a ref (or the conflict pair) came from the lock + let mut malformed: Vec<(&str, &str)> = Vec::new(); + let mut uuids: Vec = Vec::new(); + for decl in regs.iter().filter(|d| d.socket_shaped()) { + let reg = decl.registry.as_deref().unwrap_or_default(); + match decl.socket_uuid() { + Some(uuid) if !uuids.contains(&uuid) => uuids.push(uuid), + Some(_) => {} + None if !malformed.contains(&(decl.file.as_str(), reg)) => { + malformed.push((decl.file.as_str(), reg)); + } + None => {} } + } + for (file, reg) in malformed { + out.diag( + DIAG_REF_INVALID, + file, + format!( + "{file}: {name} is pinned to registry {reg:?}, which is not a \ + socket-patch- name" + ), + ); + } + if uuids.is_empty() { + continue; + } + let socket_sourced = lock.packages().iter().any(|p| { + p.name == *name + && p.source + .as_deref() + .and_then(|s| source_uuid(ctx, s)) + .is_some() + }); + if socket_sourced { + continue; // a ref (or the conflict pair) came from the lock + } + for uuid in &uuids { + let pinning: Vec<&Decl> = regs + .iter() + .filter(|d| d.socket_uuid().as_deref() == Some(uuid.as_str())) + .collect(); + let reg = pinning[0].registry.as_deref().unwrap_or_default(); + let file = pinning[0].file.as_str(); let why = match lock { Lock::Unparseable => continue, // already diagnosed Lock::Absent => { - let only_this_registry = regs.iter().all(|d| d.registry.as_ref() == Some(reg)); + // Another Socket registry (or a malformed one) for the + // same crate is a conflict, never a pin. + let only_this_patch = regs + .iter() + .filter(|d| d.socket_shaped()) + .all(|d| d.socket_uuid().as_deref() == Some(uuid.as_str())); let defined_here = definitions - .get(reg.as_str()) + .get(reg) .and_then(|index| source_uuid(ctx, index)) - .is_some_and(|u| u == uuid); - let version_reqs: Vec = deps + .is_some_and(|u| u == *uuid); + let version_reqs = distinct(pinning.iter().map(|d| d.version.clone())); + // `None` when some unpinned declaration carries no + // requirement (git, path): it may resolve any version. + let excluded_reqs: Option> = regs .iter() - .filter(|d| d.name == *name && d.registry.as_ref() == Some(reg)) - .filter_map(|d| d.version.clone()) + .filter(|d| !d.socket_shaped()) + .map(|d| d.version.clone()) .collect(); - if only_this_registry && defined_here && !version_reqs.is_empty() { + if let (true, true, false, Some(excluded_reqs)) = ( + only_this_patch, + defined_here, + version_reqs.is_empty(), + excluded_reqs, + ) { + let excluded_reqs = distinct(excluded_reqs.into_iter().map(Some)); out.unlocked_pin(UnlockedPin { ecosystem: "cargo".to_string(), name: name.clone(), uuid: uuid.clone(), file: file.into(), version_reqs, - index_url: definitions.get(reg.as_str()).cloned(), + excluded_reqs, + index_url: definitions.get(reg).cloned(), }); } format!("there is no {CARGO_LOCK} to fix its version") @@ -753,10 +854,11 @@ fn unresolved_manifest_pins( --patch-server-url)" ), }; + let files = decl_files(pinning.iter().copied()); out.diag( DIAG_REF_UNATTRIBUTABLE, file, - format!("{file}: {name} is pinned to Socket patch {uuid}, but {why}"), + format!("{files}: {name} is pinned to Socket patch {uuid}, but {why}"), ); } } @@ -1611,6 +1713,138 @@ mod tests { ); } + /// A workspace whose root pins `serde` and declares `crates/*` members. + fn pinned_root_workspace(p: &Project) { + p.write( + "Cargo.toml", + format!( + "[workspace]\nmembers = [\"crates/*\"]\n\n[package]\nname = \"app\"\n\ + version = \"0.1.0\"\n\n[dependencies]\n{}\n", + pinned("serde", "1", UUID_A) + ), + ); + } + + fn member(p: &Project, dir: &str, deps: &str) { + p.write( + &format!("crates/{dir}/Cargo.toml"), + format!("[package]\nname = \"{dir}\"\nversion = \"0.1.0\"\n\n[dependencies]\n{deps}\n"), + ); + } + + /// REVIEW (lockless members): a member's unpinned declaration at a + /// version the pin does not reach (`serde = "0.9"` beside the root's + /// pinned `serde = "1"`, which the rewriter leaves alone) keeps the + /// lockless pin; it only excludes the versions it accepts. One that + /// accepts the record's version, or that has no requirement at all (a + /// git dependency), leaves the record unrouted. + #[tokio::test] + async fn lockless_pin_survives_a_member_declaring_another_version() { + let record = "pkg:cargo/serde@1.0.1"; + for (member_decl, live) in [ + ("serde = \"0.9\"", true), + ("serde = \"1.0\"", false), + ("serde = { git = \"https://example.com/serde\" }", false), + ] { + let p = Project::new(); + pinned_root_workspace(&p); + member(&p, "a", member_decl); + p.write(".cargo/config.toml", registry_block(UUID_A)); + let out = run(&p).await; + assert_refs(&out, &[]); + assert_eq!( + out.hosted_claim(record, UUID_A), + Some(live), + "{member_decl}: {:#?}", + out.unlocked_pins + ); + } + let p = Project::new(); + pinned_root_workspace(&p); + member(&p, "a", "serde = \"0.9\""); + p.write(".cargo/config.toml", registry_block(UUID_A)); + let out = run(&p).await; + assert_eq!(out.unlocked_pins.len(), 1); + assert_eq!(out.unlocked_pins[0].excluded_reqs, vec!["0.9".to_string()]); + assert_eq!( + out.hosted_claim("pkg:cargo/serde@0.9.5", UUID_A), + Some(false), + "the member resolves 0.9 from crates.io" + ); + } + + /// REVIEW (one pin per crate and patch): the root and two members pin the + /// same patch — one lockless pin carrying every requirement, one + /// diagnostic listing each manifest once. + #[tokio::test] + async fn one_lockless_pin_and_diagnostic_per_crate_and_patch() { + let p = Project::new(); + pinned_root_workspace(&p); + member(&p, "a", &pinned("serde", "1.0", UUID_A)); + member( + &p, + "b", + &format!( + "{}\n\n[dev-dependencies]\n{}", + pinned("serde", "1", UUID_A), + pinned("serde", "1", UUID_A) + ), + ); + p.write(".cargo/config.toml", registry_block(UUID_A)); + let out = run(&p).await; + assert_eq!(out.unlocked_pins.len(), 1, "{:#?}", out.unlocked_pins); + let pin = &out.unlocked_pins[0]; + assert_eq!(pin.file, std::path::PathBuf::from("Cargo.toml")); + assert_eq!(pin.version_reqs, vec!["1".to_string(), "1.0".to_string()]); + assert!(pin.excluded_reqs.is_empty()); + assert_eq!(diag_codes(&out), vec![DIAG_REF_UNATTRIBUTABLE]); + assert!( + out.diagnostics[0] + .detail + .starts_with("Cargo.toml, crates/a/Cargo.toml, crates/b/Cargo.toml: serde"), + "{}", + out.diagnostics[0].detail + ); + } + + /// REVIEW (perf): members only matter to Socket wiring, so a project + /// with a lock that resolves nothing from a Socket patch server — or no + /// lock and no `socket-patch-` pin anywhere — reads no member manifest + /// as discovery's own (none in [`Discovery::read`], which the full run + /// collects). + #[tokio::test] + async fn members_are_read_only_when_socket_wiring_could_need_them() { + let member_read = |out: &Discovery| { + out.read + .iter() + .any(|r| r.file == std::path::Path::new("crates/a/Cargo.toml")) + }; + // A plain lock: no walk. + let p = Project::new(); + workspace_with_member(&p, "serde = \"1\""); + p.write( + "Cargo.lock", + lock(&[("serde", "1.0.1", Some(CRATES_IO), Some(CKSUM))]), + ); + assert!(!member_read(&p.discover().await)); + // No lock, no pin anywhere: walked, not adopted. + let p = Project::new(); + workspace_with_member(&p, "serde = \"1\""); + assert!(!member_read(&p.discover().await)); + // A Socket-sourced lock entry: read. + let p = Project::new(); + workspace_with_member(&p, "serde = \"1\""); + p.write( + "Cargo.lock", + lock(&[("serde", "1.0.1", Some(&index(UUID_A)), Some(CKSUM))]), + ); + assert!(member_read(&p.discover().await)); + // No lock, a member pin: read. + let p = Project::new(); + workspace_with_member(&p, &pinned("serde", "1", UUID_A)); + assert!(member_read(&p.discover().await)); + } + /// A rotated grant token in the definition is the same patch. #[tokio::test] async fn rotated_token_in_the_definition_still_agrees() { diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index 6f7398d55..9b128f8b1 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -415,6 +415,13 @@ pub struct UnlockedPin { /// `version = "…"`); a ledger version must satisfy every one. Empty = /// none to check (nuget: the csproj `Version` is only a minimum). pub version_reqs: Vec, + /// Requirements of the package's OTHER declarations, which carry no + /// pin (cargo: a member's `serde = "0.9"` beside the root's pinned + /// `serde = "1"`). The hosted rewriter pins every declaration whose + /// requirement accepts the patched version, so one that accepts a + /// ledger version means the pin does not reach it: a ledger version must + /// satisfy none of these. + pub excluded_reqs: Vec, /// The Socket index url the pin routes to (the nuget source value, the /// cargo registry's `index`): its grant-token segment is part of this /// pin, not other hosted wiring. @@ -438,16 +445,22 @@ impl UnlockedPin { }; let pinned = canonical_base_purl(&format!("pkg:{}/{}@{version}", self.ecosystem, self.name)); + let parsed = semver::Version::parse(version); + // An unreadable requirement or version answers `None`: it matches + // no pinned requirement and is never proven excluded. + let matches = |req: &str| match (semver::VersionReq::parse(req), &parsed) { + (Ok(req), Ok(version)) => Some(req.matches(version)), + _ => None, + }; pinned == key - && self.version_reqs.iter().all(|req| { - match ( - semver::VersionReq::parse(req), - semver::Version::parse(version), - ) { - (Ok(req), Ok(version)) => req.matches(&version), - _ => false, - } - }) + && self + .version_reqs + .iter() + .all(|req| matches(req) == Some(true)) + && self + .excluded_reqs + .iter() + .all(|req| matches(req) == Some(false)) } } @@ -1654,6 +1667,14 @@ impl<'a> DiscoverCtx<'a> { } /// Log a guarded read of `rel` ([`Discovery::read`]). + /// Account for `text`, which a walk through [`Self::view`] read from + /// `rel` on this ctx's behalf, exactly as [`Self::read_text`] accounts + /// for its own reads (rule 11 recognition and the read log). + pub(crate) fn adopt_read(&self, rel: &str, text: &str) { + self.log_read(rel, true); + self.recognize_text(rel, text); + } + fn log_read(&self, rel: &str, readable: bool) { self.read .lock() @@ -2765,8 +2786,12 @@ impl Discovery { /// 1. a lock inventory entry for this purl whose resolved url is a /// Socket-hosted url for THIS uuid /// ([`crate::patch::redirect::hosted_patch_uuid`] over Socket's host - /// and [`Discovery::patch_server_origins`]): live. A url on any other - /// host that merely contains the uuid is not a patch reference; + /// and [`Discovery::patch_server_origins`]): live. Failing that, an + /// entry whose url has the hosted shape for this uuid on an origin + /// this run was not given (a patch server an earlier run was pointed + /// at with `--patch-server-url`, or a host that merely spells the + /// uuid): [`RecordLiveness::Undecided`] — never attested, never + /// judged dead; /// 2. a lock inventory entry that resolves the purl from somewhere ELSE /// (a registry url, a crates.io-checksummed Cargo.lock source): dead /// — the lock positively says the build fetches the unpatched @@ -2781,6 +2806,9 @@ impl Discovery { /// ([`inventory_project_every_lock`] — step 2 is negative evidence, so a /// collapsed view that dropped the hosted instance must not feed it), /// loaded on first need and shared across calls. + /// + /// The attestation-grade verdict: [`Self::redirect_record_verdict`] + /// is [`RecordLiveness::Live`]. pub async fn redirect_record_live( &self, root: &Path, @@ -2789,8 +2817,23 @@ impl Discovery { recorded_files: &[&str], inventory: &mut Option>, ) -> bool { + self.redirect_record_verdict(root, purl, uuid, recorded_files, inventory) + .await + .attests() + } + + /// [`Self::redirect_record_live`]'s three-way verdict (see + /// [`RecordLiveness`]). + pub async fn redirect_record_verdict( + &self, + root: &Path, + purl: &str, + uuid: &str, + recorded_files: &[&str], + inventory: &mut Option>, + ) -> RecordLiveness { if let Some(live) = self.hosted_claim(purl, uuid) { - return live; + return RecordLiveness::from_bool(live); } // Bundler resolves from the Gemfile / `gems.rb`: when a `source // "" do` block there still pins the uuid but the @@ -2815,7 +2858,7 @@ impl Discovery { continue; }; if gem::gemfile_source_block_pins(&text, uuid, Some(&gem_name)) { - return true; + return RecordLiveness::Live; } } } @@ -2833,21 +2876,70 @@ impl Discovery { .iter() .filter(|e| lookup(std::slice::from_ref(*e), purl).is_some()) .collect(); - if entries.iter().any(|e| { - e.resolved.as_deref().is_some_and(|r| { - crate::patch::redirect::hosted_patch_uuid(r, &self.patch_server_origins).as_deref() - == Some(uuid) - }) + let resolved = || entries.iter().filter_map(|e| e.resolved.as_deref()); + if resolved().any(|r| { + crate::patch::redirect::hosted_patch_uuid(r, &self.patch_server_origins).as_deref() + == Some(uuid) }) { - return true; + return RecordLiveness::Live; + } + if resolved().any(|r| foreign_hosted_uuid(r).as_deref() == Some(uuid)) { + return RecordLiveness::Undecided; } if entries.iter().any(|e| resolves_elsewhere(e)) { - return false; + return RecordLiveness::Dead; } - hosted_wiring_in_files(root, &recorded, uuid).await + RecordLiveness::from_bool(hosted_wiring_in_files(root, &recorded, uuid).await) } } +/// A redirect-ledger record's liveness ([`Discovery::redirect_record_verdict`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RecordLiveness { + /// The build resolves the package from the record's patch. + Live, + /// A lock entry resolves the package from a url with the hosted shape + /// for the record's patch, on an origin this run does not accept as a + /// patch server: perhaps the operator's own server, configured on an + /// earlier run (`--patch-server-url`) but not this one; perhaps a host + /// that merely spells the uuid. Never attested, never judged dead — + /// what keeps the wiring (the hosted-wiring advisories) keeps it. + Undecided, + /// The build does not resolve the package from the record's patch. + Dead, +} + +impl RecordLiveness { + fn from_bool(live: bool) -> Self { + if live { + Self::Live + } else { + Self::Dead + } + } + + /// Whether the record may be attested. + pub fn attests(self) -> bool { + self == Self::Live + } + + /// Whether the record's wiring is to be kept (not judged gone). + pub fn retained(self) -> bool { + self != Self::Dead + } +} + +/// The patch uuid `url` names when read as a hosted url of its OWN origin +/// ([`crate::patch::redirect::hosted_patch_uuid`]'s grammar — no userinfo, +/// the last canonical-uuid path segment — with the url's origin taken as +/// configured): what a lock entry on an origin this run was not given +/// claims to resolve from. +fn foreign_hosted_uuid(url: &str) -> Option { + let parsed = reqwest::Url::parse(url.trim()).ok()?; + let origin = parsed.origin().ascii_serialization(); + crate::patch::redirect::hosted_patch_uuid(url, &[origin]) +} + /// Whether root-relative `file` is one of `ecosystem`'s lockfiles — what an /// install of that ecosystem resolves from: a [`ROOT`]-role row of the /// format registry by basename (`Cargo.lock`, `composer.lock`, `go.mod`, @@ -2922,10 +3014,24 @@ impl<'a> LedgerLiveness<'a> { } } - /// [`Discovery::redirect_record_live`] for this project state. + /// [`Discovery::redirect_record_live`] for this project state: whether + /// the record may be ATTESTED. pub async fn redirect_record(&mut self, purl: &str, uuid: &str) -> bool { + self.redirect_record_verdict(purl, uuid).await.attests() + } + + /// Whether the record's hosted wiring is still in place for a caller + /// that keeps or reports wiring rather than attesting it + /// ([`RecordLiveness::retained`]: an [`RecordLiveness::Undecided`] + /// record is kept). + pub async fn redirect_record_retained(&mut self, purl: &str, uuid: &str) -> bool { + self.redirect_record_verdict(purl, uuid).await.retained() + } + + /// [`Discovery::redirect_record_verdict`] for this project state. + pub async fn redirect_record_verdict(&mut self, purl: &str, uuid: &str) -> RecordLiveness { self.discovery - .redirect_record_live( + .redirect_record_verdict( self.root, purl, uuid, @@ -3978,14 +4084,16 @@ mod tests { None, "host not allowlisted" ); - // Without the origin, the internal host's url is no patch reference - // however much of the uuid it spells: dead. + // Without the origin, the internal host's url is undecided: never + // attested, and — the regression — not vetoed by the script lock's + // registry instance either. let mut inventory = None; - assert!( - !discovery - .redirect_record_live(root, purl, UUID_A, &["uv.lock"], &mut inventory) + assert_eq!( + discovery + .redirect_record_verdict(root, purl, UUID_A, &["uv.lock"], &mut inventory) .await, - "a uuid on an unconfigured host keeps nothing alive" + RecordLiveness::Undecided, + "an unconfigured patch host is never attested, nor judged dead" ); let discovery = Discovery { patch_server_origins: vec!["https://patches.internal.example".to_string()], @@ -4125,18 +4233,23 @@ mod tests { /// REGRESSION (P6): step 1 classifies a lock entry's resolved url with /// the one origin rule ([`crate::patch::redirect::hosted_patch_uuid`]), - /// never by substring. A url on another host that merely spells the - /// record's uuid — even in Socket's own path shape — resolves the - /// package from somewhere else (step 2: dead), as does a Socket url for - /// ANOTHER patch whose path also carries the uuid (as its grant - /// token). A Socket-host url and a configured origin's url for the - /// record's uuid stay live. + /// never by substring. Only a Socket-host url or a configured origin's + /// url for the record's uuid is LIVE (attested). A url with the hosted + /// shape for the uuid on an origin this run was not given — a patch + /// server an earlier run was pointed at, or a host that merely spells + /// it — is UNDECIDED: never attested, but its wiring is retained. A url + /// that only contains the uuid (a file name, a query) and a Socket url + /// for ANOTHER patch (whose path carries the uuid as its grant token) + /// resolve the package from somewhere else: DEAD. #[tokio::test] async fn liveness_matches_the_resolved_url_by_origin_not_substring() { + use RecordLiveness::{Dead, Live, Undecided}; let origin = "http://127.0.0.1:4545"; let purl = "pkg:npm/x@1.0.0"; let elsewhere = format!("https://evil.example/patch/npm/x/1.0.0/{TOKEN}/{UUID_A}/x-1.0.0.tgz"); + let in_name = format!("https://evil.example/files/x-{UUID_A}.tgz"); + let in_query = format!("https://evil.example/files/x-1.0.0.tgz?id={UUID_A}"); let other_patch = format!("https://patch.socket.dev/patch/npm/x/1.0.0/{UUID_A}/{UUID_B}/x-1.0.0.tgz"); let socket = hosted_url("npm", "x", "1.0.0", UUID_A, "x-1.0.0.tgz"); @@ -4145,12 +4258,15 @@ mod tests { patch_server_origins: vec![origin.to_string()], ..Discovery::default() }; - for (resolved, origins_discovery, live) in [ - (&elsewhere, &with_origin, false), - (&other_patch, &with_origin, false), - (&configured, &Discovery::default(), false), - (&socket, &Discovery::default(), true), - (&configured, &with_origin, true), + let without = Discovery::default(); + for (resolved, discovery, verdict) in [ + (&elsewhere, &with_origin, Undecided), + (&configured, &without, Undecided), + (&in_name, &with_origin, Dead), + (&in_query, &with_origin, Dead), + (&other_patch, &with_origin, Dead), + (&socket, &without, Live), + (&configured, &with_origin, Live), ] { let p = Project::new(); p.write( @@ -4159,9 +4275,24 @@ mod tests { r#"{{"lockfileVersion":3,"packages":{{"":{{}},"node_modules/x":{{"version":"1.0.0","resolved":"{resolved}"}}}}}}"# ), ); + let why = format!("{resolved} (origins {:?})", discovery.patch_server_origins); + let mut inventory = None; + assert_eq!( + discovery + .redirect_record_verdict( + p.root(), + purl, + UUID_A, + &["package-lock.json"], + &mut inventory, + ) + .await, + verdict, + "{why}" + ); let mut inventory = None; assert_eq!( - origins_discovery + discovery .redirect_record_live( p.root(), purl, @@ -4170,9 +4301,19 @@ mod tests { &mut inventory, ) .await, - live, - "{resolved} (origins {:?})", - origins_discovery.patch_server_origins + verdict == Live, + "only a live record attests: {why}" + ); + let mut liveness = LedgerLiveness::new(p.root(), discovery, None); + assert_eq!( + liveness.redirect_record(purl, UUID_A).await, + verdict.attests(), + "{why}" + ); + assert_eq!( + liveness.redirect_record_retained(purl, UUID_A).await, + verdict != Dead, + "{why}" ); } } diff --git a/crates/socket-patch-core/src/vex/discover/nuget.rs b/crates/socket-patch-core/src/vex/discover/nuget.rs index 584b727bb..3393fdc52 100644 --- a/crates/socket-patch-core/src/vex/discover/nuget.rs +++ b/crates/socket-patch-core/src/vex/discover/nuget.rs @@ -491,6 +491,7 @@ fn emit_hosted( uuid: src.uuid.clone(), file: cfg_rel.into(), version_reqs: Vec::new(), + excluded_reqs: Vec::new(), index_url: Some(src.value.clone()), }); } diff --git a/crates/socket-patch-core/src/vex/discover/testing/golden.rs b/crates/socket-patch-core/src/vex/discover/testing/golden.rs index 6b14d0c4a..464167b72 100644 --- a/crates/socket-patch-core/src/vex/discover/testing/golden.rs +++ b/crates/socket-patch-core/src/vex/discover/testing/golden.rs @@ -197,15 +197,22 @@ fn render(out: &Discovery, root: &Path) -> Value { uuid, file, version_reqs, + excluded_reqs, index_url: _, } = p; - json!({ + let mut pin = json!({ "ecosystem": ecosystem, "name": name, "uuid": uuid, "file": path_str(file), "version_reqs": version_reqs, - }) + }); + // Rendered only when present, so goldens without any keep + // their shape. + if !excluded_reqs.is_empty() { + pin["excluded_reqs"] = json!(excluded_reqs); + } + pin }) .collect(); let elsewhere_rendered: Vec = elsewhere From b2e171b5dd0581312bc9d4500df601ccc075a6c1 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 5 Nov 2026 13:51:54 -0600 Subject: [PATCH 04/22] Name the view walk's read-manifest pair (clippy type_complexity) Co-Authored-By: Claude Opus 5.5 --- crates/socket-patch-core/src/utils/cargo_workspace.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/socket-patch-core/src/utils/cargo_workspace.rs b/crates/socket-patch-core/src/utils/cargo_workspace.rs index 2b0afff17..6aa8e0388 100644 --- a/crates/socket-patch-core/src/utils/cargo_workspace.rs +++ b/crates/socket-patch-core/src/utils/cargo_workspace.rs @@ -98,7 +98,7 @@ pub(crate) async fn member_manifest_texts_in(view: &ProjectView<'_>) -> Vec<(Str level.push(dir); } } - let read: Vec<(String, Option<(Arc, String)>)> = + let read: Vec<(String, Option)> = crate::utils::concurrent::ordered_concurrent( level, MEMBER_READ_CONCURRENCY, @@ -128,9 +128,12 @@ pub(crate) async fn member_manifest_texts_in(view: &ProjectView<'_>) -> Vec<(Str .collect() } +/// A manifest the view walk read: its walk facts and its text. +type ReadManifest = (Arc, String); + /// A regular (non-symlink) manifest read through the view: its walk facts /// and its text. -async fn view_manifest(view: &ProjectView<'_>, rel: &str) -> Option<(Arc, String)> { +async fn view_manifest(view: &ProjectView<'_>, rel: &str) -> Option { if view.is_symlink(rel) { return None; } From 77a4d2d1af23f99cd667e0d7c7779d5ba2061d55 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 4 Nov 2026 11:56:34 -0600 Subject: [PATCH 05/22] Stop attesting a Cargo pin a workspace member resolves from crates.io Cargo resolves each source on its own. When one declaration of a crate pins it to `socket-patch-` but another (the root's or a workspace member's) names no Socket registry (crates.io, another registry, a path or git) with a requirement the locked patched version satisfies, that declaration builds a separate, unpatched copy, possibly of the very version the pin covers. Discovery still attested the Cargo.lock ref because "some declaration pins the patch" was enough. The hosted rewriter pins every declaration, so this mixed state is a partial revert or a hand edit. The ref now stays (rollback, remove and the ledgers' liveness still see the pin) but is marked unattested with the new `UnattestedKind::CargoUnpinnedDeclaration`, naming the unpinned declaration's manifest. When Cargo.lock already locks a same-version copy from another source beside the Socket entry, the detail names that lock entry as direct evidence. A declaration whose requirement excludes the patched version, and a `[patch]` registry entry (which redirects declarations rather than adding one), do not withhold the statement. The CLI omits the ref as `vex_cargo_unpinned_declaration`; documented in CLI_CONTRACT.md. Co-Authored-By: Claude Opus 5.5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 3 +- .../src/commands/vex_sources.rs | 56 +++ .../src/vex/discover/cargo.rs | 335 +++++++++++++++++- .../socket-patch-core/src/vex/discover/mod.rs | 6 + 4 files changed, 390 insertions(+), 10 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index a3ed909d6..8a612fb66 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -547,7 +547,7 @@ Recognition rules that hold for every ecosystem: * **Patch hosts.** A hosted reference counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin, with no userinfo. The uuid is the URL's LAST canonical-uuid path segment, because grant tokens may themselves be uuid-shaped. The Go module prefix is fixed. `socket-patch-` registry / repository / source names count only through a pin. For a URL on any other host, see **Patch hosts** above. * **Pins, not definitions.** A registry, index or source *definition* alone (cargo `[registries]`, nuget ``, pom ``, uv index tables, `.npmrc`) never makes a reference, because it survives a reverted pin. Sections the package manager ignores are not read: npm's v2 `dependencies` mirror, a `.cargo/config.toml` shadowed by `.cargo/config`. A Socket pin inside a maven `` is diagnosed, never a reference. * **Contested locks.** When one lock wires a package to a patch and another lock resolves the same `name@version` from a non-Socket source, the build's bytes depend on which package manager runs. The reference is then dropped with a `patched_ref_unattributable` diagnostic naming both files. This applies across npm / pnpm / yarn / bun and across uv / pylock / poetry / pdm / Pipfile.lock / requirements. PEP 723 script locks neither contest nor are contested. The root `requirements.txt` and its in-root `-r` includes count as one lock here: pip reads them as one requirement set, where a direct reference wins over a compatible `==` pin of the same version in another file of the set (#1086). A PyPI version matches under PEP 440 equality, so a requirements line pinned in any spelling that can only install the wired release (`==1.2.3.0`, `==v1.2.3`, `===1.2.3`) contests it like `==1.2.3` does; a range or wildcard (`~=1.2.3`, `==1.2.*`) does not (#1602). A **bundled** npm copy (`inBundle: true`, or v1 `bundled: true`, beneath a dependency that bundles it; the root project's own bundles install from the lock, #1618) of the same `name@version` contests the reference too, in the same lock, in the other npm lock of a shrinkwrap/package-lock pair, or in any other lock. npm unpacks it from the parent package's tarball, so no rewire reaches it and it stays unpatched. Bun and vlt unpack bundled copies the same way (#469, #471). pnpm unpacks bundled copies too, but its lock cannot tie one to a reference (see **Unattested references** below). For Bun, that is a `bun.lock` entry whose meta is `{ "bundled": true }`, or a `bun.lockb` record that a dependency edge with the `bundled` behavior bit reaches, including one Bun shares with a regular install. Such an entry is never a reference, and it contests the reference the same way. For vlt, the lock records no node for a bundled copy, so the copy is found in the installed store: a real package directory inside a store package's own `node_modules`. yarn classic's `yarn.lock` records nothing about bundled copies either (#758), so for a lock with a Socket wiring they are found in the installed `node_modules`: each package a parent's `bundleDependencies` / `bundledDependencies` names inside the parent's own `node_modules`, the dependencies of those beside them, and everything nested under them. Such a copy contests the reference in `yarn.lock` (the diagnostic names the parent's lock entry and the copy's directory); a lock-only `vex` with no `node_modules` can't see it. Hosted and vendored scans skip these copies with `redirect_bun_bundled_instance_skipped` / `redirect_vlt_bundled_instance_skipped` / `vendor_bundled_instance_skipped`, and a yarn classic hosted scan names one with `redirect_copy_unpatched`. When a bundled copy is the only instance, vendoring refuses with `vendor_lock_entry_not_rewritable`. A reference withheld only because such an unreachable copy installs beside it (a bundled npm / Bun / vlt copy, an npm copy beneath a `hasShrinkwrap` package (#753) or one npm installs from a git / url / `file:` spec, or a yarn classic git or `file:` directory block of the same `name@version`) is still the rewriter's own wiring of exactly one package version: it is never attested, but `rollback`, `remove`, `list` and the hosted → vendored takeover treat it as an ordinary hosted pin and restore its upstream registry entry (#828), rather than refusing it with `hosted_wiring_contested`. Another entry of the **same** npm, Bun or yarn lock that resolves the wired `name@version` from a non-Socket source (for example a workspace member added after the rewire, then `npm install` / `bun install` / `yarn install`; for yarn berry, a registry locator such as `left-pad@npm:1.3.0` beside the hosted `…::__archiveUrl=` one) contests the reference too (#588). The package manager installs both entries, and that copy stays unpatched. Re-running `scan` / `vendor` rewires every copy. -* **Unattested references.** Some evidence shows a build may run a copy no wiring reaches, but cannot be tied to the reference's exact `name@version` or cannot say the build runs it. The reference then stays a reference: `list`, `rollback` and `remove` find it, and the ledgers' liveness gates (`vendor --check`, `scan`) keep treating the wiring as live, because re-running `scan` / `vendor` could never clear the evidence. Only `vex` omits it, as a run warning and as the `failed[].reason`. Four cases besides Gradle's (`vex_gradle_lock_above_base`): **pnpm bundled copies** (`vex_pnpm_bundled_copy`): a `packages:` entry's `bundledDependencies:` names the copies pnpm unpacks from that package's own tarball, but pnpm never locks them, so the bundled version is not in the lock. A pnpm reference whose package name a `bundledDependencies` list in the same lock names is omitted whatever its version (a missed attestation when the bundled copy is another version, never a false one), and `bundledDependencies: true` (or a value that cannot be read) omits every reference of that lock. It reaches no other lock. **deno.lock** (`vex_deno_lock_copy`): `deno install` installs a `package.json` project's npm dependencies from `deno.lock` and never reads `package-lock.json` / `pnpm-lock.yaml` / `yarn.lock`, so an npm-family reference whose `name@version` the `deno.lock` npm section also locks is omitted (#406). Whether Deno or another package manager populates `node_modules` (`nodeModulesDir: "manual"` allows either) is not in the files, so this holds whatever `nodeModulesDir` says. **Maven reactor roots** (`vex_maven_reactor_root`): a hosted pin in a root `pom.xml` that declares `` / ``, or whose subprojects Maven 4 discovers (see the hosted Maven rewriter), is omitted, because a module's own literal `` overrides a root pin and module poms are not read (#261); hosted scan refuses such roots (`redirect_maven_multimodule_unsupported`), so this only covers pins an older release wrote. **Maven classifier copies** (`vex_maven_classifier_unpatched`): a `` dependency of a hosted-pinned GA is its own coordinate; the classifier-less pin does not retarget it and the grant serves only the main jar, so Maven fetches it from the next repository. When a live, non-profile classifier copy other than `sources` / `javadoc` (`tests`, a native or JDK-specific build) resolves any version other than the suffixed pin — its own literal ``, a `` entry with the same classifier, or no version in the root pom — the pin is omitted. `sources` / `javadoc` copies never run on a classpath and do not withhold the statement; hosted scan only warns about them (`redirect_maven_classifier_unsupported`) and skips the dep when an executable copy names the patched release. +* **Unattested references.** Some evidence shows a build may run a copy no wiring reaches, but cannot be tied to the reference's exact `name@version` or cannot say the build runs it. The reference then stays a reference: `list`, `rollback` and `remove` find it, and the ledgers' liveness gates (`vendor --check`, `scan`) keep treating the wiring as live, because re-running `scan` / `vendor` could never clear the evidence. Only `vex` omits it, as a run warning and as the `failed[].reason`. Five cases besides Gradle's (`vex_gradle_lock_above_base`): **pnpm bundled copies** (`vex_pnpm_bundled_copy`): a `packages:` entry's `bundledDependencies:` names the copies pnpm unpacks from that package's own tarball, but pnpm never locks them, so the bundled version is not in the lock. A pnpm reference whose package name a `bundledDependencies` list in the same lock names is omitted whatever its version (a missed attestation when the bundled copy is another version, never a false one), and `bundledDependencies: true` (or a value that cannot be read) omits every reference of that lock. It reaches no other lock. **deno.lock** (`vex_deno_lock_copy`): `deno install` installs a `package.json` project's npm dependencies from `deno.lock` and never reads `package-lock.json` / `pnpm-lock.yaml` / `yarn.lock`, so an npm-family reference whose `name@version` the `deno.lock` npm section also locks is omitted (#406). Whether Deno or another package manager populates `node_modules` (`nodeModulesDir: "manual"` allows either) is not in the files, so this holds whatever `nodeModulesDir` says. **Maven reactor roots** (`vex_maven_reactor_root`): a hosted pin in a root `pom.xml` that declares `` / ``, or whose subprojects Maven 4 discovers (see the hosted Maven rewriter), is omitted, because a module's own literal `` overrides a root pin and module poms are not read (#261); hosted scan refuses such roots (`redirect_maven_multimodule_unsupported`), so this only covers pins an older release wrote. **Maven classifier copies** (`vex_maven_classifier_unpatched`): a `` dependency of a hosted-pinned GA is its own coordinate; the classifier-less pin does not retarget it and the grant serves only the main jar, so Maven fetches it from the next repository. When a live, non-profile classifier copy other than `sources` / `javadoc` (`tests`, a native or JDK-specific build) resolves any version other than the suffixed pin — its own literal ``, a `` entry with the same classifier, or no version in the root pom — the pin is omitted. `sources` / `javadoc` copies never run on a classpath and do not withhold the statement; hosted scan only warns about them (`redirect_maven_classifier_unsupported`) and skips the dep when an executable copy names the patched release. **Unpinned Cargo declarations** (`vex_cargo_unpinned_declaration`): cargo resolves each source on its own, so when one declaration of a crate pins it to `socket-patch-` but another — the root `Cargo.toml`'s or a workspace member's, read as hosted scan reads them — names no Socket registry (crates.io, another registry, a path or git) with a version requirement the locked patched version satisfies, that declaration builds a separate, unpatched copy, possibly of the very version the pin covers. The `Cargo.lock` reference is omitted, naming the unpinned declaration's manifest, and the warning also names a same-version lock entry from another source when the lock already holds one. Hosted scan pins every declaration, so this is a partial revert or a hand edit; re-running `scan` pins a crates.io declaration again. A declaration whose requirement excludes the patched version (cargo resolves it to another version) does not withhold the statement. * **Shrinkwrap without a twin (#899).** npm >= 12 never reads `npm-shrinkwrap.json`: on a project whose only npm lock is the shrinkwrap it resolves the tree from the registry and writes a fresh `package-lock.json`, so a Socket reference there reaches npm <= 11 only. The reference is still discovered, so `list`, `rollback` and `remove` manage it, but `vex` omits the patch (`vex_npm_shrinkwrap_only`, as a run warning and as the `failed[].reason`). A `package-lock.json` beside it (wired the same way) makes it attestable again. Hosted and vendored runs still rewire the shrinkwrap and warn `redirect_npm_shrinkwrap_only` / `vendor_npm_shrinkwrap_only`. * **Lockless pins.** With no lock to name a version, a `Cargo.toml` pin (every declaration on `socket-patch-`, that registry defined on the patch host for the same uuid) or an exclusive nuget exact-id mapping is never a reference on its own, so v5.0 does not attest it (nor does `list` show it, or `rollback` / `remove` restore it — restore those files from version control). Only a pre-v5 redirect-ledger record naming a version the pin admits keeps it live. The same holds for a gem wired only in the `Gemfile` (the pre-bundler-2.6 mixed state, lock not converged). @@ -2678,6 +2678,7 @@ match it, withholds the statement: | `vex_deno_lock_copy` | run warning and `failed[].reason` | An npm pin is wired, but `deno.lock` locks the same `name@version`, which `deno install` installs without the wiring; no statement while it does. `vendor --check` and `scan` still treat the wiring as live. | | `vex_maven_reactor_root` | run warning and `failed[].reason` | A hosted Maven pin sits in a root `pom.xml` that declares `` / `` (a profile's included), where a module's own literal `` overrides it; only the root pom is read, so no statement (#261). `list`, `rollback` and `remove` still find the pin. | | `vex_maven_classifier_unpatched` | run warning and `failed[].reason` | A hosted Maven pin's GA also has a live executable `` copy (not `sources` / `javadoc`) at a version other than the pin, which the classifier-less pin does not retarget and the grant does not serve; no statement while it does. `list`, `rollback` and `remove` still find the pin. | +| `vex_cargo_unpinned_declaration` | run warning and `failed[].reason` | A hosted Cargo pin is wired, but another declaration of the crate (the root's or a workspace member's) names no Socket registry and accepts the patched version, so cargo builds a separate unpatched copy for it; no statement while it does. `list`, `rollback` and `remove` still find the pin. | | `vex_gradle_derived_cache_unchecked` | run warning | The derived-cache walk was cut short (a very large transforms cache); the statement is still emitted, the copies the walk did reach were checked. | A vendored Gradle entry attests only while its wiring is live (apply line, index rows, diff --git a/crates/socket-patch-cli/src/commands/vex_sources.rs b/crates/socket-patch-cli/src/commands/vex_sources.rs index 25e49273e..431dbd27d 100644 --- a/crates/socket-patch-cli/src/commands/vex_sources.rs +++ b/crates/socket-patch-cli/src/commands/vex_sources.rs @@ -232,6 +232,10 @@ const NOTE_MAVEN_REACTOR_ROOT: &str = "vex_maven_reactor_root"; /// classifier copy at another version, which the pin does not reach /// (`vex::Unattested`). const NOTE_MAVEN_CLASSIFIER_UNPATCHED: &str = "vex_maven_classifier_unpatched"; +/// Omission tag and note: a hosted Cargo pin beside another declaration of +/// the crate that names no Socket registry, which cargo resolves as a +/// separate, unpatched package (`vex::Unattested`). +const NOTE_CARGO_UNPINNED_DECLARATION: &str = "vex_cargo_unpinned_declaration"; /// The omission tag of an [`Unattested`](socket_patch_core::vex::Unattested) /// ref, and the remedy its note ends with. @@ -268,6 +272,12 @@ fn unattested_note(kind: UnattestedKind) -> (&'static str, &'static str) { "not attested while that classifier copy resolves an unpatched version; the \ wiring itself is intact, so re-running `scan` does not change this", ), + UnattestedKind::CargoUnpinnedDeclaration => ( + NOTE_CARGO_UNPINNED_DECLARATION, + "not attested while that declaration names no Socket registry (re-run \ + `socket-patch scan` to pin a crates.io declaration too; one from another \ + registry, a path or git cannot be pinned, so roll the patch back)", + ), } } @@ -1762,6 +1772,52 @@ mod tests { assert!(live.hosted.contains_key(PURL)); } + /// A hosted Cargo pin beside an unpinned declaration of the crate is + /// omitted as `vex_cargo_unpinned_declaration`, the note naming the + /// declaration and the remedy. + #[tokio::test] + async fn a_cargo_pin_beside_an_unpinned_declaration_is_gated() { + const PURL: &str = "pkg:cargo/serde@1.0.1"; + let tmp = tempfile::tempdir().unwrap(); + let mut redirect = RedirectState::new(); + redirect.records.insert(PURL.into(), record(U1)); + let mut found = discovery(vec![hosted_ref(PURL, U1, true)]); + found.unattested.push(socket_patch_core::vex::Unattested { + purl: PURL.into(), + uuid: U1.into(), + file: "crates/a/Cargo.toml".into(), + detail: "crates/a/Cargo.toml declares serde \"1\" from crates.io".into(), + kind: UnattestedKind::CargoUnpinnedDeclaration, + }); + let gated = plan( + &common(tmp.path()), + Sources { + manifest: PatchManifest::new(), + vendor: VendorState::new(), + redirect: Some(redirect), + discovery: found, + }, + &[], + &RunApiClient::new(), + ) + .await; + assert!(gated.view.patches.is_empty() && gated.hosted.is_empty()); + assert_eq!( + gated.gated, + vec![failed(PURL, NOTE_CARGO_UNPINNED_DECLARATION)] + ); + assert!( + gated + .notes + .iter() + .any(|n| n.code == NOTE_CARGO_UNPINNED_DECLARATION + && n.detail.contains("crates/a/Cargo.toml declares serde") + && n.detail.contains("socket-patch scan")), + "{:?}", + gated.notes + ); + } + /// Ledger-only records with no wiring anywhere are gated, except the /// manifest-owned one, which falls back to agent-mode verification; an /// in-run confirmed purl is live by construction. diff --git a/crates/socket-patch-core/src/vex/discover/cargo.rs b/crates/socket-patch-core/src/vex/discover/cargo.rs index 3139998ec..93d2f172c 100644 --- a/crates/socket-patch-core/src/vex/discover/cargo.rs +++ b/crates/socket-patch-core/src/vex/discover/cargo.rs @@ -40,7 +40,15 @@ //! //! * not declared in any of them (a transitive-only crate; no / unparseable //! root manifest) → ref; -//! * some declaration pins `socket-patch-` (the same uuid) → ref; +//! * some declaration pins `socket-patch-` (the same uuid) → ref; but +//! when another declaration names no Socket registry (crates.io, another +//! registry, a path or git) with a requirement that accepts the locked +//! version, cargo resolves it as a separate, unpatched package — maybe of +//! this very version (the lock may already hold it beside the Socket +//! entry) — so the ref is marked +//! [`super::UnattestedKind::CargoUnpinnedDeclaration`], naming that +//! declaration's file (the hosted rewriter pins every declaration, so this +//! is a partial revert or a hand edit); //! * declarations pin only OTHER `socket-patch-` registries → the lock //! ref AND a `Cargo.toml` ref for `` at the locked version (no pin): //! the files disagree and the CLI gates the package as `wiring_conflict`; @@ -121,7 +129,7 @@ use toml_edit::{DocumentMut, Item, TableLike}; use super::{ names_vendor_dir, simple_purl, socket_patch_name_uuid, toml_or_diag, vendor_ref, DiscoverCtx, - Discovery, PatchedRef, TomlDiag, UnlockedPin, VendorRef, DIAG_REF_INVALID, + Discovery, PatchedRef, TomlDiag, UnattestedKind, UnlockedPin, VendorRef, DIAG_REF_INVALID, DIAG_REF_UNATTRIBUTABLE, }; use crate::formats::cargo::{is_registry_source, CargoLock, CopyClaim, LockedPackage}; @@ -436,8 +444,10 @@ async fn read_config( // ── manifest / config shapes ───────────────────────────────────────────── -/// One declaration of a crate: the root-relative file holding it and its -/// `registry` value (`None` = no registry key). +/// One declaration of a crate: the root-relative file holding it, its +/// `registry` value (`None` = no registry key) and, for a dependency +/// declaration, its version requirement and the source cargo fetches it +/// from. #[derive(Debug, Clone)] struct Decl { file: String, @@ -445,14 +455,20 @@ struct Decl { /// The `version` requirement (`None` for a `[patch]` registry pin, or /// a declaration without one). version: Option, + /// Where cargo resolves a dependency declaration from, for messages + /// ([`DepEntry::origin`]); `None` for a `[patch]` entry's registry pin, + /// which redirects declarations rather than adding one. + origin: Option, } impl Decl { + /// A `[patch]` entry's registry pin (no dependency of its own). fn new(file: &str, registry: Option<&str>, version: Option<&str>) -> Self { Decl { file: file.to_string(), registry: registry.map(str::to_string), version: version.map(str::to_string), + origin: None, } } @@ -468,6 +484,25 @@ impl Decl { .as_deref() .is_some_and(|r| r.trim().starts_with(PIN_NAME_PREFIX)) } + + /// Whether this is a dependency declaration cargo resolves outside + /// every Socket patch registry: no `registry` key (crates.io, or a + /// path / git source), or a registry not named `socket-patch-*`. + fn unpinned(&self) -> bool { + self.origin.is_some() && !self.socket_shaped() + } + + /// Whether this declaration's version requirement may select + /// `version`. A missing or unreadable requirement (a path / git + /// declaration, a spelling semver does not read) may select any + /// version, so it does. + fn accepts(&self, version: &str) -> bool { + let (Some(req), Ok(version)) = (self.version.as_deref(), semver::Version::parse(version)) + else { + return true; + }; + semver::VersionReq::parse(req.trim()).map_or(true, |req| req.matches(&version)) + } } /// Crate name → its declarations ([`Decl`]). @@ -481,11 +516,13 @@ fn manifest_declarations(manifests: &[(String, DocumentMut)]) -> Decls { let mut decls = Decls::new(); for (file, doc) in manifests { for dep in dependency_entries(doc) { - decls.entry(dep.name).or_default().push(Decl::new( - file, - dep.registry.as_deref(), - dep.version.as_deref(), - )); + let decl = Decl { + file: file.clone(), + origin: Some(dep.origin()), + registry: dep.registry, + version: dep.version, + }; + decls.entry(dep.name).or_default().push(decl); } } decls @@ -519,6 +556,20 @@ struct DepEntry { registry: Option, /// The `version` requirement (a bare string entry is one). version: Option, + /// The entry's `git` / `path` key, when it has one. + local: Option<&'static str>, +} + +impl DepEntry { + /// Where cargo resolves this declaration from, for messages. + fn origin(&self) -> String { + match (self.local, &self.registry) { + (Some("git"), _) => "a git repository".to_string(), + (Some(_), _) => "a local path".to_string(), + (None, Some(reg)) => format!("registry `{reg}`"), + (None, None) => "crates.io".to_string(), + } + } } fn dependency_entries(doc: &DocumentMut) -> Vec { @@ -548,6 +599,7 @@ fn dependency_entries(doc: &DocumentMut) -> Vec { name: key.to_string(), registry: None, version: Some(version.to_string()), + local: None, } } else if let Some(entry) = item.as_table_like() { if entry.get("workspace").and_then(Item::as_bool) == Some(true) { @@ -558,6 +610,7 @@ fn dependency_entries(doc: &DocumentMut) -> Vec { name: field("package").unwrap_or_else(|| key.to_string()), registry: field("registry"), version: field("version"), + local: ["git", "path"].into_iter().find(|k| entry.contains_key(k)), } } else { continue; @@ -642,6 +695,7 @@ fn hosted_from_lock( .iter() .any(|d| d.registry.as_deref() == Some(reg.as_str())) { + unpinned_beside_pin(ctx, lock, pkg, &purl, &uuid, regs, out); out.push(lock_ref); continue; } @@ -688,6 +742,79 @@ fn hosted_from_lock( } } +/// Mark the Socket-locked `pkg` [`UnattestedKind::CargoUnpinnedDeclaration`] +/// when, beside a declaration pinned to its patch, another declaration of +/// the crate (the root's or a workspace member's) is not pinned to any +/// Socket registry and its version requirement accepts the locked version. +/// Cargo resolves each source on its own, so that declaration builds a +/// separate, unpatched copy (from crates.io, its own registry, a path or a +/// git checkout), which may be this very version: the lock may already +/// hold it beside the Socket entry, or the next build adds it. The hosted +/// rewriter pins every declaration, so this is a partial revert or a hand +/// edit. The ref stays (rollback, remove and the ledgers still see the +/// pin), but nothing attests it while that declaration stands. +fn unpinned_beside_pin( + ctx: &DiscoverCtx<'_>, + lock: &Lock, + pkg: &LockedPackage, + purl: &str, + uuid: &str, + regs: &[Decl], + out: &mut Discovery, +) { + let unpinned: Vec<&Decl> = regs + .iter() + .filter(|d| d.unpinned() && d.accepts(&pkg.version)) + .collect(); + let Some(first) = unpinned.first() else { + return; + }; + let declared = unpinned + .iter() + .map(|d| { + let req = d + .version + .as_deref() + .map_or_else(|| "with no version".to_string(), |v| format!("{v:?}")); + let from = d.origin.as_deref().unwrap_or_default(); + format!("{} declares {} {req} from {from}", d.file, pkg.name) + }) + .collect::>() + .join(", and "); + // Direct evidence: the lock already holds that unpinned copy at the + // locked version (another source beside the Socket one). + let twin = lock + .packages() + .iter() + .filter(|p| p.name == pkg.name && p.version == pkg.version) + .find_map(|p| { + p.source + .as_deref() + .filter(|s| source_uuid(ctx, s).is_none()) + }); + let evidence = match twin { + Some(source) => format!( + "; {CARGO_LOCK} already locks that copy beside the Socket one ({} {} from {source})", + pkg.name, pkg.version + ), + None => String::new(), + }; + out.unattested( + purl, + uuid, + &first.file, + format!( + "{declared}, not registry \"{}\": cargo resolves that declaration as a \ + separate package, so the crate declaring it builds an unpatched {} that the \ + requirement allows to be {}{evidence}", + hosted_pin_name(uuid), + pkg.name, + pkg.version + ), + UnattestedKind::CargoUnpinnedDeclaration, + ); +} + /// Record every lock block that resolves a Socket-hosted crate's /// `name@version` from another source ([`Discovery::unpatched_copy`]). /// Cargo never unifies packages of different sources, so a dependency that @@ -1666,6 +1793,196 @@ mod tests { &out, &[("pkg:cargo/serde@1.0.1", UUID_A, WiringMode::Hosted)], ); + assert!(out.unattested.is_empty(), "{:#?}", out.unattested); + } + + /// A workspace whose root and member `crates/a` declare serde with the + /// given dependency lines, locked to Socket patch A at 1.0.1 (plus + /// `extra` lock entries). + fn mixed_workspace(root_dep: &str, member_dep: &str, extra: &[(&str, &str)]) -> Project { + let p = Project::new(); + p.write( + "Cargo.toml", + format!( + "[workspace]\nmembers = [\"crates/*\"]\n\n[package]\nname = \"app\"\n\ + version = \"0.1.0\"\n\n[dependencies]\n{root_dep}\n" + ), + ); + p.write( + "crates/a/Cargo.toml", + format!( + "[package]\nname = \"a\"\nversion = \"0.1.0\"\n\n[dependencies]\n{member_dep}\n" + ), + ); + let socket = index(UUID_A); + let mut pkgs = vec![("serde", "1.0.1", Some(socket.as_str()), Some(CKSUM))]; + pkgs.extend( + extra + .iter() + .map(|(v, s)| ("serde", *v, Some(*s), Some(CKSUM))), + ); + p.write("Cargo.lock", lock(&pkgs)); + p.write(".cargo/config.toml", registry_block(UUID_A)); + p + } + + /// The one unattested mark of `out`, asserted to be the hosted serde + /// ref's [`UnattestedKind::CargoUnpinnedDeclaration`] naming `file`. + fn unpinned_mark<'a>(out: &'a Discovery, file: &str) -> &'a Unattested { + assert_eq!(out.unattested.len(), 1, "{:#?}", out.unattested); + let u = &out.unattested[0]; + assert_eq!(u.kind, UnattestedKind::CargoUnpinnedDeclaration); + assert_eq!( + (u.purl.as_str(), u.uuid.as_str()), + ("pkg:cargo/serde@1.0.1", UUID_A) + ); + assert_eq!(u.file, std::path::PathBuf::from(file)); + u + } + + /// REGRESSION: the root pins serde to Socket, but a member declares it + /// from crates.io at a requirement the patched 1.0.1 satisfies. Cargo + /// resolves that declaration as a separate crates.io package, so the + /// member builds unpatched bytes: the ref stays (rollback / remove find + /// it, the ledger claim lives) but is not attested, naming the member. + #[tokio::test] + async fn a_root_pin_beside_an_unpinned_member_is_unattested() { + for member in [ + "serde = \"1\"", + "serde = \"=1.0.1\"", + "serde = { version = \"1\", registry = \"my-corp\" }", + "serde = { git = \"https://github.com/serde-rs/serde\" }", + "legacy = { package = \"serde\", version = \"1.0\" }", + ] { + let p = mixed_workspace(&pinned("serde", "1", UUID_A), member, &[]); + let out = run(&p).await; + assert_refs( + &out, + &[("pkg:cargo/serde@1.0.1", UUID_A, WiringMode::Hosted)], + ); + assert!( + out.diagnostics.is_empty(), + "{member}: {:#?}", + out.diagnostics + ); + assert_eq!( + out.hosted_claim("pkg:cargo/serde@1.0.1", UUID_A), + Some(true), + "{member}" + ); + let u = unpinned_mark(&out, "crates/a/Cargo.toml"); + assert!( + u.detail.contains("crates/a/Cargo.toml declares serde") + && u.detail + .contains(&format!("not registry \"socket-patch-{UUID_A}\"")) + && !u.detail.contains("already locks"), + "{member}: {}", + u.detail + ); + } + let p = mixed_workspace(&pinned("serde", "1", UUID_A), "serde = \"1\"", &[]); + let u = &run(&p).await.unattested[0]; + assert!( + u.detail.contains("serde \"1\" from crates.io"), + "{}", + u.detail + ); + } + + /// The mirror image: a member pins serde, the root declares it from + /// crates.io. The root's declaration builds unpatched bytes. + #[tokio::test] + async fn a_member_pin_beside_an_unpinned_root_is_unattested() { + let p = mixed_workspace("serde = \"1.0\"", &pinned("serde", "1", UUID_A), &[]); + let out = run(&p).await; + assert_refs( + &out, + &[("pkg:cargo/serde@1.0.1", UUID_A, WiringMode::Hosted)], + ); + let u = unpinned_mark(&out, "Cargo.toml"); + assert!( + u.detail + .contains("Cargo.toml declares serde \"1.0\" from crates.io"), + "{}", + u.detail + ); + } + + /// What the hosted rewriter writes — every declaration pinned — is + /// attested; so is a pin beside an unpinned declaration that the + /// patched version cannot satisfy (cargo resolves it to another + /// version, never this one). + #[tokio::test] + async fn every_declaration_pinned_is_attested() { + for (root, member) in [ + (pinned("serde", "1", UUID_A), pinned("serde", "1.0", UUID_A)), + (pinned("serde", "1", UUID_A), "serde = \"0.9\"".to_string()), + ( + pinned("serde", "1", UUID_A), + "serde = \">=1.0.2\"".to_string(), + ), + // A `[patch]` registry entry redirects declarations; it declares + // no dependency of its own. + ( + format!( + "{}\n\n[patch.crates-io]\nserde = {{ version = \"1\", registry = \"my-corp\" }}", + pinned("serde", "1", UUID_A) + ), + pinned("serde", "1", UUID_A), + ), + ] { + let p = mixed_workspace(&root, &member, &[]); + let out = run(&p).await; + assert_refs( + &out, + &[("pkg:cargo/serde@1.0.1", UUID_A, WiringMode::Hosted)], + ); + assert!(out.unattested.is_empty(), "{member}: {:#?}", out.unattested); + assert!( + out.diagnostics.is_empty(), + "{member}: {:#?}", + out.diagnostics + ); + } + } + + /// The lock already holds the member's crates.io copy at the patched + /// version beside the Socket entry: the mark names that lock entry as + /// direct evidence (the crates.io-twin rule also withholds the ref). + /// A crates.io copy at another version is no evidence of this version, + /// but the declaration still may select it on the next resolve. + #[tokio::test] + async fn the_locked_crates_io_copy_is_named_as_evidence() { + let p = mixed_workspace( + &pinned("serde", "1", UUID_A), + "serde = \"1\"", + &[("1.0.1", CRATES_IO)], + ); + let out = run(&p).await; + assert_refs(&out, &[]); + assert_eq!(out.shadowed.len(), 1, "{:#?}", out.shadowed); + let u = unpinned_mark(&out, "crates/a/Cargo.toml"); + assert!( + u.detail.contains(&format!( + "Cargo.lock already locks that copy beside the Socket one (serde 1.0.1 from \ + {CRATES_IO})" + )), + "{}", + u.detail + ); + + let p = mixed_workspace( + &pinned("serde", "1", UUID_A), + "serde = \"1\"", + &[("1.0.200", CRATES_IO)], + ); + let out = run(&p).await; + assert_refs( + &out, + &[("pkg:cargo/serde@1.0.1", UUID_A, WiringMode::Hosted)], + ); + let u = unpinned_mark(&out, "crates/a/Cargo.toml"); + assert!(!u.detail.contains("already locks"), "{}", u.detail); } /// A manifest under `.socket/` (a path dependency into Socket's own diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index 9b128f8b1..1e4165109 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -619,6 +619,12 @@ pub enum UnattestedKind { /// version: the classifier-less pin does not retarget it and the grant /// serves only the main jar, so the build runs the public classifier jar. MavenClassifierUnpatched, + /// A hosted Cargo pin beside another declaration of the crate (the + /// root's or a workspace member's) that names no Socket registry and + /// whose requirement accepts the pinned version: cargo resolves that + /// declaration from its own source as a separate package, so the crate + /// declaring it builds an unpatched copy, possibly of this version. + CargoUnpinnedDeclaration, } /// A ref discovery emits (so rollback, remove and list find the wiring, From 7837fb49a87ccd7583fc79322cee0bbba33a3365 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 5 Nov 2026 22:56:31 -0600 Subject: [PATCH 06/22] Classify each Cargo declaration before judging a hosted pin Review fixes for the unpinned-declaration mark. Each declaration of a Socket-locked crate is now classified once (`route`): routed to a Socket patch (its own `socket-patch-` registry, or a Socket `[patch.]` registry entry that redirects it), unpinned (cargo builds its own copy), or saying nothing about the locked version. - A crates.io (or registry) declaration that a Socket `[patch.]` registry entry in the root manifest or project config redirects is no longer counted as unpinned. - A declaration naming `registry = "socket-patch-"` beside `path`, `git` or `registry-index` is not a pin (the hosted rewriter's own pinned check): cargo builds that other source, so it now marks the ref. - Requirements go through `patch::redirect::cargo_req_selects` with the lock's other versions of the crate, and the detail says whether a re-scan can pin the declaration: only a crates.io declaration whose requirement matches no other locked version; otherwise the rewriter refuses (`redirect_cargo_toml_dep_unrewritable`), so the remedy is to change the declaration or roll back. The CLI note's remedy is now generic and the per-case remedy lives in the detail. - `registry-index` declarations are reported as such, not as crates.io. - A `[workspace.dependencies]` template counts only when some manifest inherits it (`workspace = true`). - When the lock already holds a same-version copy from another source, no mark is added: the twin rule (#679) withholds the ref and names it. - Root pinned to patch A with a member pinned to patch B (for the locked version, and B not resolved by the lock) now emits B's Cargo.toml ref beside A's, so the CLI's wiring-conflict gate runs. - `Decl` carries a `DeclSource` enum and a `live` flag; the origin text is formatted only for messages, and `[patch]` pins are built with `Decl::patch` (no dead version parameter). Co-Authored-By: Claude Opus 5.5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 4 +- .../src/commands/vex_sources.rs | 13 +- .../src/vex/discover/cargo.rs | 656 ++++++++++++++---- .../socket-patch-core/src/vex/discover/mod.rs | 6 +- 4 files changed, 543 insertions(+), 136 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 8a612fb66..e09c2f1a1 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -547,7 +547,7 @@ Recognition rules that hold for every ecosystem: * **Patch hosts.** A hosted reference counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin, with no userinfo. The uuid is the URL's LAST canonical-uuid path segment, because grant tokens may themselves be uuid-shaped. The Go module prefix is fixed. `socket-patch-` registry / repository / source names count only through a pin. For a URL on any other host, see **Patch hosts** above. * **Pins, not definitions.** A registry, index or source *definition* alone (cargo `[registries]`, nuget ``, pom ``, uv index tables, `.npmrc`) never makes a reference, because it survives a reverted pin. Sections the package manager ignores are not read: npm's v2 `dependencies` mirror, a `.cargo/config.toml` shadowed by `.cargo/config`. A Socket pin inside a maven `` is diagnosed, never a reference. * **Contested locks.** When one lock wires a package to a patch and another lock resolves the same `name@version` from a non-Socket source, the build's bytes depend on which package manager runs. The reference is then dropped with a `patched_ref_unattributable` diagnostic naming both files. This applies across npm / pnpm / yarn / bun and across uv / pylock / poetry / pdm / Pipfile.lock / requirements. PEP 723 script locks neither contest nor are contested. The root `requirements.txt` and its in-root `-r` includes count as one lock here: pip reads them as one requirement set, where a direct reference wins over a compatible `==` pin of the same version in another file of the set (#1086). A PyPI version matches under PEP 440 equality, so a requirements line pinned in any spelling that can only install the wired release (`==1.2.3.0`, `==v1.2.3`, `===1.2.3`) contests it like `==1.2.3` does; a range or wildcard (`~=1.2.3`, `==1.2.*`) does not (#1602). A **bundled** npm copy (`inBundle: true`, or v1 `bundled: true`, beneath a dependency that bundles it; the root project's own bundles install from the lock, #1618) of the same `name@version` contests the reference too, in the same lock, in the other npm lock of a shrinkwrap/package-lock pair, or in any other lock. npm unpacks it from the parent package's tarball, so no rewire reaches it and it stays unpatched. Bun and vlt unpack bundled copies the same way (#469, #471). pnpm unpacks bundled copies too, but its lock cannot tie one to a reference (see **Unattested references** below). For Bun, that is a `bun.lock` entry whose meta is `{ "bundled": true }`, or a `bun.lockb` record that a dependency edge with the `bundled` behavior bit reaches, including one Bun shares with a regular install. Such an entry is never a reference, and it contests the reference the same way. For vlt, the lock records no node for a bundled copy, so the copy is found in the installed store: a real package directory inside a store package's own `node_modules`. yarn classic's `yarn.lock` records nothing about bundled copies either (#758), so for a lock with a Socket wiring they are found in the installed `node_modules`: each package a parent's `bundleDependencies` / `bundledDependencies` names inside the parent's own `node_modules`, the dependencies of those beside them, and everything nested under them. Such a copy contests the reference in `yarn.lock` (the diagnostic names the parent's lock entry and the copy's directory); a lock-only `vex` with no `node_modules` can't see it. Hosted and vendored scans skip these copies with `redirect_bun_bundled_instance_skipped` / `redirect_vlt_bundled_instance_skipped` / `vendor_bundled_instance_skipped`, and a yarn classic hosted scan names one with `redirect_copy_unpatched`. When a bundled copy is the only instance, vendoring refuses with `vendor_lock_entry_not_rewritable`. A reference withheld only because such an unreachable copy installs beside it (a bundled npm / Bun / vlt copy, an npm copy beneath a `hasShrinkwrap` package (#753) or one npm installs from a git / url / `file:` spec, or a yarn classic git or `file:` directory block of the same `name@version`) is still the rewriter's own wiring of exactly one package version: it is never attested, but `rollback`, `remove`, `list` and the hosted → vendored takeover treat it as an ordinary hosted pin and restore its upstream registry entry (#828), rather than refusing it with `hosted_wiring_contested`. Another entry of the **same** npm, Bun or yarn lock that resolves the wired `name@version` from a non-Socket source (for example a workspace member added after the rewire, then `npm install` / `bun install` / `yarn install`; for yarn berry, a registry locator such as `left-pad@npm:1.3.0` beside the hosted `…::__archiveUrl=` one) contests the reference too (#588). The package manager installs both entries, and that copy stays unpatched. Re-running `scan` / `vendor` rewires every copy. -* **Unattested references.** Some evidence shows a build may run a copy no wiring reaches, but cannot be tied to the reference's exact `name@version` or cannot say the build runs it. The reference then stays a reference: `list`, `rollback` and `remove` find it, and the ledgers' liveness gates (`vendor --check`, `scan`) keep treating the wiring as live, because re-running `scan` / `vendor` could never clear the evidence. Only `vex` omits it, as a run warning and as the `failed[].reason`. Five cases besides Gradle's (`vex_gradle_lock_above_base`): **pnpm bundled copies** (`vex_pnpm_bundled_copy`): a `packages:` entry's `bundledDependencies:` names the copies pnpm unpacks from that package's own tarball, but pnpm never locks them, so the bundled version is not in the lock. A pnpm reference whose package name a `bundledDependencies` list in the same lock names is omitted whatever its version (a missed attestation when the bundled copy is another version, never a false one), and `bundledDependencies: true` (or a value that cannot be read) omits every reference of that lock. It reaches no other lock. **deno.lock** (`vex_deno_lock_copy`): `deno install` installs a `package.json` project's npm dependencies from `deno.lock` and never reads `package-lock.json` / `pnpm-lock.yaml` / `yarn.lock`, so an npm-family reference whose `name@version` the `deno.lock` npm section also locks is omitted (#406). Whether Deno or another package manager populates `node_modules` (`nodeModulesDir: "manual"` allows either) is not in the files, so this holds whatever `nodeModulesDir` says. **Maven reactor roots** (`vex_maven_reactor_root`): a hosted pin in a root `pom.xml` that declares `` / ``, or whose subprojects Maven 4 discovers (see the hosted Maven rewriter), is omitted, because a module's own literal `` overrides a root pin and module poms are not read (#261); hosted scan refuses such roots (`redirect_maven_multimodule_unsupported`), so this only covers pins an older release wrote. **Maven classifier copies** (`vex_maven_classifier_unpatched`): a `` dependency of a hosted-pinned GA is its own coordinate; the classifier-less pin does not retarget it and the grant serves only the main jar, so Maven fetches it from the next repository. When a live, non-profile classifier copy other than `sources` / `javadoc` (`tests`, a native or JDK-specific build) resolves any version other than the suffixed pin — its own literal ``, a `` entry with the same classifier, or no version in the root pom — the pin is omitted. `sources` / `javadoc` copies never run on a classpath and do not withhold the statement; hosted scan only warns about them (`redirect_maven_classifier_unsupported`) and skips the dep when an executable copy names the patched release. **Unpinned Cargo declarations** (`vex_cargo_unpinned_declaration`): cargo resolves each source on its own, so when one declaration of a crate pins it to `socket-patch-` but another — the root `Cargo.toml`'s or a workspace member's, read as hosted scan reads them — names no Socket registry (crates.io, another registry, a path or git) with a version requirement the locked patched version satisfies, that declaration builds a separate, unpatched copy, possibly of the very version the pin covers. The `Cargo.lock` reference is omitted, naming the unpinned declaration's manifest, and the warning also names a same-version lock entry from another source when the lock already holds one. Hosted scan pins every declaration, so this is a partial revert or a hand edit; re-running `scan` pins a crates.io declaration again. A declaration whose requirement excludes the patched version (cargo resolves it to another version) does not withhold the statement. +* **Unattested references.** Some evidence shows a build may run a copy no wiring reaches, but cannot be tied to the reference's exact `name@version` or cannot say the build runs it. The reference then stays a reference: `list`, `rollback` and `remove` find it, and the ledgers' liveness gates (`vendor --check`, `scan`) keep treating the wiring as live, because re-running `scan` / `vendor` could never clear the evidence. Only `vex` omits it, as a run warning and as the `failed[].reason`. Five cases besides Gradle's (`vex_gradle_lock_above_base`): **pnpm bundled copies** (`vex_pnpm_bundled_copy`): a `packages:` entry's `bundledDependencies:` names the copies pnpm unpacks from that package's own tarball, but pnpm never locks them, so the bundled version is not in the lock. A pnpm reference whose package name a `bundledDependencies` list in the same lock names is omitted whatever its version (a missed attestation when the bundled copy is another version, never a false one), and `bundledDependencies: true` (or a value that cannot be read) omits every reference of that lock. It reaches no other lock. **deno.lock** (`vex_deno_lock_copy`): `deno install` installs a `package.json` project's npm dependencies from `deno.lock` and never reads `package-lock.json` / `pnpm-lock.yaml` / `yarn.lock`, so an npm-family reference whose `name@version` the `deno.lock` npm section also locks is omitted (#406). Whether Deno or another package manager populates `node_modules` (`nodeModulesDir: "manual"` allows either) is not in the files, so this holds whatever `nodeModulesDir` says. **Maven reactor roots** (`vex_maven_reactor_root`): a hosted pin in a root `pom.xml` that declares `` / ``, or whose subprojects Maven 4 discovers (see the hosted Maven rewriter), is omitted, because a module's own literal `` overrides a root pin and module poms are not read (#261); hosted scan refuses such roots (`redirect_maven_multimodule_unsupported`), so this only covers pins an older release wrote. **Maven classifier copies** (`vex_maven_classifier_unpatched`): a `` dependency of a hosted-pinned GA is its own coordinate; the classifier-less pin does not retarget it and the grant serves only the main jar, so Maven fetches it from the next repository. When a live, non-profile classifier copy other than `sources` / `javadoc` (`tests`, a native or JDK-specific build) resolves any version other than the suffixed pin — its own literal ``, a `` entry with the same classifier, or no version in the root pom — the pin is omitted. `sources` / `javadoc` copies never run on a classpath and do not withhold the statement; hosted scan only warns about them (`redirect_maven_classifier_unsupported`) and skips the dep when an executable copy names the patched release. **Unpinned Cargo declarations** (`vex_cargo_unpinned_declaration`): cargo resolves each source on its own, so when one declaration of a crate pins it to `socket-patch-` but another — the root `Cargo.toml`'s or a workspace member's, read as hosted scan reads them — is not pinned, with a version requirement that may select the locked patched version, that declaration builds a separate, unpatched copy, possibly of the very version the pin covers. Not pinned means crates.io or another registry that no Socket `[patch.]` registry entry redirects, a `registry-index`, a path or git — the last three even beside `registry = "socket-patch-"`, which they override (hosted scan's own pinned check). The `Cargo.lock` reference is omitted, naming the unpinned declaration's manifest. Hosted scan pins every declaration, so this is a partial revert or a hand edit; the warning says whether re-running `scan` pins it again (a crates.io declaration whose requirement matches no other locked version of the crate) or cannot (`redirect_cargo_toml_dep_unrewritable`: roll the patch back or change the declaration). A declaration whose requirement excludes the patched version (cargo resolves it to another version), and a `[workspace.dependencies]` entry no manifest inherits, do not withhold the statement. When the lock already holds a same-version copy from another source, the reference is withheld as an unpatched lock copy instead. A declaration pinned to ANOTHER Socket patch, which the lock does not resolve the crate from, adds that patch's `Cargo.toml` reference, so the package is gated as `wiring_conflict`. * **Shrinkwrap without a twin (#899).** npm >= 12 never reads `npm-shrinkwrap.json`: on a project whose only npm lock is the shrinkwrap it resolves the tree from the registry and writes a fresh `package-lock.json`, so a Socket reference there reaches npm <= 11 only. The reference is still discovered, so `list`, `rollback` and `remove` manage it, but `vex` omits the patch (`vex_npm_shrinkwrap_only`, as a run warning and as the `failed[].reason`). A `package-lock.json` beside it (wired the same way) makes it attestable again. Hosted and vendored runs still rewire the shrinkwrap and warn `redirect_npm_shrinkwrap_only` / `vendor_npm_shrinkwrap_only`. * **Lockless pins.** With no lock to name a version, a `Cargo.toml` pin (every declaration on `socket-patch-`, that registry defined on the patch host for the same uuid) or an exclusive nuget exact-id mapping is never a reference on its own, so v5.0 does not attest it (nor does `list` show it, or `rollback` / `remove` restore it — restore those files from version control). Only a pre-v5 redirect-ledger record naming a version the pin admits keeps it live. The same holds for a gem wired only in the `Gemfile` (the pre-bundler-2.6 mixed state, lock not converged). @@ -2678,7 +2678,7 @@ match it, withholds the statement: | `vex_deno_lock_copy` | run warning and `failed[].reason` | An npm pin is wired, but `deno.lock` locks the same `name@version`, which `deno install` installs without the wiring; no statement while it does. `vendor --check` and `scan` still treat the wiring as live. | | `vex_maven_reactor_root` | run warning and `failed[].reason` | A hosted Maven pin sits in a root `pom.xml` that declares `` / `` (a profile's included), where a module's own literal `` overrides it; only the root pom is read, so no statement (#261). `list`, `rollback` and `remove` still find the pin. | | `vex_maven_classifier_unpatched` | run warning and `failed[].reason` | A hosted Maven pin's GA also has a live executable `` copy (not `sources` / `javadoc`) at a version other than the pin, which the classifier-less pin does not retarget and the grant does not serve; no statement while it does. `list`, `rollback` and `remove` still find the pin. | -| `vex_cargo_unpinned_declaration` | run warning and `failed[].reason` | A hosted Cargo pin is wired, but another declaration of the crate (the root's or a workspace member's) names no Socket registry and accepts the patched version, so cargo builds a separate unpatched copy for it; no statement while it does. `list`, `rollback` and `remove` still find the pin. | +| `vex_cargo_unpinned_declaration` | run warning and `failed[].reason` | A hosted Cargo pin is wired, but another declaration of the crate (the root's or a workspace member's) is not pinned (crates.io or another registry no Socket `[patch]` entry redirects, a `registry-index`, a path or git) and may select the patched version, so cargo builds a separate unpatched copy for it; no statement while it does. The warning says whether re-running `scan` can pin it. `list`, `rollback` and `remove` still find the pin. | | `vex_gradle_derived_cache_unchecked` | run warning | The derived-cache walk was cut short (a very large transforms cache); the statement is still emitted, the copies the walk did reach were checked. | A vendored Gradle entry attests only while its wiring is live (apply line, index rows, diff --git a/crates/socket-patch-cli/src/commands/vex_sources.rs b/crates/socket-patch-cli/src/commands/vex_sources.rs index 431dbd27d..84c72843c 100644 --- a/crates/socket-patch-cli/src/commands/vex_sources.rs +++ b/crates/socket-patch-cli/src/commands/vex_sources.rs @@ -274,9 +274,7 @@ fn unattested_note(kind: UnattestedKind) -> (&'static str, &'static str) { ), UnattestedKind::CargoUnpinnedDeclaration => ( NOTE_CARGO_UNPINNED_DECLARATION, - "not attested while that declaration names no Socket registry (re-run \ - `socket-patch scan` to pin a crates.io declaration too; one from another \ - registry, a path or git cannot be pinned, so roll the patch back)", + "not attested while that declaration stands", ), } } @@ -1786,7 +1784,9 @@ mod tests { purl: PURL.into(), uuid: U1.into(), file: "crates/a/Cargo.toml".into(), - detail: "crates/a/Cargo.toml declares serde \"1\" from crates.io".into(), + detail: "crates/a/Cargo.toml declares serde \"1\" from crates.io, not registry \ + \"socket-patch-…\"; re-run `socket-patch scan` to pin it too" + .into(), kind: UnattestedKind::CargoUnpinnedDeclaration, }); let gated = plan( @@ -1812,7 +1812,10 @@ mod tests { .iter() .any(|n| n.code == NOTE_CARGO_UNPINNED_DECLARATION && n.detail.contains("crates/a/Cargo.toml declares serde") - && n.detail.contains("socket-patch scan")), + && n.detail.ends_with( + "re-run `socket-patch scan` to pin it too; not attested while that \ + declaration stands" + )), "{:?}", gated.notes ); diff --git a/crates/socket-patch-core/src/vex/discover/cargo.rs b/crates/socket-patch-core/src/vex/discover/cargo.rs index 93d2f172c..76e025bbc 100644 --- a/crates/socket-patch-core/src/vex/discover/cargo.rs +++ b/crates/socket-patch-core/src/vex/discover/cargo.rs @@ -40,15 +40,23 @@ //! //! * not declared in any of them (a transitive-only crate; no / unparseable //! root manifest) → ref; -//! * some declaration pins `socket-patch-` (the same uuid) → ref; but -//! when another declaration names no Socket registry (crates.io, another -//! registry, a path or git) with a requirement that accepts the locked -//! version, cargo resolves it as a separate, unpatched package — maybe of -//! this very version (the lock may already hold it beside the Socket -//! entry) — so the ref is marked -//! [`super::UnattestedKind::CargoUnpinnedDeclaration`], naming that -//! declaration's file (the hosted rewriter pins every declaration, so this -//! is a partial revert or a hand edit); +//! * some declaration names `socket-patch-` (the same uuid) → ref; +//! the other declarations whose requirement may select the locked +//! version then decide whether it is attested. One routed to ANOTHER +//! Socket patch (that the lock does not resolve the crate from) adds a +//! `Cargo.toml` ref for it — the conflict pair below. One cargo builds +//! its own copy for — crates.io or another registry not redirected by a +//! Socket `[patch.]` entry, a `registry-index`, a path or git +//! (even beside a `socket-patch-` registry, which those override: +//! the hosted rewriter's own pinned check) — marks the ref +//! [`super::UnattestedKind::CargoUnpinnedDeclaration`], naming its file +//! and whether a re-scan can pin it: cargo resolves each source on its +//! own, so that copy is unpatched and may be this very version. The +//! hosted rewriter pins every declaration, so this is a partial revert or +//! a hand edit. A `[workspace.dependencies]` template counts only when a +//! manifest inherits it. When the lock already holds a same-version copy +//! from another source, the twin rule (`unpatched_twins`, #679) withholds +//! the ref instead; //! * declarations pin only OTHER `socket-patch-` registries → the lock //! ref AND a `Cargo.toml` ref for `` at the locked version (no pin): //! the files disagree and the CLI gates the package as `wiring_conflict`; @@ -134,6 +142,7 @@ use super::{ }; use crate::formats::cargo::{is_registry_source, CargoLock, CopyClaim, LockedPackage}; use crate::patch::redirect::generation::{hosted_pin_name, PIN_NAME_PREFIX}; +use crate::patch::redirect::{cargo_req_selects, CargoReqMatch}; use crate::utils::digest::is_hex64_lower; use crate::vendor::cargo_config::{ effective_config_rel_in, patch_entries, registry_definitions, CargoPatchEntry, CONFIG_LEGACY, @@ -183,7 +192,7 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { decls .entry(name) .or_default() - .push(Decl::new(CARGO_TOML, Some(reg), None)); + .push(Decl::patch(CARGO_TOML, patch.source, reg)); } } decls @@ -194,7 +203,7 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { if let Some((file, doc)) = &config { for patch in patch_entries(doc) { if let (Some(reg), Some(existing)) = (patch.registry, decls.get_mut(patch.name)) { - existing.push(Decl::new(file, Some(reg), None)); + existing.push(Decl::patch(file, patch.source, reg)); } } } @@ -444,10 +453,26 @@ async fn read_config( // ── manifest / config shapes ───────────────────────────────────────────── +/// Where cargo takes one declaration of a crate from. +#[derive(Debug, Clone, PartialEq, Eq)] +enum DeclSource { + /// A `[patch.]` entry's `registry` pin: it declares no + /// dependency, but redirects the declarations cargo resolves from + /// `
` ([`Decl::patched_by`]). + Patch(String), + /// A registry dependency: its `registry`, else crates.io. + Registry, + /// `registry-index = ""`. + RegistryIndex(String), + /// `path = "…"` (cargo builds it locally, whatever `registry` says). + Path, + /// `git = "…"`. + Git, +} + /// One declaration of a crate: the root-relative file holding it, its -/// `registry` value (`None` = no registry key) and, for a dependency -/// declaration, its version requirement and the source cargo fetches it -/// from. +/// `registry` value (`None` = no registry key), its version requirement +/// and where cargo takes it from. #[derive(Debug, Clone)] struct Decl { file: String, @@ -455,20 +480,21 @@ struct Decl { /// The `version` requirement (`None` for a `[patch]` registry pin, or /// a declaration without one). version: Option, - /// Where cargo resolves a dependency declaration from, for messages - /// ([`DepEntry::origin`]); `None` for a `[patch]` entry's registry pin, - /// which redirects declarations rather than adding one. - origin: Option, + source: DeclSource, + /// `false` for a `[workspace.dependencies]` template no manifest + /// inherits (`workspace = true`): cargo resolves it for nobody. + live: bool, } impl Decl { - /// A `[patch]` entry's registry pin (no dependency of its own). - fn new(file: &str, registry: Option<&str>, version: Option<&str>) -> Self { + /// A `[patch.
]` entry's registry pin. + fn patch(file: &str, table: &str, registry: &str) -> Self { Decl { file: file.to_string(), - registry: registry.map(str::to_string), - version: version.map(str::to_string), - origin: None, + registry: Some(registry.to_string()), + version: None, + source: DeclSource::Patch(table.to_string()), + live: true, } } @@ -485,23 +511,38 @@ impl Decl { .is_some_and(|r| r.trim().starts_with(PIN_NAME_PREFIX)) } - /// Whether this is a dependency declaration cargo resolves outside - /// every Socket patch registry: no `registry` key (crates.io, or a - /// path / git source), or a registry not named `socket-patch-*`. - fn unpinned(&self) -> bool { - self.origin.is_some() && !self.socket_shaped() + /// The Socket patch this declaration itself pins the crate to: a + /// `socket-patch-` registry on a registry dependency or a + /// `[patch]` entry. Beside `path`, `git` or `registry-index` the + /// registry key does not decide where cargo takes the crate from (the + /// hosted rewriter's own pinned check), so that is no pin. + fn pin(&self) -> Option { + match self.source { + DeclSource::Registry | DeclSource::Patch(_) => self.socket_uuid(), + _ => None, + } } - /// Whether this declaration's version requirement may select - /// `version`. A missing or unreadable requirement (a path / git - /// declaration, a spelling semver does not read) may select any - /// version, so it does. - fn accepts(&self, version: &str) -> bool { - let (Some(req), Ok(version)) = (self.version.as_deref(), semver::Version::parse(version)) - else { - return true; - }; - semver::VersionReq::parse(req.trim()).map_or(true, |req| req.matches(&version)) + /// Whether the `[patch.
]` table redirects this declaration: + /// a registry dependency of that registry (`crates-io`, or a crates.io + /// URL alias, for one with no `registry`). + fn patched_by(&self, table: &str) -> bool { + self.source == DeclSource::Registry + && match self.registry.as_deref() { + None => is_crates_io_source(table), + Some(reg) => reg.trim() == table, + } + } + + /// Where cargo takes this dependency from, for messages. + fn origin(&self) -> String { + match (&self.source, self.registry.as_deref()) { + (DeclSource::Git, _) => "a git repository".to_string(), + (DeclSource::Path, _) => "a local path".to_string(), + (DeclSource::RegistryIndex(url), _) => format!("the registry index {url}"), + (_, Some(reg)) => format!("registry `{reg}`"), + (_, None) => "crates.io".to_string(), + } } } @@ -510,20 +551,33 @@ type Decls = BTreeMap>; /// Every dependency declaration in `manifests` (root first, then members), /// keyed by the crate it names (`package = "…"` when renamed, else the -/// key). `workspace = true` inheritors are skipped — the root's -/// `[workspace.dependencies]` entry they inherit is itself collected. +/// key). `workspace = true` inheritors are not declarations of their own — +/// the root's `[workspace.dependencies]` template they inherit is, and it +/// is live only when some manifest inherits its key. fn manifest_declarations(manifests: &[(String, DocumentMut)]) -> Decls { + let entries: Vec<(&str, DepEntry)> = manifests + .iter() + .flat_map(|(file, doc)| { + dependency_entries(doc) + .into_iter() + .map(move |d| (file.as_str(), d)) + }) + .collect(); + let inherited: Vec<&str> = entries + .iter() + .filter(|(_, d)| d.inherits) + .map(|(_, d)| d.key.as_str()) + .collect(); let mut decls = Decls::new(); - for (file, doc) in manifests { - for dep in dependency_entries(doc) { - let decl = Decl { - file: file.clone(), - origin: Some(dep.origin()), - registry: dep.registry, - version: dep.version, - }; - decls.entry(dep.name).or_default().push(decl); - } + for (file, dep) in entries.iter().filter(|(_, d)| !d.inherits) { + let decl = Decl { + file: file.to_string(), + registry: dep.registry.clone(), + version: dep.version.clone(), + source: dep.source.clone(), + live: !dep.template || inherited.contains(&dep.key.as_str()), + }; + decls.entry(dep.name.clone()).or_default().push(decl); } decls } @@ -550,37 +604,39 @@ fn decl_files<'a>(decls: impl IntoIterator) -> String { files.join(", ") } -/// One manifest dependency declaration (see [`manifest_declarations`]). +/// One manifest dependency entry (see [`manifest_declarations`]). struct DepEntry { + /// The table key. + key: String, + /// The crate: `package = "…"` when renamed, else the key. name: String, registry: Option, /// The `version` requirement (a bare string entry is one). version: Option, - /// The entry's `git` / `path` key, when it has one. - local: Option<&'static str>, -} - -impl DepEntry { - /// Where cargo resolves this declaration from, for messages. - fn origin(&self) -> String { - match (self.local, &self.registry) { - (Some("git"), _) => "a git repository".to_string(), - (Some(_), _) => "a local path".to_string(), - (None, Some(reg)) => format!("registry `{reg}`"), - (None, None) => "crates.io".to_string(), - } - } + source: DeclSource, + /// A `[workspace.dependencies]` template. + template: bool, + /// A `workspace = true` inheritor (of the template keyed `key`). + inherits: bool, } fn dependency_entries(doc: &DocumentMut) -> Vec { let mut deps = Vec::new(); - let mut tables: Vec<&dyn TableLike> = Vec::new(); + let mut tables: Vec<(&dyn TableLike, bool)> = Vec::new(); let root = doc.as_table(); for kind in DEP_TABLES { - tables.extend(root.get(kind).and_then(Item::as_table_like)); + tables.extend( + root.get(kind) + .and_then(Item::as_table_like) + .map(|t| (t, false)), + ); } if let Some(ws) = root.get("workspace").and_then(Item::as_table_like) { - tables.extend(ws.get("dependencies").and_then(Item::as_table_like)); + tables.extend( + ws.get("dependencies") + .and_then(Item::as_table_like) + .map(|t| (t, true)), + ); } if let Some(targets) = root.get("target").and_then(Item::as_table_like) { for (_, target) in targets.iter() { @@ -588,29 +644,47 @@ fn dependency_entries(doc: &DocumentMut) -> Vec { continue; }; for kind in DEP_TABLES { - tables.extend(target.get(kind).and_then(Item::as_table_like)); + tables.extend( + target + .get(kind) + .and_then(Item::as_table_like) + .map(|t| (t, false)), + ); } } } - for table in tables { + for (table, template) in tables { for (key, item) in table.iter() { let dep = if let Some(version) = item.as_str() { DepEntry { + key: key.to_string(), name: key.to_string(), registry: None, version: Some(version.to_string()), - local: None, + source: DeclSource::Registry, + template, + inherits: false, } } else if let Some(entry) = item.as_table_like() { - if entry.get("workspace").and_then(Item::as_bool) == Some(true) { - continue; - } let field = |k: &str| entry.get(k).and_then(Item::as_str).map(str::to_string); + let source = if entry.contains_key("git") { + DeclSource::Git + } else if entry.contains_key("path") { + DeclSource::Path + } else if let Some(url) = field("registry-index") { + DeclSource::RegistryIndex(url) + } else { + DeclSource::Registry + }; DepEntry { + key: key.to_string(), name: field("package").unwrap_or_else(|| key.to_string()), registry: field("registry"), version: field("version"), - local: ["git", "path"].into_iter().find(|k| entry.contains_key(k)), + source, + template, + inherits: !template + && entry.get("workspace").and_then(Item::as_bool) == Some(true), } } else { continue; @@ -691,20 +765,21 @@ fn hosted_from_lock( out.push(lock_ref); continue; }; + // Some declaration names this patch's registry (even one a `path`, + // `git` or `registry-index` overrides): Socket's wiring is present, + // so the lock ref stands, and the other declarations decide whether + // it is attested ([`beside_the_pin`]). if regs .iter() .any(|d| d.registry.as_deref() == Some(reg.as_str())) { - unpinned_beside_pin(ctx, lock, pkg, &purl, &uuid, regs, out); out.push(lock_ref); + beside_the_pin(ctx, lock, pkg, &purl, &uuid, regs, out); continue; } let others: Vec<(&str, String)> = regs .iter() - .filter_map(|d| { - let other = socket_patch_name_uuid(d.registry.as_deref()?, false)?; - Some((d.file.as_str(), other)) - }) + .filter_map(|d| Some((d.file.as_str(), d.pin()?))) .collect(); if others.is_empty() { let files = decl_files(regs); @@ -742,18 +817,67 @@ fn hosted_from_lock( } } -/// Mark the Socket-locked `pkg` [`UnattestedKind::CargoUnpinnedDeclaration`] -/// when, beside a declaration pinned to its patch, another declaration of -/// the crate (the root's or a workspace member's) is not pinned to any -/// Socket registry and its version requirement accepts the locked version. -/// Cargo resolves each source on its own, so that declaration builds a -/// separate, unpatched copy (from crates.io, its own registry, a path or a -/// git checkout), which may be this very version: the lock may already -/// hold it beside the Socket entry, or the next build adds it. The hosted -/// rewriter pins every declaration, so this is a partial revert or a hand -/// edit. The ref stays (rollback, remove and the ledgers still see the -/// pin), but nothing attests it while that declaration stands. -fn unpinned_beside_pin( +/// What one declaration of a crate means for its Socket-locked +/// `name@version` ([`route`]). +enum Route { + /// Routes it to this Socket patch: its own `socket-patch-` + /// registry, or a `[patch]` entry's that redirects it. + Pin(String), + /// Cargo builds its own, unpatched copy for it, which may be this + /// version: [`CargoReqMatch::Ours`] when only this locked version + /// matches its requirement, [`CargoReqMatch::Ambiguous`] when another + /// locked one does too. + Unpinned(CargoReqMatch), + /// Says nothing about this version: its requirement excludes it, it is + /// a `[patch]` entry (a redirection, counted through the declarations + /// it redirects), a template nobody inherits, or a malformed + /// `socket-patch-*` name (diagnosed by [`unresolved_manifest_pins`]). + Elsewhere, +} + +/// Classify `d` for the locked `version` of its crate, `others` being the +/// other versions the lock holds of it and `patch_pins` the `[patch.
]` +/// Socket registry pins (table, uuid) cargo honors. +fn route(d: &Decl, patch_pins: &[(&str, String)], version: &str, others: &[String]) -> Route { + if matches!(d.source, DeclSource::Patch(_)) || !d.live { + return Route::Elsewhere; + } + let selects = cargo_req_selects(d.version.as_deref(), version, others); + if selects == CargoReqMatch::NotOurs { + return Route::Elsewhere; + } + if let Some(uuid) = d.pin() { + return Route::Pin(uuid); + } + if d.source == DeclSource::Registry && d.socket_shaped() { + return Route::Elsewhere; + } + match patch_pins.iter().find(|(table, _)| d.patched_by(table)) { + Some((_, uuid)) => Route::Pin(uuid.clone()), + None => Route::Unpinned(selects), + } +} + +/// The other declarations beside a lock entry pinned to Socket patch +/// `uuid` ([`route`] for each): +/// +/// * one routed to ANOTHER Socket patch that may select this version is a +/// conflict: its wiring is emitted beside the lock ref, so the CLI's +/// conflict gate refuses to pick one; +/// * one cargo builds its own copy for (crates.io, another registry or +/// registry index, a path, git — including a `socket-patch-` +/// registry a `path` / `git` / `registry-index` overrides) marks the ref +/// [`UnattestedKind::CargoUnpinnedDeclaration`]: cargo resolves each +/// source on its own, so that copy is unpatched and may be this very +/// version. The hosted rewriter pins every declaration, so this is a +/// partial revert or a hand edit. The ref stays (rollback, remove and +/// the ledgers still see the pin); the detail names the declarations and +/// whether a re-scan can pin them. +/// +/// When the lock already holds a same-version copy from another source, +/// [`unpatched_twins`] withholds the ref and names that entry (#679), so +/// nothing is added here. +fn beside_the_pin( ctx: &DiscoverCtx<'_>, lock: &Lock, pkg: &LockedPackage, @@ -762,42 +886,109 @@ fn unpinned_beside_pin( regs: &[Decl], out: &mut Discovery, ) { - let unpinned: Vec<&Decl> = regs + let same_name = || lock.packages().iter().filter(|p| p.name == pkg.name); + let twin = same_name().any(|p| { + p.version == pkg.version + && p.source + .as_deref() + .is_some_and(|s| source_uuid(ctx, s).is_none()) + }); + if twin { + return; + } + let others = distinct( + same_name() + .filter(|p| p.version != pkg.version) + .map(|p| Some(p.version.clone())), + ); + let patch_pins: Vec<(&str, String)> = regs .iter() - .filter(|d| d.unpinned() && d.accepts(&pkg.version)) + .filter_map(|d| match &d.source { + DeclSource::Patch(table) => Some((table.as_str(), d.socket_uuid()?)), + _ => None, + }) .collect(); - let Some(first) = unpinned.first() else { + let mut unpinned: Vec<(&Decl, CargoReqMatch)> = Vec::new(); + for d in regs { + match route(d, &patch_pins, &pkg.version, &others) { + // A patch the lock resolves the crate from at all is its own + // ref (that registry serves only its own version, a separate, + // patched package): no conflict. + Route::Pin(other) + if other != uuid + && !same_name().any(|p| { + p.source + .as_deref() + .and_then(|s| source_uuid(ctx, s)) + .is_some_and(|u| u == other) + }) => + { + out.push(PatchedRef::hosted( + purl.to_string(), + other, + &d.file, + None, + None, + true, + )); + } + Route::Unpinned(selects) => unpinned.push((d, selects)), + Route::Pin(_) | Route::Elsewhere => {} + } + } + let Some((first, _)) = unpinned.first() else { return; }; let declared = unpinned .iter() - .map(|d| { + .map(|(d, _)| { let req = d .version .as_deref() .map_or_else(|| "with no version".to_string(), |v| format!("{v:?}")); - let from = d.origin.as_deref().unwrap_or_default(); - format!("{} declares {} {req} from {from}", d.file, pkg.name) + format!("{} declares {} {req} from {}", d.file, pkg.name, d.origin()) }) .collect::>() .join(", and "); - // Direct evidence: the lock already holds that unpinned copy at the - // locked version (another source beside the Socket one). - let twin = lock - .packages() + // What a re-scan can do: the hosted rewriter pins a crates.io + // declaration whose requirement selects only the patched version among + // the locked ones, and refuses every other kind. + let refused: Vec = unpinned .iter() - .filter(|p| p.name == pkg.name && p.version == pkg.version) - .find_map(|p| { - p.source - .as_deref() - .filter(|s| source_uuid(ctx, s).is_none()) - }); - let evidence = match twin { - Some(source) => format!( - "; {CARGO_LOCK} already locks that copy beside the Socket one ({} {} from {source})", - pkg.name, pkg.version - ), - None => String::new(), + .filter_map(|(d, selects)| { + let why = match (&d.source, d.registry.as_deref(), selects) { + (DeclSource::Registry, None, CargoReqMatch::Ours) => return None, + (DeclSource::Registry, None, _) => { + let also: Vec<&str> = others + .iter() + .filter(|v| { + cargo_req_selects(d.version.as_deref(), v, &[]) + != CargoReqMatch::NotOurs + }) + .map(String::as_str) + .collect(); + format!( + "its requirement also matches {} {} locked beside it", + pkg.name, + also.join(", ") + ) + } + (DeclSource::Registry | DeclSource::RegistryIndex(_), ..) => { + "it names another registry".to_string() + } + _ => "it builds a path or git source".to_string(), + }; + Some(format!("{}'s declaration ({why})", d.file)) + }) + .collect(); + let remedy = if refused.is_empty() { + "re-run `socket-patch scan` to pin it too".to_string() + } else { + format!( + "`socket-patch scan` cannot pin {}, so change that declaration or roll the \ + patch back", + refused.join(", or ") + ) }; out.unattested( purl, @@ -806,7 +997,7 @@ fn unpinned_beside_pin( format!( "{declared}, not registry \"{}\": cargo resolves that declaration as a \ separate package, so the crate declaring it builds an unpatched {} that the \ - requirement allows to be {}{evidence}", + requirement allows to be {}; {remedy}", hosted_pin_name(uuid), pkg.name, pkg.version @@ -1947,12 +2138,11 @@ mod tests { } /// The lock already holds the member's crates.io copy at the patched - /// version beside the Socket entry: the mark names that lock entry as - /// direct evidence (the crates.io-twin rule also withholds the ref). - /// A crates.io copy at another version is no evidence of this version, - /// but the declaration still may select it on the next resolve. + /// version beside the Socket entry: the crates.io-twin rule (#679) + /// withholds the ref (shadowed, its diagnostic naming that lock entry) + /// and no unattested mark is added for a ref that no longer stands. #[tokio::test] - async fn the_locked_crates_io_copy_is_named_as_evidence() { + async fn a_locked_same_version_twin_is_left_to_the_twin_rule() { let p = mixed_workspace( &pinned("serde", "1", UUID_A), "serde = \"1\"", @@ -1961,18 +2151,35 @@ mod tests { let out = run(&p).await; assert_refs(&out, &[]); assert_eq!(out.shadowed.len(), 1, "{:#?}", out.shadowed); - let u = unpinned_mark(&out, "crates/a/Cargo.toml"); + assert!(out.unattested.is_empty(), "{:#?}", out.unattested); + assert!( + out.diagnostics + .iter() + .any(|d| d.code == DIAG_REF_UNATTRIBUTABLE + && d.detail.contains(&format!("serde 1.0.1 ({CRATES_IO})"))), + "{:#?}", + out.diagnostics + ); + } + + /// The remedy follows what a re-scan can do: the hosted rewriter pins a + /// crates.io declaration whose requirement selects only the patched + /// version, but refuses one that also matches another locked version + /// (`redirect_cargo_toml_dep_unrewritable`), one from another registry + /// or `registry-index`, and a path / git one. + #[tokio::test] + async fn the_remedy_says_whether_a_rescan_can_pin_it() { + let p = mixed_workspace(&pinned("serde", "=1.0.1", UUID_A), "serde = \"1\"", &[]); + let u = run(&p).await.unattested.remove(0); assert!( - u.detail.contains(&format!( - "Cargo.lock already locks that copy beside the Socket one (serde 1.0.1 from \ - {CRATES_IO})" - )), + u.detail.contains("re-run `socket-patch scan`"), "{}", u.detail ); + // `"1"` also matches the crates.io 1.0.200 locked beside it. let p = mixed_workspace( - &pinned("serde", "1", UUID_A), + &pinned("serde", "=1.0.1", UUID_A), "serde = \"1\"", &[("1.0.200", CRATES_IO)], ); @@ -1982,7 +2189,176 @@ mod tests { &[("pkg:cargo/serde@1.0.1", UUID_A, WiringMode::Hosted)], ); let u = unpinned_mark(&out, "crates/a/Cargo.toml"); - assert!(!u.detail.contains("already locks"), "{}", u.detail); + assert!( + u.detail.contains("also matches serde 1.0.200") + && u.detail.contains("roll the patch back") + && !u.detail.contains("re-run `socket-patch scan`"), + "{}", + u.detail + ); + + for (member, from) in [ + ( + "serde = { version = \"1\", registry = \"my-corp\" }", + "from registry `my-corp`", + ), + ( + "serde = { version = \"1\", registry-index = \"https://corp.example/index\" }", + "from the registry index https://corp.example/index", + ), + ("serde = { path = \"../serde\" }", "from a local path"), + ] { + let p = mixed_workspace(&pinned("serde", "1", UUID_A), member, &[]); + let out = run(&p).await; + let u = unpinned_mark(&out, "crates/a/Cargo.toml"); + assert!( + u.detail.contains(from) + && u.detail.contains("roll the patch back") + && !u.detail.contains("re-run `socket-patch scan`"), + "{member}: {}", + u.detail + ); + } + } + + /// A declaration that names the Socket registry but also a `path`, + /// `git` or `registry-index` source is not a pin (the hosted rewriter's + /// own check): cargo builds that source. Beside a real pin it withholds + /// the statement; alone it still keeps the ref (Socket's wiring is + /// present) but never attests it. + #[tokio::test] + async fn a_socket_registry_beside_another_source_is_not_a_pin() { + let reg = format!("registry = \"socket-patch-{UUID_A}\""); + for extra in [ + "path = \"../serde\"", + "git = \"https://github.com/serde-rs/serde\"", + "registry-index = \"https://corp.example/index\"", + ] { + let member = format!("serde = {{ version = \"1\", {reg}, {extra} }}"); + let p = mixed_workspace(&pinned("serde", "1", UUID_A), &member, &[]); + let out = run(&p).await; + assert_refs( + &out, + &[("pkg:cargo/serde@1.0.1", UUID_A, WiringMode::Hosted)], + ); + unpinned_mark(&out, "crates/a/Cargo.toml"); + + let p = Project::new(); + p.write("Cargo.toml", manifest(&member)); + p.write( + "Cargo.lock", + lock(&[("serde", "1.0.1", Some(&index(UUID_A)), Some(CKSUM))]), + ); + p.write(".cargo/config.toml", registry_block(UUID_A)); + let out = run(&p).await; + assert_refs( + &out, + &[("pkg:cargo/serde@1.0.1", UUID_A, WiringMode::Hosted)], + ); + unpinned_mark(&out, "Cargo.toml"); + } + } + + /// A `[workspace.dependencies]` entry is a template: cargo resolves it + /// only for a manifest that inherits it (`workspace = true`). An + /// unpinned template nobody inherits builds nothing; an inherited one + /// does. + #[tokio::test] + async fn an_unpinned_workspace_template_counts_only_when_inherited() { + for (member_dep, inherited) in [ + ("other = \"1\"", false), + ("serde = { workspace = true }", true), + ] { + let p = Project::new(); + p.write( + "Cargo.toml", + format!( + "[workspace]\nmembers = [\"crates/*\"]\n\n[workspace.dependencies]\n\ + serde = \"1\"\n\n[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n\ + [dependencies]\n{}\n", + pinned("serde", "1", UUID_A) + ), + ); + p.write( + "crates/a/Cargo.toml", + format!( + "[package]\nname = \"a\"\nversion = \"0.1.0\"\n\n[dependencies]\n\ + {member_dep}\n" + ), + ); + p.write( + "Cargo.lock", + lock(&[("serde", "1.0.1", Some(&index(UUID_A)), Some(CKSUM))]), + ); + p.write(".cargo/config.toml", registry_block(UUID_A)); + let out = run(&p).await; + assert_refs( + &out, + &[("pkg:cargo/serde@1.0.1", UUID_A, WiringMode::Hosted)], + ); + if inherited { + unpinned_mark(&out, "Cargo.toml"); + } else { + assert!(out.unattested.is_empty(), "{:#?}", out.unattested); + } + } + } + + /// The root pins the lock's patch A, a member pins ANOTHER patch B for + /// the same version: the files disagree, so both wirings are emitted + /// and the CLI's conflict gate refuses to pick one. A member pin whose + /// requirement excludes the locked version is about another version. + #[tokio::test] + async fn a_member_pinned_to_another_patch_is_a_conflict_pair() { + let p = mixed_workspace( + &pinned("serde", "1", UUID_A), + &pinned("serde", "1", UUID_B), + &[], + ); + let out = run(&p).await; + assert_refs( + &out, + &[ + ("pkg:cargo/serde@1.0.1", UUID_A, WiringMode::Hosted), + ("pkg:cargo/serde@1.0.1", UUID_B, WiringMode::Hosted), + ], + ); + let b = out.refs.iter().find(|r| r.uuid == UUID_B).unwrap(); + assert_eq!( + b.source_file, + std::path::PathBuf::from("crates/a/Cargo.toml") + ); + assert!(out.unattested.is_empty(), "{:#?}", out.unattested); + + let p = mixed_workspace( + &pinned("serde", "1", UUID_A), + &pinned("serde", "0.9", UUID_B), + &[], + ); + let out = run(&p).await; + assert_refs( + &out, + &[("pkg:cargo/serde@1.0.1", UUID_A, WiringMode::Hosted)], + ); + + // The lock resolves the member's B pin too, at another version: B's + // registry serves only that version, a separate patched package + // with its own ref, not a conflict over 1.0.1. + let b_index = index(UUID_B); + let p = mixed_workspace( + &pinned("serde", "1", UUID_A), + &pinned("serde", "1", UUID_B), + &[("1.0.5", b_index.as_str())], + ); + let out = run(&p).await; + assert_refs( + &out, + &[ + ("pkg:cargo/serde@1.0.1", UUID_A, WiringMode::Hosted), + ("pkg:cargo/serde@1.0.5", UUID_B, WiringMode::Hosted), + ], + ); + assert!(out.unattested.is_empty(), "{:#?}", out.unattested); } /// A manifest under `.socket/` (a path dependency into Socket's own @@ -2309,6 +2685,31 @@ mod tests { &[("pkg:cargo/serde@1.0.1", UUID_A, WiringMode::Hosted)], ); assert!(out.diagnostics.is_empty(), "{:#?}", out.diagnostics); + // The crates.io declaration is what the entry redirects: not unpinned. + assert!(out.unattested.is_empty(), "{:#?}", out.unattested); + + // A declaration from ANOTHER registry is not redirected by + // `[patch.crates-io]`: cargo builds my-corp's serde for it. + let p = Project::new(); + p.write( + "Cargo.toml", + format!( + "{}\n[patch.crates-io]\n{}\n", + manifest("serde = \"1\"\nalt = { package = \"serde\", version = \"1\", registry = \"my-corp\" }"), + pinned("serde", "1", UUID_A) + ), + ); + p.write( + "Cargo.lock", + lock(&[("serde", "1.0.1", Some(&index(UUID_A)), Some(CKSUM))]), + ); + let out = run(&p).await; + assert_eq!(out.unattested.len(), 1, "{:#?}", out.unattested); + assert!( + out.unattested[0].detail.contains("from registry `my-corp`"), + "{:#?}", + out.unattested + ); } /// A registry pin in the effective config's `[patch]` counts as a @@ -2331,6 +2732,7 @@ mod tests { &[("pkg:cargo/serde@1.0.1", UUID_A, WiringMode::Hosted)], ); assert!(out.diagnostics.is_empty(), "{:#?}", out.diagnostics); + assert!(out.unattested.is_empty(), "{:#?}", out.unattested); } #[tokio::test] diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index 1e4165109..495aa261f 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -620,8 +620,10 @@ pub enum UnattestedKind { /// serves only the main jar, so the build runs the public classifier jar. MavenClassifierUnpatched, /// A hosted Cargo pin beside another declaration of the crate (the - /// root's or a workspace member's) that names no Socket registry and - /// whose requirement accepts the pinned version: cargo resolves that + /// root's or a workspace member's) that is not pinned (no Socket + /// registry or `[patch]` redirect reaches it, or a path / git / + /// `registry-index` overrides its registry) and whose requirement may + /// select the pinned version: cargo resolves that /// declaration from its own source as a separate package, so the crate /// declaring it builds an unpatched copy, possibly of this version. CargoUnpinnedDeclaration, From 64d73a621ddbf55e36a594956f1be2dbda4a2e51 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 5 Nov 2026 23:27:28 -0600 Subject: [PATCH 07/22] Withhold a vendored Cargo copy a member's other source bypasses `vendor` wires a crate through the root's `[patch.crates-io]`, which cargo applies only to declarations from crates.io. A workspace member (or the root) that declares the crate from another registry, a `registry-index` or git, at a requirement that may select the patched version, resolves its own unpatched copy. The vendored ref stayed attested beside it because the lock copy claim only reads sourceless entries. The ref now stays (rollback, remove and the ledgers still see the wiring) but is marked unattested with the new `UnattestedKind::CargoPatchBypassed`, naming the declaration. A same-version Cargo.lock entry from any registry or git source beside the copy is marked the same way, naming the lock entry. The check reads the declarations through #2019's classification: live declarations only, a registry dependency counts as covered when a `[patch.]` table pointing at the same copy redirects it (`Decl::patched_by`), a path declaration (the user's own fork) keeps the existing "ignored" rule, and a `socket-patch-*` registry dependency is hosted wiring. The CLI omits the ref as `vex_cargo_patch_bypassed`; documented in CLI_CONTRACT.md. The workspace member walk ran only when the lock held a Socket-sourced package (or, with no lock, a manifest named a `socket-patch-` registry). A vendored copy's lock entry has no source and it has no such pin, so the walk now also runs whenever the root manifest or the project config wires a Socket copy through `[patch]`, whatever the lock's state. Co-Authored-By: Claude Opus 5.5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 3 +- .../src/commands/vex_sources.rs | 59 +++ .../src/vex/discover/cargo.rs | 397 +++++++++++++++++- .../socket-patch-core/src/vex/discover/mod.rs | 7 + 4 files changed, 451 insertions(+), 15 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index e09c2f1a1..11d122a9f 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -547,7 +547,7 @@ Recognition rules that hold for every ecosystem: * **Patch hosts.** A hosted reference counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin, with no userinfo. The uuid is the URL's LAST canonical-uuid path segment, because grant tokens may themselves be uuid-shaped. The Go module prefix is fixed. `socket-patch-` registry / repository / source names count only through a pin. For a URL on any other host, see **Patch hosts** above. * **Pins, not definitions.** A registry, index or source *definition* alone (cargo `[registries]`, nuget ``, pom ``, uv index tables, `.npmrc`) never makes a reference, because it survives a reverted pin. Sections the package manager ignores are not read: npm's v2 `dependencies` mirror, a `.cargo/config.toml` shadowed by `.cargo/config`. A Socket pin inside a maven `` is diagnosed, never a reference. * **Contested locks.** When one lock wires a package to a patch and another lock resolves the same `name@version` from a non-Socket source, the build's bytes depend on which package manager runs. The reference is then dropped with a `patched_ref_unattributable` diagnostic naming both files. This applies across npm / pnpm / yarn / bun and across uv / pylock / poetry / pdm / Pipfile.lock / requirements. PEP 723 script locks neither contest nor are contested. The root `requirements.txt` and its in-root `-r` includes count as one lock here: pip reads them as one requirement set, where a direct reference wins over a compatible `==` pin of the same version in another file of the set (#1086). A PyPI version matches under PEP 440 equality, so a requirements line pinned in any spelling that can only install the wired release (`==1.2.3.0`, `==v1.2.3`, `===1.2.3`) contests it like `==1.2.3` does; a range or wildcard (`~=1.2.3`, `==1.2.*`) does not (#1602). A **bundled** npm copy (`inBundle: true`, or v1 `bundled: true`, beneath a dependency that bundles it; the root project's own bundles install from the lock, #1618) of the same `name@version` contests the reference too, in the same lock, in the other npm lock of a shrinkwrap/package-lock pair, or in any other lock. npm unpacks it from the parent package's tarball, so no rewire reaches it and it stays unpatched. Bun and vlt unpack bundled copies the same way (#469, #471). pnpm unpacks bundled copies too, but its lock cannot tie one to a reference (see **Unattested references** below). For Bun, that is a `bun.lock` entry whose meta is `{ "bundled": true }`, or a `bun.lockb` record that a dependency edge with the `bundled` behavior bit reaches, including one Bun shares with a regular install. Such an entry is never a reference, and it contests the reference the same way. For vlt, the lock records no node for a bundled copy, so the copy is found in the installed store: a real package directory inside a store package's own `node_modules`. yarn classic's `yarn.lock` records nothing about bundled copies either (#758), so for a lock with a Socket wiring they are found in the installed `node_modules`: each package a parent's `bundleDependencies` / `bundledDependencies` names inside the parent's own `node_modules`, the dependencies of those beside them, and everything nested under them. Such a copy contests the reference in `yarn.lock` (the diagnostic names the parent's lock entry and the copy's directory); a lock-only `vex` with no `node_modules` can't see it. Hosted and vendored scans skip these copies with `redirect_bun_bundled_instance_skipped` / `redirect_vlt_bundled_instance_skipped` / `vendor_bundled_instance_skipped`, and a yarn classic hosted scan names one with `redirect_copy_unpatched`. When a bundled copy is the only instance, vendoring refuses with `vendor_lock_entry_not_rewritable`. A reference withheld only because such an unreachable copy installs beside it (a bundled npm / Bun / vlt copy, an npm copy beneath a `hasShrinkwrap` package (#753) or one npm installs from a git / url / `file:` spec, or a yarn classic git or `file:` directory block of the same `name@version`) is still the rewriter's own wiring of exactly one package version: it is never attested, but `rollback`, `remove`, `list` and the hosted → vendored takeover treat it as an ordinary hosted pin and restore its upstream registry entry (#828), rather than refusing it with `hosted_wiring_contested`. Another entry of the **same** npm, Bun or yarn lock that resolves the wired `name@version` from a non-Socket source (for example a workspace member added after the rewire, then `npm install` / `bun install` / `yarn install`; for yarn berry, a registry locator such as `left-pad@npm:1.3.0` beside the hosted `…::__archiveUrl=` one) contests the reference too (#588). The package manager installs both entries, and that copy stays unpatched. Re-running `scan` / `vendor` rewires every copy. -* **Unattested references.** Some evidence shows a build may run a copy no wiring reaches, but cannot be tied to the reference's exact `name@version` or cannot say the build runs it. The reference then stays a reference: `list`, `rollback` and `remove` find it, and the ledgers' liveness gates (`vendor --check`, `scan`) keep treating the wiring as live, because re-running `scan` / `vendor` could never clear the evidence. Only `vex` omits it, as a run warning and as the `failed[].reason`. Five cases besides Gradle's (`vex_gradle_lock_above_base`): **pnpm bundled copies** (`vex_pnpm_bundled_copy`): a `packages:` entry's `bundledDependencies:` names the copies pnpm unpacks from that package's own tarball, but pnpm never locks them, so the bundled version is not in the lock. A pnpm reference whose package name a `bundledDependencies` list in the same lock names is omitted whatever its version (a missed attestation when the bundled copy is another version, never a false one), and `bundledDependencies: true` (or a value that cannot be read) omits every reference of that lock. It reaches no other lock. **deno.lock** (`vex_deno_lock_copy`): `deno install` installs a `package.json` project's npm dependencies from `deno.lock` and never reads `package-lock.json` / `pnpm-lock.yaml` / `yarn.lock`, so an npm-family reference whose `name@version` the `deno.lock` npm section also locks is omitted (#406). Whether Deno or another package manager populates `node_modules` (`nodeModulesDir: "manual"` allows either) is not in the files, so this holds whatever `nodeModulesDir` says. **Maven reactor roots** (`vex_maven_reactor_root`): a hosted pin in a root `pom.xml` that declares `` / ``, or whose subprojects Maven 4 discovers (see the hosted Maven rewriter), is omitted, because a module's own literal `` overrides a root pin and module poms are not read (#261); hosted scan refuses such roots (`redirect_maven_multimodule_unsupported`), so this only covers pins an older release wrote. **Maven classifier copies** (`vex_maven_classifier_unpatched`): a `` dependency of a hosted-pinned GA is its own coordinate; the classifier-less pin does not retarget it and the grant serves only the main jar, so Maven fetches it from the next repository. When a live, non-profile classifier copy other than `sources` / `javadoc` (`tests`, a native or JDK-specific build) resolves any version other than the suffixed pin — its own literal ``, a `` entry with the same classifier, or no version in the root pom — the pin is omitted. `sources` / `javadoc` copies never run on a classpath and do not withhold the statement; hosted scan only warns about them (`redirect_maven_classifier_unsupported`) and skips the dep when an executable copy names the patched release. **Unpinned Cargo declarations** (`vex_cargo_unpinned_declaration`): cargo resolves each source on its own, so when one declaration of a crate pins it to `socket-patch-` but another — the root `Cargo.toml`'s or a workspace member's, read as hosted scan reads them — is not pinned, with a version requirement that may select the locked patched version, that declaration builds a separate, unpatched copy, possibly of the very version the pin covers. Not pinned means crates.io or another registry that no Socket `[patch.]` registry entry redirects, a `registry-index`, a path or git — the last three even beside `registry = "socket-patch-"`, which they override (hosted scan's own pinned check). The `Cargo.lock` reference is omitted, naming the unpinned declaration's manifest. Hosted scan pins every declaration, so this is a partial revert or a hand edit; the warning says whether re-running `scan` pins it again (a crates.io declaration whose requirement matches no other locked version of the crate) or cannot (`redirect_cargo_toml_dep_unrewritable`: roll the patch back or change the declaration). A declaration whose requirement excludes the patched version (cargo resolves it to another version), and a `[workspace.dependencies]` entry no manifest inherits, do not withhold the statement. When the lock already holds a same-version copy from another source, the reference is withheld as an unpatched lock copy instead. A declaration pinned to ANOTHER Socket patch, which the lock does not resolve the crate from, adds that patch's `Cargo.toml` reference, so the package is gated as `wiring_conflict`. +* **Unattested references.** Some evidence shows a build may run a copy no wiring reaches, but cannot be tied to the reference's exact `name@version` or cannot say the build runs it. The reference then stays a reference: `list`, `rollback` and `remove` find it, and the ledgers' liveness gates (`vendor --check`, `scan`) keep treating the wiring as live, because re-running `scan` / `vendor` could never clear the evidence. Only `vex` omits it, as a run warning and as the `failed[].reason`. Six cases besides Gradle's (`vex_gradle_lock_above_base`): **pnpm bundled copies** (`vex_pnpm_bundled_copy`): a `packages:` entry's `bundledDependencies:` names the copies pnpm unpacks from that package's own tarball, but pnpm never locks them, so the bundled version is not in the lock. A pnpm reference whose package name a `bundledDependencies` list in the same lock names is omitted whatever its version (a missed attestation when the bundled copy is another version, never a false one), and `bundledDependencies: true` (or a value that cannot be read) omits every reference of that lock. It reaches no other lock. **deno.lock** (`vex_deno_lock_copy`): `deno install` installs a `package.json` project's npm dependencies from `deno.lock` and never reads `package-lock.json` / `pnpm-lock.yaml` / `yarn.lock`, so an npm-family reference whose `name@version` the `deno.lock` npm section also locks is omitted (#406). Whether Deno or another package manager populates `node_modules` (`nodeModulesDir: "manual"` allows either) is not in the files, so this holds whatever `nodeModulesDir` says. **Maven reactor roots** (`vex_maven_reactor_root`): a hosted pin in a root `pom.xml` that declares `` / ``, or whose subprojects Maven 4 discovers (see the hosted Maven rewriter), is omitted, because a module's own literal `` overrides a root pin and module poms are not read (#261); hosted scan refuses such roots (`redirect_maven_multimodule_unsupported`), so this only covers pins an older release wrote. **Maven classifier copies** (`vex_maven_classifier_unpatched`): a `` dependency of a hosted-pinned GA is its own coordinate; the classifier-less pin does not retarget it and the grant serves only the main jar, so Maven fetches it from the next repository. When a live, non-profile classifier copy other than `sources` / `javadoc` (`tests`, a native or JDK-specific build) resolves any version other than the suffixed pin — its own literal ``, a `` entry with the same classifier, or no version in the root pom — the pin is omitted. `sources` / `javadoc` copies never run on a classpath and do not withhold the statement; hosted scan only warns about them (`redirect_maven_classifier_unsupported`) and skips the dep when an executable copy names the patched release. **Unpinned Cargo declarations** (`vex_cargo_unpinned_declaration`): cargo resolves each source on its own, so when one declaration of a crate pins it to `socket-patch-` but another — the root `Cargo.toml`'s or a workspace member's, read as hosted scan reads them — is not pinned, with a version requirement that may select the locked patched version, that declaration builds a separate, unpatched copy, possibly of the very version the pin covers. Not pinned means crates.io or another registry that no Socket `[patch.]` registry entry redirects, a `registry-index`, a path or git — the last three even beside `registry = "socket-patch-"`, which they override (hosted scan's own pinned check). The `Cargo.lock` reference is omitted, naming the unpinned declaration's manifest. Hosted scan pins every declaration, so this is a partial revert or a hand edit; the warning says whether re-running `scan` pins it again (a crates.io declaration whose requirement matches no other locked version of the crate) or cannot (`redirect_cargo_toml_dep_unrewritable`: roll the patch back or change the declaration). A declaration whose requirement excludes the patched version (cargo resolves it to another version), and a `[workspace.dependencies]` entry no manifest inherits, do not withhold the statement. When the lock already holds a same-version copy from another source, the reference is withheld as an unpatched lock copy instead. A declaration pinned to ANOTHER Socket patch, which the lock does not resolve the crate from, adds that patch's `Cargo.toml` reference, so the package is gated as `wiring_conflict`. **Vendored Cargo copies a `[patch]` does not reach** (`vex_cargo_patch_bypassed`): a `[patch.]` entry replaces the crate only in declarations from ``, and `vendor` writes `[patch.crates-io]`. So a vendored reference is omitted when a live declaration of the crate (the root `Cargo.toml`'s or a workspace member's) names another registry, a `registry-index` or a git repository, with a version requirement that may select the patched version, unless it is a registry dependency of a registry whose `[patch.]` table points at the same copy; cargo builds that declaration's own, unpatched copy. Workspace members are read for this whenever the root manifest or the project cargo config wires a Socket copy through `[patch]`, whatever the lock says. It is also omitted when `Cargo.lock` locks the crate at exactly the patched version from any source (a registry, crates.io included, or git) beside the vendored copy; the warning names that lock entry. A path declaration (the user's own fork) does not withhold the statement. Re-running `vendor` does not change this; declare the crate from crates.io there, or roll the patch back. * **Shrinkwrap without a twin (#899).** npm >= 12 never reads `npm-shrinkwrap.json`: on a project whose only npm lock is the shrinkwrap it resolves the tree from the registry and writes a fresh `package-lock.json`, so a Socket reference there reaches npm <= 11 only. The reference is still discovered, so `list`, `rollback` and `remove` manage it, but `vex` omits the patch (`vex_npm_shrinkwrap_only`, as a run warning and as the `failed[].reason`). A `package-lock.json` beside it (wired the same way) makes it attestable again. Hosted and vendored runs still rewire the shrinkwrap and warn `redirect_npm_shrinkwrap_only` / `vendor_npm_shrinkwrap_only`. * **Lockless pins.** With no lock to name a version, a `Cargo.toml` pin (every declaration on `socket-patch-`, that registry defined on the patch host for the same uuid) or an exclusive nuget exact-id mapping is never a reference on its own, so v5.0 does not attest it (nor does `list` show it, or `rollback` / `remove` restore it — restore those files from version control). Only a pre-v5 redirect-ledger record naming a version the pin admits keeps it live. The same holds for a gem wired only in the `Gemfile` (the pre-bundler-2.6 mixed state, lock not converged). @@ -2679,6 +2679,7 @@ match it, withholds the statement: | `vex_maven_reactor_root` | run warning and `failed[].reason` | A hosted Maven pin sits in a root `pom.xml` that declares `` / `` (a profile's included), where a module's own literal `` overrides it; only the root pom is read, so no statement (#261). `list`, `rollback` and `remove` still find the pin. | | `vex_maven_classifier_unpatched` | run warning and `failed[].reason` | A hosted Maven pin's GA also has a live executable `` copy (not `sources` / `javadoc`) at a version other than the pin, which the classifier-less pin does not retarget and the grant does not serve; no statement while it does. `list`, `rollback` and `remove` still find the pin. | | `vex_cargo_unpinned_declaration` | run warning and `failed[].reason` | A hosted Cargo pin is wired, but another declaration of the crate (the root's or a workspace member's) is not pinned (crates.io or another registry no Socket `[patch]` entry redirects, a `registry-index`, a path or git) and may select the patched version, so cargo builds a separate unpatched copy for it; no statement while it does. The warning says whether re-running `scan` can pin it. `list`, `rollback` and `remove` still find the pin. | +| `vex_cargo_patch_bypassed` | run warning and `failed[].reason` | A vendored Cargo copy is wired, but a declaration of the crate from another registry or git accepts the patched version, or `Cargo.lock` locks that version from a registry or git, and the copy's `[patch]` entry does not redirect it; no statement while it does. `list`, `rollback` and `remove` still find the wiring. | | `vex_gradle_derived_cache_unchecked` | run warning | The derived-cache walk was cut short (a very large transforms cache); the statement is still emitted, the copies the walk did reach were checked. | A vendored Gradle entry attests only while its wiring is live (apply line, index rows, diff --git a/crates/socket-patch-cli/src/commands/vex_sources.rs b/crates/socket-patch-cli/src/commands/vex_sources.rs index 84c72843c..d99bae325 100644 --- a/crates/socket-patch-cli/src/commands/vex_sources.rs +++ b/crates/socket-patch-cli/src/commands/vex_sources.rs @@ -236,6 +236,10 @@ const NOTE_MAVEN_CLASSIFIER_UNPATCHED: &str = "vex_maven_classifier_unpatched"; /// the crate that names no Socket registry, which cargo resolves as a /// separate, unpatched package (`vex::Unattested`). const NOTE_CARGO_UNPINNED_DECLARATION: &str = "vex_cargo_unpinned_declaration"; +/// Omission tag and note: a vendored Cargo copy beside a copy of the crate +/// from a source its `[patch]` entry does not redirect (another registry, +/// git), which cargo builds unpatched (`vex::Unattested`). +const NOTE_CARGO_PATCH_BYPASSED: &str = "vex_cargo_patch_bypassed"; /// The omission tag of an [`Unattested`](socket_patch_core::vex::Unattested) /// ref, and the remedy its note ends with. @@ -276,6 +280,12 @@ fn unattested_note(kind: UnattestedKind) -> (&'static str, &'static str) { NOTE_CARGO_UNPINNED_DECLARATION, "not attested while that declaration stands", ), + UnattestedKind::CargoPatchBypassed => ( + NOTE_CARGO_PATCH_BYPASSED, + "not attested while that copy builds; the vendored wiring itself is intact, so \ + re-running `vendor` does not change this (declare the crate from crates.io \ + there, or roll the patch back)", + ), } } @@ -1821,6 +1831,55 @@ mod tests { ); } + /// A vendored Cargo copy beside a copy of the crate its `[patch]` + /// entry does not redirect is omitted as `vex_cargo_patch_bypassed`, + /// the note naming that copy and the remedy. + #[tokio::test] + async fn a_vendored_cargo_copy_beside_a_bypassing_source_is_gated() { + const PURL: &str = "pkg:cargo/serde@1.0.1"; + let tmp = tempfile::tempdir().unwrap(); + let mut manifest = PatchManifest::new(); + manifest.patches.insert(PURL.into(), record(U1)); + let vref = vendor_ref(&format!(".socket/vendor/cargo/{U1}/serde-1.0.1")).unwrap(); + let mut found = discovery(vec![PatchedRef::vendored( + PURL.into(), + &vref, + "Cargo.toml", + None, + )]); + found.unattested.push(socket_patch_core::vex::Unattested { + purl: PURL.into(), + uuid: U1.into(), + file: "crates/a/Cargo.toml".into(), + detail: "crates/a/Cargo.toml declares serde \"1\" from registry `my-corp`".into(), + kind: UnattestedKind::CargoPatchBypassed, + }); + let gated = plan( + &common(tmp.path()), + Sources { + manifest, + vendor: VendorState::new(), + redirect: None, + discovery: found, + }, + &[], + &RunApiClient::new(), + ) + .await; + assert!(gated.view.patches.is_empty() && gated.hosted.is_empty()); + assert_eq!(gated.gated, vec![failed(PURL, NOTE_CARGO_PATCH_BYPASSED)]); + assert!( + gated + .notes + .iter() + .any(|n| n.code == NOTE_CARGO_PATCH_BYPASSED + && n.detail.contains("from registry `my-corp`") + && n.detail.contains("re-running `vendor`")), + "{:?}", + gated.notes + ); + } + /// Ledger-only records with no wiring anywhere are gated, except the /// manifest-owned one, which falls back to agent-mode verification; an /// in-run confirmed purl is live by construction. diff --git a/crates/socket-patch-core/src/vex/discover/cargo.rs b/crates/socket-patch-core/src/vex/discover/cargo.rs index 76e025bbc..971bb3220 100644 --- a/crates/socket-patch-core/src/vex/discover/cargo.rs +++ b/crates/socket-patch-core/src/vex/discover/cargo.rs @@ -126,6 +126,14 @@ //! config), and a `[patch.""]` table in the manifest //! replaces `[patch.crates-io]` wholesale — both leave the lock looking //! exactly like live Socket wiring. +//! A `[patch.]` entry replaces the crate only in declarations from +//! ``, so a ref whose build may also compile a copy of the version +//! the wiring does not reach — a live declaration (root or member) from +//! another registry, a `registry-index` or git that may select the version +//! and that no `[patch]` table pointing at this copy redirects, or a +//! same-version lock entry from any source — stays a ref but is marked +//! [`super::UnattestedKind::CargoPatchBypassed`] ([`patch_bypassed`]); the +//! members are read for this whenever a `[patch]` wires a Socket copy. //! A `.socket/cargo-patches/` path (the retired redirect backend) carries no //! uuid and is not a ref. Socket-shaped entries in a `.cargo/config.toml` //! that cargo ignores (because `.cargo/config` exists) are diagnosed, never @@ -174,8 +182,13 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { let mut manifests: Vec<(String, DocumentMut)> = Vec::new(); if let Some(doc) = root { let root_pins = manifest_has_socket_pin(&doc); + // A vendored copy wired in the root or the config is weighed against + // every member's declarations ([`patch_bypassed`]). + let vendored = std::iter::once(&doc) + .chain(config.iter().map(|(_, c)| c)) + .any(|d| !socket_copies(d).is_empty()); manifests.push((CARGO_TOML.to_string(), doc)); - manifests.extend(read_members(ctx, &lock, root_pins, out).await); + manifests.extend(read_members(ctx, &lock, root_pins, vendored, out).await); } let manifest: Option<&DocumentMut> = manifests.first().map(|(_, doc)| doc); @@ -219,6 +232,16 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { if let Some(decls) = &decls { unresolved_manifest_pins(ctx, &lock, decls, &definitions, out); } + let wired: Vec<(String, VendorRef)> = manifest + .into_iter() + .chain(config.iter().map(|(_, doc)| doc)) + .flat_map(socket_copies) + .collect(); + let bypass = Bypass { + lock: &lock, + decls: decls.as_ref(), + wired: &wired, + }; if let Some(doc) = manifest { // Cargo drops a manifest crates.io `[patch]` item that the project // config redefines under the same key (any version), and a @@ -253,25 +276,22 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { format!("{file} redefines `[patch]` key `{key}`, which replaces it in cargo") }) }; - vendored_from_patches(ctx, CARGO_TOML, doc, &lock, &shadowed, out).await; + vendored_from_patches(ctx, CARGO_TOML, doc, &lock, &shadowed, &bypass, out).await; } if let Some((file, doc)) = &config { - vendored_from_patches(ctx, file, doc, &lock, &|_| None, out).await; + vendored_from_patches(ctx, file, doc, &lock, &|_| None, &bypass, out).await; } - let wired: Vec = manifest - .into_iter() - .chain(config.iter().map(|(_, doc)| doc)) - .flat_map(socket_copies) - .collect(); - unattributed_tags(&lock, &wired, out); + let copies: Vec = wired.into_iter().map(|(_, v)| v).collect(); + unattributed_tags(&lock, &copies, out); } -/// Every Socket copy a file's `[patch]` tables point at. -fn socket_copies(doc: &DocumentMut) -> Vec { +/// Every Socket copy a file's `[patch]` tables point at, with the +/// `[patch.]` table pointing at it. +fn socket_copies(doc: &DocumentMut) -> Vec<(String, VendorRef)> { patch_entries(doc) .into_iter() - .filter_map(|e| e.path.and_then(vendor_ref)) - .filter(|v| v.eco == "cargo") + .filter_map(|e| Some((e.source.to_string(), e.path.and_then(vendor_ref)?))) + .filter(|(_, v)| v.eco == "cargo") .collect() } @@ -359,14 +379,20 @@ async fn read_toml(ctx: &DiscoverCtx<'_>, file: &str, out: &mut Discovery) -> Op /// Socket-sourced package, or an unparseable one (no ref either way). With /// no lock, a member matters only when some manifest carries a /// `socket-patch-` pin (a lockless pin, [`UnlockedPin`]); the walk has the -/// texts, so the parse is skipped otherwise. +/// texts, so the parse is skipped otherwise. `vendored` (the root manifest +/// or the config wires a Socket copy through `[patch]`, whose lock entries +/// are sourceless) always reads them, whatever the lock: a member's +/// declaration from a source the `[patch]` does not redirect bypasses the +/// copy ([`patch_bypassed`]). async fn read_members( ctx: &DiscoverCtx<'_>, lock: &Lock, root_pins: bool, + vendored: bool, out: &mut Discovery, ) -> Vec<(String, DocumentMut)> { let lockless = match lock { + _ if vendored => false, Lock::Parsed(_) if has_socket_sourced(ctx, lock) => false, Lock::Parsed(_) | Lock::Unparseable => return Vec::new(), Lock::Absent => true, @@ -1223,6 +1249,124 @@ async fn copy_tag(ctx: &DiscoverCtx<'_>, artifact_rel: &str, out: &mut Discovery } } +/// What a vendored copy is weighed against for copies its `[patch]` wiring +/// does not redirect ([`patch_bypassed`]). +struct Bypass<'a> { + lock: &'a Lock, + /// Every declaration in the root and member manifests (`None`: no + /// usable root manifest). + decls: Option<&'a Decls>, + /// Every Socket copy the manifest and config `[patch]` tables point at, + /// with the `[patch.]` table pointing at it. + wired: &'a [(String, VendorRef)], +} + +/// Mark the vendored ref `purl` (the copy `vref` of `name`@`version`) +/// [`UnattestedKind::CargoPatchBypassed`] when the build may also compile +/// a copy of that version its wiring does not reach. A `[patch.]` +/// entry replaces the crate only in declarations from `` (v5 +/// `vendor` writes `[patch.crates-io]`), so either of these builds +/// unpatched bytes: +/// +/// * a live dependency declaration (the root's or a workspace member's) +/// from another registry, a `registry-index` or git, whose requirement +/// may select `version`, unless it is a registry dependency that a +/// `[patch]` table pointing at this copy redirects ([`Decl::patched_by`]): +/// cargo resolves it as a separate package, maybe at this very version; +/// * a `Cargo.lock` entry of `name` at exactly `version` with a source +/// (any registry or git; a Socket patch server's is hosted wiring, a ref +/// of its own): that copy is already locked beside the vendored one. +/// +/// A path declaration (the user's own fork) is left alone, as the lock's +/// copy claim leaves its sourceless entry alone; a `socket-patch-*` +/// registry dependency is hosted wiring, weighed there. The ref stays +/// (rollback, remove and the ledgers still see the wiring), but nothing +/// attests it. +fn patch_bypassed( + ctx: &DiscoverCtx<'_>, + bypass: &Bypass<'_>, + vref: &VendorRef, + name: &str, + version: &str, + purl: &str, + out: &mut Discovery, +) { + let redirected = |d: &Decl| { + bypass + .wired + .iter() + .any(|(table, v)| v.uuid == vref.uuid && v.leaf == vref.leaf && d.patched_by(table)) + }; + let declared: Vec<&Decl> = bypass + .decls + .and_then(|d| d.get(name)) + .into_iter() + .flatten() + .filter(|d| d.live) + .filter(|d| match &d.source { + DeclSource::Registry => !d.socket_shaped() && !redirected(d), + DeclSource::RegistryIndex(_) | DeclSource::Git => true, + DeclSource::Path | DeclSource::Patch(_) => false, + }) + .filter(|d| cargo_req_selects(d.version.as_deref(), version, &[]) != CargoReqMatch::NotOurs) + .collect(); + let locked = bypass + .lock + .packages() + .iter() + .filter(|p| p.name == name && p.version == version) + .find_map(|p| { + p.source + .as_deref() + .filter(|s| source_uuid(ctx, s).is_none()) + }); + let redirects = format!( + "which no [patch] entry pointing at {} redirects", + vref.artifact_rel + ); + let (file, detail) = match (declared.first(), locked) { + (Some(first), _) => { + let evidence = locked.map_or_else(String::new, |source| { + format!("; {CARGO_LOCK} also locks {name} {version} from {source}") + }); + let declared = declared + .iter() + .map(|d| { + let req = d + .version + .as_deref() + .map_or_else(|| "with no version".to_string(), |v| format!("{v:?}")); + format!("{} declares {name} {req} from {}", d.file, d.origin()) + }) + .collect::>() + .join(", and "); + ( + first.file.as_str(), + format!( + "{declared}, {redirects}: cargo resolves that declaration as a separate \ + package, so the crate declaring it builds an unpatched {name} that the \ + requirement allows to be {version}{evidence}" + ), + ) + } + (None, Some(source)) => ( + CARGO_LOCK, + format!( + "{CARGO_LOCK} also locks {name} {version} from {source}, {redirects}: cargo \ + builds that copy unpatched beside the vendored one" + ), + ), + (None, None) => return, + }; + out.unattested( + purl, + &vref.uuid, + file, + detail, + UnattestedKind::CargoPatchBypassed, + ); +} + /// Vendored refs from one file's `[patch]` tables. `shadowed` names why /// cargo ignores an entry (a higher-precedence item replaces it), if so. async fn vendored_from_patches( @@ -1231,6 +1375,7 @@ async fn vendored_from_patches( doc: &DocumentMut, lock: &Lock, shadowed: &(dyn Fn(&CargoPatchEntry<'_>) -> Option + Sync), + bypass: &Bypass<'_>, out: &mut Discovery, ) { for entry in patch_entries(doc) { @@ -1343,6 +1488,7 @@ async fn vendored_from_patches( continue; } } + patch_bypassed(ctx, bypass, &vref, name, version, &purl, out); out.push(PatchedRef::vendored(purl, &vref, file, None)); } } @@ -3415,4 +3561,227 @@ mod tests { } } } + + // ── vendored: copies the [patch] does not redirect ─────────────── + + const MY_CORP: &str = "registry+https://my-corp.example/index"; + + /// A v5-vendored workspace: the root wires cfg-if 1.0.4 to the tagged + /// copy for patch A through `[patch.crates-io]` (plus `root_extra` + /// appended to the root manifest) and declares it from crates.io; the + /// member `crates/a` declares it with `member_dep`. The lock holds the + /// tagged copy plus `extra` entries `(version, source)`. + fn vendored_workspace(member_dep: &str, root_extra: &str, extra: &[(&str, &str)]) -> Project { + let rel = vendor_path(UUID_A, "cfg-if-1.0.4"); + let p = Project::new(); + p.write( + "Cargo.toml", + format!( + "[workspace]\nmembers = [\"crates/*\"]\n\n{}\n[patch.crates-io]\n\ + cfg-if-socket-aaaaaaaa = {{ package = \"cfg-if\", path = \"{rel}\" }}\n\ + {root_extra}\n", + manifest("cfg-if = \"1\"") + ), + ); + p.write( + "crates/a/Cargo.toml", + format!( + "[package]\nname = \"a\"\nversion = \"0.1.0\"\n\n[dependencies]\n{member_dep}\n" + ), + ); + let tagged = crate::vendor::cargo_tag::tag_version("1.0.4", UUID_A); + let mut pkgs = vec![("cfg-if", tagged.as_str(), None, None)]; + pkgs.extend( + extra + .iter() + .map(|(v, s)| ("cfg-if", *v, Some(*s), Some(CKSUM))), + ); + p.write("Cargo.lock", lock(&pkgs)); + p + } + + /// The one unattested mark of `out`, asserted to be the vendored + /// cfg-if ref's [`UnattestedKind::CargoPatchBypassed`] naming `file`. + fn bypassed_mark<'a>(out: &'a Discovery, file: &str) -> &'a Unattested { + assert_eq!(out.unattested.len(), 1, "{:#?}", out.unattested); + let u = &out.unattested[0]; + assert_eq!(u.kind, UnattestedKind::CargoPatchBypassed); + assert_eq!( + (u.purl.as_str(), u.uuid.as_str()), + ("pkg:cargo/cfg-if@1.0.4", UUID_A) + ); + assert_eq!(u.file, std::path::PathBuf::from(file)); + u + } + + /// REGRESSION: `[patch.crates-io]` redirects only crates.io + /// declarations. A member that declares cfg-if from another registry or + /// from git, at a requirement the patched 1.0.4 satisfies, resolves its + /// own unpatched copy, which may be that very version. The vendored ref + /// stays (rollback / remove find it, the ledger claim lives) but is not + /// attested, naming the member. + #[tokio::test] + async fn a_member_from_another_source_bypasses_the_vendored_copy() { + for (member, origin) in [ + ( + "cfg-if = { version = \"1\", registry = \"my-corp\" }", + "from registry `my-corp`", + ), + ( + "fork = { package = \"cfg-if\", version = \"=1.0.4\", registry = \"my-corp\" }", + "from registry `my-corp`", + ), + ( + "cfg-if = { git = \"https://example.com/cfg-if\" }", + "from a git repository", + ), + ( + "cfg-if = { version = \"1\", registry-index = \"sparse+https://my-corp.example/index/\" }", + "from the registry index sparse+https://my-corp.example/index/", + ), + ] { + let p = vendored_workspace(member, "", &[]); + let out = run(&p).await; + assert_refs( + &out, + &[("pkg:cargo/cfg-if@1.0.4", UUID_A, WiringMode::Vendored)], + ); + assert!( + out.diagnostics.is_empty(), + "{member}: {:#?}", + out.diagnostics + ); + assert_eq!( + out.vendored_claim( + "pkg:cargo/cfg-if@1.0.4", + UUID_A, + &vendor_path(UUID_A, "cfg-if-1.0.4") + ), + Some(true), + "{member}" + ); + let u = bypassed_mark(&out, "crates/a/Cargo.toml"); + assert!( + u.detail.contains("crates/a/Cargo.toml declares cfg-if") + && u.detail.contains(origin) + && !u.detail.contains("also locks"), + "{member}: {}", + u.detail + ); + } + } + + /// The lock already holds a same-version cfg-if from another source + /// (another registry, git, even crates.io) beside the vendored copy: + /// cargo builds that copy unpatched whoever declares it, so the mark + /// names the lock entry. A copy at another version is no such evidence. + #[tokio::test] + async fn a_same_version_lock_entry_from_any_source_bypasses_the_vendored_copy() { + for source in [MY_CORP, "git+https://example.com/cfg-if#abc123", CRATES_IO] { + let p = vendored_workspace("cfg-if = \"1\"", "", &[("1.0.4", source)]); + let out = run(&p).await; + assert_refs( + &out, + &[("pkg:cargo/cfg-if@1.0.4", UUID_A, WiringMode::Vendored)], + ); + let u = bypassed_mark(&out, "Cargo.lock"); + assert!( + u.detail + .contains(&format!("Cargo.lock also locks cfg-if 1.0.4 from {source}")), + "{source}: {}", + u.detail + ); + } + let p = vendored_workspace("cfg-if = \"1\"", "", &[("0.1.10", MY_CORP)]); + let out = run(&p).await; + assert_refs( + &out, + &[("pkg:cargo/cfg-if@1.0.4", UUID_A, WiringMode::Vendored)], + ); + assert!(out.unattested.is_empty(), "{:#?}", out.unattested); + } + + /// What the `[patch]` wiring redirects stays attested: a member + /// declaring cfg-if from crates.io, under any spelling, and a + /// declaration from elsewhere whose requirement excludes the patched + /// version, or a registry dependency whose `[patch.]` table + /// also points at the same copy; and a `[workspace.dependencies]` + /// template from another registry that no manifest inherits. A user's + /// own path fork is the documented exception (see + /// `a_same_version_fork_beside_the_tagged_copy_is_ignored`). + #[tokio::test] + async fn declarations_the_patch_redirects_keep_the_vendored_copy_attested() { + let rel = vendor_path(UUID_A, "cfg-if-1.0.4"); + let covered = format!("\n[patch.my-corp]\ncfg-if = {{ path = \"{rel}\" }}\n"); + let template = "\n[workspace.dependencies]\n\ + cfg-if = { version = \"1\", registry = \"my-corp\" }\n"; + for (member, root_extra) in [ + ("cfg-if = \"1\"", ""), + ("cfg-if = \"=1.0.4\"", ""), + ("cfg-if = { version = \"1\", registry = \"crates-io\" }", ""), + ("alias = { package = \"cfg-if\", version = \"1.0\" }", ""), + ("cfg-if = { version = \"0.1\", registry = \"my-corp\" }", ""), + ( + "cfg-if = { version = \"1\", registry = \"my-corp\" }", + covered.as_str(), + ), + ("cfg-if = { path = \"../fork\" }", ""), + ("cfg-if = \"1\"", template), + ] { + let p = vendored_workspace(member, root_extra, &[]); + let out = run(&p).await; + assert!( + out.refs.iter().any(|r| r.purl == "pkg:cargo/cfg-if@1.0.4" + && r.uuid == UUID_A + && r.mode == WiringMode::Vendored), + "{member}: {:#?}", + out.refs + ); + assert!(out.unattested.is_empty(), "{member}: {:#?}", out.unattested); + } + } + + /// The pre-v5 wiring in the project config redirects only crates.io + /// too: a member's git declaration bypasses it the same way. + #[tokio::test] + async fn a_member_from_git_bypasses_the_config_wired_copy() { + let p = Project::new(); + p.write( + ".cargo/config.toml", + patch_config(&[("cfg-if", &vendor_path(UUID_A, "cfg-if-1.0.4"))]), + ); + workspace_with_member(&p, "cfg-if = { git = \"https://example.com/cfg-if\" }"); + p.write("Cargo.lock", lock(&[("cfg-if", "1.0.4", None, None)])); + let out = run(&p).await; + assert_refs( + &out, + &[("pkg:cargo/cfg-if@1.0.4", UUID_A, WiringMode::Vendored)], + ); + bypassed_mark(&out, "crates/a/Cargo.toml"); + } + + /// REGRESSION (member gate): a vendored project's lock has no + /// Socket-sourced package (the copy's entry is sourceless), and a + /// lockless one has no `socket-patch-` pin, so the member walk must + /// not hinge on either: the members are read whenever a `[patch]` + /// wires a Socket copy, lock or no lock. + #[tokio::test] + async fn members_are_read_for_a_vendored_copy_without_a_socket_lock_entry() { + for with_lock in [true, false] { + let p = vendored_workspace( + "cfg-if = { version = \"1\", registry = \"my-corp\" }", + "", + &[], + ); + if !with_lock { + std::fs::remove_file(p.root().join("Cargo.lock")).unwrap(); + } + let out = run(&p).await; + assert_refs( + &out, + &[("pkg:cargo/cfg-if@1.0.4", UUID_A, WiringMode::Vendored)], + ); + bypassed_mark(&out, "crates/a/Cargo.toml"); + } + } } diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index 495aa261f..3f1d39294 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -627,6 +627,13 @@ pub enum UnattestedKind { /// declaration from its own source as a separate package, so the crate /// declaring it builds an unpatched copy, possibly of this version. CargoUnpinnedDeclaration, + /// A vendored Cargo copy that a `[patch.]` entry wires (in v5, + /// the root's `[patch.crates-io]`) beside a copy of the crate from a + /// source that entry does not redirect: a declaration (the root's or a + /// workspace member's) from another registry or git whose requirement + /// accepts the patched version, or a same-version `Cargo.lock` entry + /// from any source. Cargo builds that copy unpatched. + CargoPatchBypassed, } /// A ref discovery emits (so rollback, remove and list find the wiring, From 346a313ae2b5f6293a2d08f52d77d3bba25de3d2 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 5 Nov 2026 23:46:17 -0600 Subject: [PATCH 08/22] Classify Cargo lockless pins, git patch tables and conflict pins Three Cargo follow-ups to the declaration classification: Lockless pins read declarations like locked ones. A `socket-patch-` registry beside `path`, `git` or `registry-index` is no pin (cargo builds that source, and the rewriter's own pinned check agrees), so it no longer adds its requirement to an `UnlockedPin` or makes one on its own. It counts as an unpinned declaration instead, excluding the versions it accepts (every version, without a requirement). A `[workspace.dependencies]` template no manifest inherits is no declaration: a Socket-pinned one makes no lockless pin, and an unpinned one excludes nothing from the root's pin. A non-Socket `[patch]` registry entry no longer counts as an unpinned declaration either (it redirects declarations, it adds none). `DeclSource::Git` keeps its URL, and `Decl::patched_by` matches a `[patch.""]` table against it (a trailing `/` or `.git` ignored). So a git declaration whose repository table points at the same vendored copy no longer withholds that copy, while one from another repository still does. Hosted routing gains the same match for a Socket registry pin in such a table. When no declaration pins the lock's patch, a pin to another patch now makes the conflict pair only when it is live and its requirement may select the locked version (`cargo_req_selects`). One that excludes it is about another version; the stale lock entry is then diagnosed and dropped like any unrouted one. Co-Authored-By: Claude Opus 5.5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 4 +- .../src/vex/discover/cargo.rs | 226 ++++++++++++++++-- 2 files changed, 202 insertions(+), 28 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 11d122a9f..8f35c613d 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -547,9 +547,9 @@ Recognition rules that hold for every ecosystem: * **Patch hosts.** A hosted reference counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin, with no userinfo. The uuid is the URL's LAST canonical-uuid path segment, because grant tokens may themselves be uuid-shaped. The Go module prefix is fixed. `socket-patch-` registry / repository / source names count only through a pin. For a URL on any other host, see **Patch hosts** above. * **Pins, not definitions.** A registry, index or source *definition* alone (cargo `[registries]`, nuget ``, pom ``, uv index tables, `.npmrc`) never makes a reference, because it survives a reverted pin. Sections the package manager ignores are not read: npm's v2 `dependencies` mirror, a `.cargo/config.toml` shadowed by `.cargo/config`. A Socket pin inside a maven `` is diagnosed, never a reference. * **Contested locks.** When one lock wires a package to a patch and another lock resolves the same `name@version` from a non-Socket source, the build's bytes depend on which package manager runs. The reference is then dropped with a `patched_ref_unattributable` diagnostic naming both files. This applies across npm / pnpm / yarn / bun and across uv / pylock / poetry / pdm / Pipfile.lock / requirements. PEP 723 script locks neither contest nor are contested. The root `requirements.txt` and its in-root `-r` includes count as one lock here: pip reads them as one requirement set, where a direct reference wins over a compatible `==` pin of the same version in another file of the set (#1086). A PyPI version matches under PEP 440 equality, so a requirements line pinned in any spelling that can only install the wired release (`==1.2.3.0`, `==v1.2.3`, `===1.2.3`) contests it like `==1.2.3` does; a range or wildcard (`~=1.2.3`, `==1.2.*`) does not (#1602). A **bundled** npm copy (`inBundle: true`, or v1 `bundled: true`, beneath a dependency that bundles it; the root project's own bundles install from the lock, #1618) of the same `name@version` contests the reference too, in the same lock, in the other npm lock of a shrinkwrap/package-lock pair, or in any other lock. npm unpacks it from the parent package's tarball, so no rewire reaches it and it stays unpatched. Bun and vlt unpack bundled copies the same way (#469, #471). pnpm unpacks bundled copies too, but its lock cannot tie one to a reference (see **Unattested references** below). For Bun, that is a `bun.lock` entry whose meta is `{ "bundled": true }`, or a `bun.lockb` record that a dependency edge with the `bundled` behavior bit reaches, including one Bun shares with a regular install. Such an entry is never a reference, and it contests the reference the same way. For vlt, the lock records no node for a bundled copy, so the copy is found in the installed store: a real package directory inside a store package's own `node_modules`. yarn classic's `yarn.lock` records nothing about bundled copies either (#758), so for a lock with a Socket wiring they are found in the installed `node_modules`: each package a parent's `bundleDependencies` / `bundledDependencies` names inside the parent's own `node_modules`, the dependencies of those beside them, and everything nested under them. Such a copy contests the reference in `yarn.lock` (the diagnostic names the parent's lock entry and the copy's directory); a lock-only `vex` with no `node_modules` can't see it. Hosted and vendored scans skip these copies with `redirect_bun_bundled_instance_skipped` / `redirect_vlt_bundled_instance_skipped` / `vendor_bundled_instance_skipped`, and a yarn classic hosted scan names one with `redirect_copy_unpatched`. When a bundled copy is the only instance, vendoring refuses with `vendor_lock_entry_not_rewritable`. A reference withheld only because such an unreachable copy installs beside it (a bundled npm / Bun / vlt copy, an npm copy beneath a `hasShrinkwrap` package (#753) or one npm installs from a git / url / `file:` spec, or a yarn classic git or `file:` directory block of the same `name@version`) is still the rewriter's own wiring of exactly one package version: it is never attested, but `rollback`, `remove`, `list` and the hosted → vendored takeover treat it as an ordinary hosted pin and restore its upstream registry entry (#828), rather than refusing it with `hosted_wiring_contested`. Another entry of the **same** npm, Bun or yarn lock that resolves the wired `name@version` from a non-Socket source (for example a workspace member added after the rewire, then `npm install` / `bun install` / `yarn install`; for yarn berry, a registry locator such as `left-pad@npm:1.3.0` beside the hosted `…::__archiveUrl=` one) contests the reference too (#588). The package manager installs both entries, and that copy stays unpatched. Re-running `scan` / `vendor` rewires every copy. -* **Unattested references.** Some evidence shows a build may run a copy no wiring reaches, but cannot be tied to the reference's exact `name@version` or cannot say the build runs it. The reference then stays a reference: `list`, `rollback` and `remove` find it, and the ledgers' liveness gates (`vendor --check`, `scan`) keep treating the wiring as live, because re-running `scan` / `vendor` could never clear the evidence. Only `vex` omits it, as a run warning and as the `failed[].reason`. Six cases besides Gradle's (`vex_gradle_lock_above_base`): **pnpm bundled copies** (`vex_pnpm_bundled_copy`): a `packages:` entry's `bundledDependencies:` names the copies pnpm unpacks from that package's own tarball, but pnpm never locks them, so the bundled version is not in the lock. A pnpm reference whose package name a `bundledDependencies` list in the same lock names is omitted whatever its version (a missed attestation when the bundled copy is another version, never a false one), and `bundledDependencies: true` (or a value that cannot be read) omits every reference of that lock. It reaches no other lock. **deno.lock** (`vex_deno_lock_copy`): `deno install` installs a `package.json` project's npm dependencies from `deno.lock` and never reads `package-lock.json` / `pnpm-lock.yaml` / `yarn.lock`, so an npm-family reference whose `name@version` the `deno.lock` npm section also locks is omitted (#406). Whether Deno or another package manager populates `node_modules` (`nodeModulesDir: "manual"` allows either) is not in the files, so this holds whatever `nodeModulesDir` says. **Maven reactor roots** (`vex_maven_reactor_root`): a hosted pin in a root `pom.xml` that declares `` / ``, or whose subprojects Maven 4 discovers (see the hosted Maven rewriter), is omitted, because a module's own literal `` overrides a root pin and module poms are not read (#261); hosted scan refuses such roots (`redirect_maven_multimodule_unsupported`), so this only covers pins an older release wrote. **Maven classifier copies** (`vex_maven_classifier_unpatched`): a `` dependency of a hosted-pinned GA is its own coordinate; the classifier-less pin does not retarget it and the grant serves only the main jar, so Maven fetches it from the next repository. When a live, non-profile classifier copy other than `sources` / `javadoc` (`tests`, a native or JDK-specific build) resolves any version other than the suffixed pin — its own literal ``, a `` entry with the same classifier, or no version in the root pom — the pin is omitted. `sources` / `javadoc` copies never run on a classpath and do not withhold the statement; hosted scan only warns about them (`redirect_maven_classifier_unsupported`) and skips the dep when an executable copy names the patched release. **Unpinned Cargo declarations** (`vex_cargo_unpinned_declaration`): cargo resolves each source on its own, so when one declaration of a crate pins it to `socket-patch-` but another — the root `Cargo.toml`'s or a workspace member's, read as hosted scan reads them — is not pinned, with a version requirement that may select the locked patched version, that declaration builds a separate, unpatched copy, possibly of the very version the pin covers. Not pinned means crates.io or another registry that no Socket `[patch.]` registry entry redirects, a `registry-index`, a path or git — the last three even beside `registry = "socket-patch-"`, which they override (hosted scan's own pinned check). The `Cargo.lock` reference is omitted, naming the unpinned declaration's manifest. Hosted scan pins every declaration, so this is a partial revert or a hand edit; the warning says whether re-running `scan` pins it again (a crates.io declaration whose requirement matches no other locked version of the crate) or cannot (`redirect_cargo_toml_dep_unrewritable`: roll the patch back or change the declaration). A declaration whose requirement excludes the patched version (cargo resolves it to another version), and a `[workspace.dependencies]` entry no manifest inherits, do not withhold the statement. When the lock already holds a same-version copy from another source, the reference is withheld as an unpatched lock copy instead. A declaration pinned to ANOTHER Socket patch, which the lock does not resolve the crate from, adds that patch's `Cargo.toml` reference, so the package is gated as `wiring_conflict`. **Vendored Cargo copies a `[patch]` does not reach** (`vex_cargo_patch_bypassed`): a `[patch.]` entry replaces the crate only in declarations from ``, and `vendor` writes `[patch.crates-io]`. So a vendored reference is omitted when a live declaration of the crate (the root `Cargo.toml`'s or a workspace member's) names another registry, a `registry-index` or a git repository, with a version requirement that may select the patched version, unless it is a registry dependency of a registry whose `[patch.]` table points at the same copy; cargo builds that declaration's own, unpatched copy. Workspace members are read for this whenever the root manifest or the project cargo config wires a Socket copy through `[patch]`, whatever the lock says. It is also omitted when `Cargo.lock` locks the crate at exactly the patched version from any source (a registry, crates.io included, or git) beside the vendored copy; the warning names that lock entry. A path declaration (the user's own fork) does not withhold the statement. Re-running `vendor` does not change this; declare the crate from crates.io there, or roll the patch back. +* **Unattested references.** Some evidence shows a build may run a copy no wiring reaches, but cannot be tied to the reference's exact `name@version` or cannot say the build runs it. The reference then stays a reference: `list`, `rollback` and `remove` find it, and the ledgers' liveness gates (`vendor --check`, `scan`) keep treating the wiring as live, because re-running `scan` / `vendor` could never clear the evidence. Only `vex` omits it, as a run warning and as the `failed[].reason`. Six cases besides Gradle's (`vex_gradle_lock_above_base`): **pnpm bundled copies** (`vex_pnpm_bundled_copy`): a `packages:` entry's `bundledDependencies:` names the copies pnpm unpacks from that package's own tarball, but pnpm never locks them, so the bundled version is not in the lock. A pnpm reference whose package name a `bundledDependencies` list in the same lock names is omitted whatever its version (a missed attestation when the bundled copy is another version, never a false one), and `bundledDependencies: true` (or a value that cannot be read) omits every reference of that lock. It reaches no other lock. **deno.lock** (`vex_deno_lock_copy`): `deno install` installs a `package.json` project's npm dependencies from `deno.lock` and never reads `package-lock.json` / `pnpm-lock.yaml` / `yarn.lock`, so an npm-family reference whose `name@version` the `deno.lock` npm section also locks is omitted (#406). Whether Deno or another package manager populates `node_modules` (`nodeModulesDir: "manual"` allows either) is not in the files, so this holds whatever `nodeModulesDir` says. **Maven reactor roots** (`vex_maven_reactor_root`): a hosted pin in a root `pom.xml` that declares `` / ``, or whose subprojects Maven 4 discovers (see the hosted Maven rewriter), is omitted, because a module's own literal `` overrides a root pin and module poms are not read (#261); hosted scan refuses such roots (`redirect_maven_multimodule_unsupported`), so this only covers pins an older release wrote. **Maven classifier copies** (`vex_maven_classifier_unpatched`): a `` dependency of a hosted-pinned GA is its own coordinate; the classifier-less pin does not retarget it and the grant serves only the main jar, so Maven fetches it from the next repository. When a live, non-profile classifier copy other than `sources` / `javadoc` (`tests`, a native or JDK-specific build) resolves any version other than the suffixed pin — its own literal ``, a `` entry with the same classifier, or no version in the root pom — the pin is omitted. `sources` / `javadoc` copies never run on a classpath and do not withhold the statement; hosted scan only warns about them (`redirect_maven_classifier_unsupported`) and skips the dep when an executable copy names the patched release. **Unpinned Cargo declarations** (`vex_cargo_unpinned_declaration`): cargo resolves each source on its own, so when one declaration of a crate pins it to `socket-patch-` but another — the root `Cargo.toml`'s or a workspace member's, read as hosted scan reads them — is not pinned, with a version requirement that may select the locked patched version, that declaration builds a separate, unpatched copy, possibly of the very version the pin covers. Not pinned means crates.io or another registry that no Socket `[patch.]` registry entry redirects, a `registry-index`, a path or git — the last three even beside `registry = "socket-patch-"`, which they override (hosted scan's own pinned check). The `Cargo.lock` reference is omitted, naming the unpinned declaration's manifest. Hosted scan pins every declaration, so this is a partial revert or a hand edit; the warning says whether re-running `scan` pins it again (a crates.io declaration whose requirement matches no other locked version of the crate) or cannot (`redirect_cargo_toml_dep_unrewritable`: roll the patch back or change the declaration). A declaration whose requirement excludes the patched version (cargo resolves it to another version), and a `[workspace.dependencies]` entry no manifest inherits, do not withhold the statement. When the lock already holds a same-version copy from another source, the reference is withheld as an unpatched lock copy instead. A declaration pinned to ANOTHER Socket patch, which the lock does not resolve the crate from, adds that patch's `Cargo.toml` reference, so the package is gated as `wiring_conflict`. **Vendored Cargo copies a `[patch]` does not reach** (`vex_cargo_patch_bypassed`): a `[patch.]` entry replaces the crate only in declarations from ``, and `vendor` writes `[patch.crates-io]`. So a vendored reference is omitted when a live declaration of the crate (the root `Cargo.toml`'s or a workspace member's) names another registry, a `registry-index` or a git repository, with a version requirement that may select the patched version, unless a `[patch]` table for its source points at the same copy (`[patch.]` for a registry dependency, `[patch.""]` for a git one, a trailing `/` or `.git` ignored); cargo builds that declaration's own, unpatched copy. Workspace members are read for this whenever the root manifest or the project cargo config wires a Socket copy through `[patch]`, whatever the lock says. It is also omitted when `Cargo.lock` locks the crate at exactly the patched version from any source (a registry, crates.io included, or git) beside the vendored copy; the warning names that lock entry. A path declaration (the user's own fork) does not withhold the statement. Re-running `vendor` does not change this; declare the crate from crates.io there, or roll the patch back. * **Shrinkwrap without a twin (#899).** npm >= 12 never reads `npm-shrinkwrap.json`: on a project whose only npm lock is the shrinkwrap it resolves the tree from the registry and writes a fresh `package-lock.json`, so a Socket reference there reaches npm <= 11 only. The reference is still discovered, so `list`, `rollback` and `remove` manage it, but `vex` omits the patch (`vex_npm_shrinkwrap_only`, as a run warning and as the `failed[].reason`). A `package-lock.json` beside it (wired the same way) makes it attestable again. Hosted and vendored runs still rewire the shrinkwrap and warn `redirect_npm_shrinkwrap_only` / `vendor_npm_shrinkwrap_only`. -* **Lockless pins.** With no lock to name a version, a `Cargo.toml` pin (every declaration on `socket-patch-`, that registry defined on the patch host for the same uuid) or an exclusive nuget exact-id mapping is never a reference on its own, so v5.0 does not attest it (nor does `list` show it, or `rollback` / `remove` restore it — restore those files from version control). Only a pre-v5 redirect-ledger record naming a version the pin admits keeps it live. The same holds for a gem wired only in the `Gemfile` (the pre-bundler-2.6 mixed state, lock not converged). +* **Lockless pins.** With no lock to name a version, a `Cargo.toml` pin (every declaration on `socket-patch-`, that registry defined on the patch host for the same uuid) or an exclusive nuget exact-id mapping is never a reference on its own, so v5.0 does not attest it (nor does `list` show it, or `rollback` / `remove` restore it — restore those files from version control). Only a pre-v5 redirect-ledger record naming a version the pin admits keeps it live. For Cargo, declarations are read as for a locked pin: a `socket-patch-` registry beside `path`, `git` or `registry-index` is no pin (cargo builds that source), so it admits nothing and its requirement (any version, without one) is excluded from the pin; a `[workspace.dependencies]` template no manifest inherits is no declaration at all. The same holds for a gem wired only in the `Gemfile` (the pre-bundler-2.6 mixed state, lock not converged). **Record resolution.** A candidate's record must carry the patch uuid the lockfile actually **wires**. It is taken from the first source that has one: the manifest (matched qualifier-insensitively), the hosted records above (this run's, then a pre-v5 ledger's), then the vendor ledger's embedded records. If none has it and the run is online, `vex` fetches the patch view by uuid from the patch API — for a v5 hosted checkout this is the normal path. The fetch uses `get`'s API client: the public proxy when no token is configured, and a one-shot 401/403 fallback to the proxy (free patches only). At most 10 fetches run concurrently. Fetched records stay in memory: `vex` never writes the manifest. A candidate still has no record under `--offline`, after a transport error or a 404, or when the patch is refused (paid without an entitled token); it is then omitted as `record_unavailable`, and the run is not aborted. A record whose uuid or package disagrees with the wiring is omitted as `record_mismatch`. The informational `socket-patch.vendor.json` marker is never a record source. When the lockfile wires a package to patch U, a manifest or ledger record for that package under another uuid is superseded, and a human-mode `Note:` says so. diff --git a/crates/socket-patch-core/src/vex/discover/cargo.rs b/crates/socket-patch-core/src/vex/discover/cargo.rs index 971bb3220..6d7cc0ed7 100644 --- a/crates/socket-patch-core/src/vex/discover/cargo.rs +++ b/crates/socket-patch-core/src/vex/discover/cargo.rs @@ -492,8 +492,8 @@ enum DeclSource { RegistryIndex(String), /// `path = "…"` (cargo builds it locally, whatever `registry` says). Path, - /// `git = "…"`. - Git, + /// `git = ""` (empty when the value is not a string). + Git(String), } /// One declaration of a crate: the root-relative file holding it, its @@ -551,19 +551,29 @@ impl Decl { /// Whether the `[patch.
]` table redirects this declaration: /// a registry dependency of that registry (`crates-io`, or a crates.io - /// URL alias, for one with no `registry`). + /// URL alias, for one with no `registry`), or a git dependency of that + /// repository URL (a trailing `/` or `.git` ignored; any other + /// difference reads as another repository, so at worst a missed + /// redirect). fn patched_by(&self, table: &str) -> bool { - self.source == DeclSource::Registry - && match self.registry.as_deref() { + fn git_key(url: &str) -> &str { + let url = url.trim().trim_end_matches('/'); + url.strip_suffix(".git").unwrap_or(url) + } + match &self.source { + DeclSource::Registry => match self.registry.as_deref() { None => is_crates_io_source(table), Some(reg) => reg.trim() == table, - } + }, + DeclSource::Git(url) => !url.trim().is_empty() && git_key(url) == git_key(table), + _ => false, + } } /// Where cargo takes this dependency from, for messages. fn origin(&self) -> String { match (&self.source, self.registry.as_deref()) { - (DeclSource::Git, _) => "a git repository".to_string(), + (DeclSource::Git(_), _) => "a git repository".to_string(), (DeclSource::Path, _) => "a local path".to_string(), (DeclSource::RegistryIndex(url), _) => format!("the registry index {url}"), (_, Some(reg)) => format!("registry `{reg}`"), @@ -694,7 +704,7 @@ fn dependency_entries(doc: &DocumentMut) -> Vec { } else if let Some(entry) = item.as_table_like() { let field = |k: &str| entry.get(k).and_then(Item::as_str).map(str::to_string); let source = if entry.contains_key("git") { - DeclSource::Git + DeclSource::Git(field("git").unwrap_or_default()) } else if entry.contains_key("path") { DeclSource::Path } else if let Some(url) = field("registry-index") { @@ -803,8 +813,17 @@ fn hosted_from_lock( beside_the_pin(ctx, lock, pkg, &purl, &uuid, regs, out); continue; } + // Pins to another Socket patch that may select this version; one + // whose requirement excludes it (or a template nobody inherits) is + // about another version, so it neither conflicts nor keeps the + // stale entry. let others: Vec<(&str, String)> = regs .iter() + .filter(|d| { + d.live + && cargo_req_selects(d.version.as_deref(), &pkg.version, &[]) + != CargoReqMatch::NotOurs + }) .filter_map(|d| Some((d.file.as_str(), d.pin()?))) .collect(); if others.is_empty() { @@ -1113,13 +1132,20 @@ fn unresolved_manifest_pins( let mut uuids: Vec = Vec::new(); for decl in regs.iter().filter(|d| d.socket_shaped()) { let reg = decl.registry.as_deref().unwrap_or_default(); - match decl.socket_uuid() { - Some(uuid) if !uuids.contains(&uuid) => uuids.push(uuid), - Some(_) => {} - None if !malformed.contains(&(decl.file.as_str(), reg)) => { - malformed.push((decl.file.as_str(), reg)); - } - None => {} + if decl.socket_uuid().is_none() && !malformed.contains(&(decl.file.as_str(), reg)) { + malformed.push((decl.file.as_str(), reg)); + } + } + // Classified like a locked pin ([`route`]): only a live registry + // dependency or `[patch]` entry naming `socket-patch-` pins + // (beside `path` / `git` / `registry-index` the registry key does + // not decide, as in the rewriter's own pinned check), and a + // `[workspace.dependencies]` template nobody inherits declares + // nothing. + let live: Vec<&Decl> = regs.iter().filter(|d| d.live).collect(); + for uuid in live.iter().filter_map(|d| d.pin()) { + if !uuids.contains(&uuid) { + uuids.push(uuid); } } for (file, reg) in malformed { @@ -1146,9 +1172,10 @@ fn unresolved_manifest_pins( continue; // a ref (or the conflict pair) came from the lock } for uuid in &uuids { - let pinning: Vec<&Decl> = regs + let pinning: Vec<&Decl> = live .iter() - .filter(|d| d.socket_uuid().as_deref() == Some(uuid.as_str())) + .copied() + .filter(|d| d.pin().as_deref() == Some(uuid.as_str())) .collect(); let reg = pinning[0].registry.as_deref().unwrap_or_default(); let file = pinning[0].file.as_str(); @@ -1157,20 +1184,26 @@ fn unresolved_manifest_pins( Lock::Absent => { // Another Socket registry (or a malformed one) for the // same crate is a conflict, never a pin. - let only_this_patch = regs + let only_this_patch = live .iter() - .filter(|d| d.socket_shaped()) - .all(|d| d.socket_uuid().as_deref() == Some(uuid.as_str())); + .filter(|d| { + d.socket_shaped() + && matches!(d.source, DeclSource::Registry | DeclSource::Patch(_)) + }) + .all(|d| d.pin().as_deref() == Some(uuid.as_str())); let defined_here = definitions .get(reg) .and_then(|index| source_uuid(ctx, index)) .is_some_and(|u| u == *uuid); let version_reqs = distinct(pinning.iter().map(|d| d.version.clone())); - // `None` when some unpinned declaration carries no - // requirement (git, path): it may resolve any version. - let excluded_reqs: Option> = regs + // Every live dependency declaration this patch does + // not pin (a `path` / `git` / `registry-index` one + // beside a `socket-patch-` registry included); `None` + // when one carries no requirement: it may resolve any + // version. + let excluded_reqs: Option> = live .iter() - .filter(|d| !d.socket_shaped()) + .filter(|d| !matches!(d.source, DeclSource::Patch(_)) && d.pin().is_none()) .map(|d| d.version.clone()) .collect(); if let (true, true, false, Some(excluded_reqs)) = ( @@ -1270,7 +1303,7 @@ struct Bypass<'a> { /// /// * a live dependency declaration (the root's or a workspace member's) /// from another registry, a `registry-index` or git, whose requirement -/// may select `version`, unless it is a registry dependency that a +/// may select `version`, unless it is a registry or git dependency that a /// `[patch]` table pointing at this copy redirects ([`Decl::patched_by`]): /// cargo resolves it as a separate package, maybe at this very version; /// * a `Cargo.lock` entry of `name` at exactly `version` with a source @@ -1305,7 +1338,8 @@ fn patch_bypassed( .filter(|d| d.live) .filter(|d| match &d.source { DeclSource::Registry => !d.socket_shaped() && !redirected(d), - DeclSource::RegistryIndex(_) | DeclSource::Git => true, + DeclSource::Git(_) => !redirected(d), + DeclSource::RegistryIndex(_) => true, DeclSource::Path | DeclSource::Patch(_) => false, }) .filter(|d| cargo_req_selects(d.version.as_deref(), version, &[]) != CargoReqMatch::NotOurs) @@ -1994,6 +2028,28 @@ mod tests { assert!(!toml_ref.lockfile_basis_ok()); } + /// No declaration pins the lock's patch A, and the one pinning B + /// cannot select the locked version: B's pin is about another version, + /// so there is no conflict pair, and nothing routes the crate to A, so + /// the stale lock entry is no ref. (B's pin that may select it is the + /// conflict pair, see `manifest_and_lock_disagreeing_on_the_patch_emit_both`.) + #[tokio::test] + async fn another_patch_pin_excluding_the_locked_version_is_no_conflict() { + let p = Project::new(); + p.write("Cargo.toml", manifest(&pinned("serde", "=1.0.2", UUID_B))); + p.write( + "Cargo.lock", + lock(&[("serde", "1.0.1", Some(&index(UUID_A)), Some(CKSUM))]), + ); + let out = run(&p).await; + assert_refs(&out, &[]); + assert!( + diag_codes(&out).contains(&DIAG_REF_INVALID), + "{:#?}", + out.diagnostics + ); + } + /// A project-config definition of the pinned registry that indexes a /// different patch (or a non-Socket host) is what cargo resolves /// against: the lock entry is stale. @@ -2612,6 +2668,95 @@ mod tests { ); } + /// A `socket-patch-` registry beside `path`, `git` or + /// `registry-index` is no pin (cargo builds the path / git / index + /// source; the rewriter's own pinned check agrees): lockless, such a + /// member declaration is an unpinned one, so it excludes the versions it + /// accepts (all of them with no requirement), and alone it makes no + /// lockless pin at all. + #[tokio::test] + async fn lockless_path_or_git_beside_a_socket_registry_is_no_pin() { + let record = "pkg:cargo/serde@1.0.1"; + let reg = format!("registry = \"socket-patch-{UUID_A}\""); + for member_decl in [ + format!("serde = {{ path = \"../serde\", version = \"1\", {reg} }}"), + format!("serde = {{ git = \"https://example.com/serde\", {reg} }}"), + format!( + "serde = {{ version = \"1\", registry-index = \"sparse+https://x.example/\", {reg} }}" + ), + ] { + let p = Project::new(); + pinned_root_workspace(&p); + member(&p, "a", &member_decl); + p.write(".cargo/config.toml", registry_block(UUID_A)); + let out = run(&p).await; + assert_refs(&out, &[]); + assert_eq!( + out.hosted_claim(record, UUID_A), + Some(false), + "{member_decl}: {:#?}", + out.unlocked_pins + ); + } + // The member's overridden registry alone: no pin anywhere. + let p = Project::new(); + workspace_with_member( + &p, + &format!("serde = {{ path = \"../serde\", version = \"1\", {reg} }}"), + ); + p.write(".cargo/config.toml", registry_block(UUID_A)); + let out = run(&p).await; + assert!(out.unlocked_pins.is_empty(), "{:#?}", out.unlocked_pins); + } + + /// A `[workspace.dependencies]` template no manifest inherits declares + /// nothing cargo resolves: lockless, a Socket-pinned one makes no pin, + /// and an unpinned one excludes no version from the root's pin. Once a + /// member inherits the pinned template, it is the pin. + #[tokio::test] + async fn lockless_uninherited_template_is_not_a_declaration() { + let template = |dep: &str| { + format!("[workspace]\nmembers = [\"crates/*\"]\n\n[workspace.dependencies]\n{dep}\n") + }; + let p = Project::new(); + p.write("Cargo.toml", template(&pinned("serde", "1", UUID_A))); + member(&p, "a", "other = \"1\""); + p.write(".cargo/config.toml", registry_block(UUID_A)); + let out = run(&p).await; + assert!(out.unlocked_pins.is_empty(), "{:#?}", out.unlocked_pins); + + let p = Project::new(); + p.write("Cargo.toml", template(&pinned("serde", "1", UUID_A))); + member(&p, "a", "serde = { workspace = true }"); + p.write(".cargo/config.toml", registry_block(UUID_A)); + let out = run(&p).await; + assert_eq!(out.unlocked_pins.len(), 1, "{:#?}", out.unlocked_pins); + assert_eq!( + out.hosted_claim("pkg:cargo/serde@1.0.1", UUID_A), + Some(true) + ); + + let p = Project::new(); + p.write( + "Cargo.toml", + format!( + "[workspace]\nmembers = [\"crates/*\"]\n\n[workspace.dependencies]\n\ + serde = \"1\"\n\n[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n\ + [dependencies]\n{}\n", + pinned("serde", "1", UUID_A) + ), + ); + member(&p, "a", "other = \"1\""); + p.write(".cargo/config.toml", registry_block(UUID_A)); + let out = run(&p).await; + assert_eq!(out.unlocked_pins.len(), 1, "{:#?}", out.unlocked_pins); + assert!(out.unlocked_pins[0].excluded_reqs.is_empty()); + assert_eq!( + out.hosted_claim("pkg:cargo/serde@1.0.1", UUID_A), + Some(true) + ); + } + /// REVIEW (one pin per crate and patch): the root and two members pin the /// same patch — one lockless pin carrying every requirement, one /// diagnostic listing each manifest once. @@ -3741,6 +3886,35 @@ mod tests { } } + /// A `[patch.""]` table pointing at the same copy redirects a + /// declaration from that repository (a trailing `/` or `.git` aside), + /// so the copy stays attested; one from another repository still + /// bypasses it. + #[tokio::test] + async fn a_git_patch_table_for_the_copy_covers_that_repository_only() { + let rel = vendor_path(UUID_A, "cfg-if-1.0.4"); + let table = + format!("\n[patch.\"https://example.com/cfg-if\"]\ncfg-if = {{ path = \"{rel}\" }}\n"); + for member in [ + "cfg-if = { git = \"https://example.com/cfg-if\" }", + "cfg-if = { git = \"https://example.com/cfg-if.git/\", branch = \"main\" }", + ] { + let out = run(&vendored_workspace(member, &table, &[])).await; + assert_refs( + &out, + &[("pkg:cargo/cfg-if@1.0.4", UUID_A, WiringMode::Vendored)], + ); + assert!(out.unattested.is_empty(), "{member}: {:#?}", out.unattested); + } + let out = run(&vendored_workspace( + "cfg-if = { git = \"https://example.com/fork/cfg-if\" }", + &table, + &[], + )) + .await; + bypassed_mark(&out, "crates/a/Cargo.toml"); + } + /// The pre-v5 wiring in the project config redirects only crates.io /// too: a member's git declaration bypasses it the same way. #[tokio::test] From 94af3e4f0cadeda821cef6af601fc5356eece898 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 10:24:56 +0000 Subject: [PATCH 09/22] Normalize foreign hosted urls before reading their origin foreign_hosted_uuid parsed the raw lock url, while hosted_patch_uuid first unescapes \/, percent-decodes and strips a Cargo sparse+ / registry+ prefix. A hosted url on an unconfigured origin that only parses after that normalization was never Undecided and could be marked Dead, dropping live wiring. Share the normalization. Addresses one of the two Bugbot findings on this PR. Co-Authored-By: Claude --- .../src/patch/redirect/mod.rs | 24 +++++++++++++------ .../socket-patch-core/src/vex/discover/mod.rs | 22 ++++++++++++++++- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index f78b4e356..664eaf807 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -7648,8 +7648,11 @@ pub fn hosted_patch_uuid(url: &str, extra_origins: &[String]) -> Option /// exact acceptance rules, without electing one segment. `vex` discovery /// uses it to RECOGNIZE every Socket identity a lockfile mentions, including /// the malformed or rejected shapes whose "last uuid" is not a patch. -pub fn hosted_patch_url_uuids(url: &str, extra_origins: &[String]) -> Option> { - use crate::patch::path_safety::is_canonical_uuid; +/// `url` normalized the way [`hosted_patch_url_uuids`] reads it: `\/` +/// unescaped, a percent-encoded `http(s)://` url decoded, and a Cargo +/// `sparse+` / `registry+` source prefix stripped. `None` without a +/// `://`. +pub(crate) fn normalized_hosted_url(url: &str) -> Option { use crate::utils::purl::percent_decode_purl_component; let unescaped = url.trim().replace("\\/", "/"); @@ -7661,13 +7664,20 @@ pub fn hosted_patch_url_uuids(url: &str, extra_origins: &[String]) -> Option { - &decoded[kind.len() + 1..] + decoded[kind.len() + 1..].to_string() } - _ => decoded.as_str(), - }; - let parsed = reqwest::Url::parse(text).ok()?; + _ => decoded, + }) +} + +pub fn hosted_patch_url_uuids(url: &str, extra_origins: &[String]) -> Option> { + use crate::patch::path_safety::is_canonical_uuid; + use crate::utils::purl::percent_decode_purl_component; + + let text = normalized_hosted_url(url)?; + let parsed = reqwest::Url::parse(&text).ok()?; if !matches!(parsed.scheme(), "https" | "http") || !parsed.username().is_empty() || parsed.password().is_some() diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index 9b128f8b1..9ed147621 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -2935,7 +2935,10 @@ impl RecordLiveness { /// configured): what a lock entry on an origin this run was not given /// claims to resolve from. fn foreign_hosted_uuid(url: &str) -> Option { - let parsed = reqwest::Url::parse(url.trim()).ok()?; + // Normalized the way `hosted_patch_uuid` reads it, so an escaped, + // percent-encoded or `sparse+` url still yields its origin. + let normalized = crate::patch::redirect::normalized_hosted_url(url)?; + let parsed = reqwest::Url::parse(&normalized).ok()?; let origin = parsed.origin().ascii_serialization(); crate::patch::redirect::hosted_patch_uuid(url, &[origin]) } @@ -3620,6 +3623,23 @@ mod tests { use super::testing::*; use super::*; + /// A foreign hosted url reads its origin after the same normalization + /// `hosted_patch_uuid` applies: `\\/` escapes, a percent-encoded url + /// and a Cargo `sparse+` / `registry+` prefix. + #[test] + fn foreign_hosted_uuid_normalizes_like_hosted_patch_uuid() { + const U: &str = "11111111-2222-4333-8444-555555555555"; + for url in [ + format!("https://mirror.example/patch/{U}/x.tgz"), + format!("https:\\/\\/mirror.example\\/patch\\/{U}\\/x.tgz"), + format!("https%3A%2F%2Fmirror.example%2Fpatch%2F{U}%2Fx.tgz"), + format!("sparse+https://mirror.example/patch/{U}/"), + format!("registry+https://mirror.example/patch/{U}/"), + ] { + assert_eq!(foreign_hosted_uuid(&url).as_deref(), Some(U), "{url}"); + } + } + /// The read-only parse [`toml_or_diag`] hands extractors reads every /// value the way the `DocumentMut` it replaced did (#1473): escaped, /// literal and multi-line strings decode, quoted and dotted keys From 6592bf24497d0655437c9a1d6aa5d08a48d910dc Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 6 Nov 2026 09:31:26 -0600 Subject: [PATCH 10/22] Fix review findings on Cargo vendored bypass and pin diagnostics False attestation: the vendored bypass check counted a declaration as redirected by any root or config `[patch]` entry pointing at the copy, including a manifest entry cargo ignores (the project config redefines its crates.io key, or a crates.io URL table replaces `[patch.crates-io]`). Cargo then builds whatever replaced it for those declarations, while the copy's other, honored entry was attested. Only honored entries count now: the root's `[patch]` entries are collected once with their shadow reason (a `Shadow` built from the root manifest and the config), and the bypass check sees only the unshadowed ones. `vendored` (the member gate) is derived from the same list instead of re-reading every `[patch]` table. One classifier: the bypass check runs each declaration through `route` with the copy's honored `[patch]` tables as the pins, as hosted pins do, passing the lock's other versions of the crate. A path declaration (the user's own fork) is still left alone. `patched_by` now covers a `registry-index` declaration with a `[patch.""]` table for the same index (a `registry+` prefix, host case and a trailing `/` ignored; `sparse+` is another source), and treats a `registry-index` naming the crates.io git index as crates.io. The sparse crates.io index stays a separate source. Git URLs compare as cargo's `CanonicalUrl` spells them (one trailing `/` and a `.git` suffix dropped, a GitHub URL lowercased whole, any host lowercased). The bypass detail ends with the remedy for its evidence: a declaration (re-running `vendor` does not help), a crates.io lock entry beside a `[patch.crates-io]` copy (stale: re-lock), or another lock entry (`cargo tree -i` names its declarer). The CLI note no longer claims re-vendoring never helps. Diagnostics: a Socket pin to another patch that the lock does not reflect (its requirement excludes every locked version) used to vanish once any Socket entry of the crate was locked; it is now diagnosed `patched_ref_unattributable`. A well-formed `socket-patch-` registry that pins nothing (beside `path` / `git` / `registry-index`, or on a `[workspace.dependencies]` entry no manifest inherits) is diagnosed `patched_ref_invalid`. The committed `workspace-member` redirect golden gains that diagnostic: its `expected/` tree omits the unchanged inheriting member, so the pinned template reads as uninherited there. `Decl::describe` and `same_version_foreign_entry` are shared by the hosted and vendored checks. Co-Authored-By: Claude Opus 5.5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../src/commands/vex_sources.rs | 7 +- .../src/vex/discover/cargo.rs | 708 ++++++++++++++---- .../vex-discover-golden/redirect-cargo.json | 8 +- 4 files changed, 571 insertions(+), 154 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 8f35c613d..0cde5a9bb 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -547,7 +547,7 @@ Recognition rules that hold for every ecosystem: * **Patch hosts.** A hosted reference counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin, with no userinfo. The uuid is the URL's LAST canonical-uuid path segment, because grant tokens may themselves be uuid-shaped. The Go module prefix is fixed. `socket-patch-` registry / repository / source names count only through a pin. For a URL on any other host, see **Patch hosts** above. * **Pins, not definitions.** A registry, index or source *definition* alone (cargo `[registries]`, nuget ``, pom ``, uv index tables, `.npmrc`) never makes a reference, because it survives a reverted pin. Sections the package manager ignores are not read: npm's v2 `dependencies` mirror, a `.cargo/config.toml` shadowed by `.cargo/config`. A Socket pin inside a maven `` is diagnosed, never a reference. * **Contested locks.** When one lock wires a package to a patch and another lock resolves the same `name@version` from a non-Socket source, the build's bytes depend on which package manager runs. The reference is then dropped with a `patched_ref_unattributable` diagnostic naming both files. This applies across npm / pnpm / yarn / bun and across uv / pylock / poetry / pdm / Pipfile.lock / requirements. PEP 723 script locks neither contest nor are contested. The root `requirements.txt` and its in-root `-r` includes count as one lock here: pip reads them as one requirement set, where a direct reference wins over a compatible `==` pin of the same version in another file of the set (#1086). A PyPI version matches under PEP 440 equality, so a requirements line pinned in any spelling that can only install the wired release (`==1.2.3.0`, `==v1.2.3`, `===1.2.3`) contests it like `==1.2.3` does; a range or wildcard (`~=1.2.3`, `==1.2.*`) does not (#1602). A **bundled** npm copy (`inBundle: true`, or v1 `bundled: true`, beneath a dependency that bundles it; the root project's own bundles install from the lock, #1618) of the same `name@version` contests the reference too, in the same lock, in the other npm lock of a shrinkwrap/package-lock pair, or in any other lock. npm unpacks it from the parent package's tarball, so no rewire reaches it and it stays unpatched. Bun and vlt unpack bundled copies the same way (#469, #471). pnpm unpacks bundled copies too, but its lock cannot tie one to a reference (see **Unattested references** below). For Bun, that is a `bun.lock` entry whose meta is `{ "bundled": true }`, or a `bun.lockb` record that a dependency edge with the `bundled` behavior bit reaches, including one Bun shares with a regular install. Such an entry is never a reference, and it contests the reference the same way. For vlt, the lock records no node for a bundled copy, so the copy is found in the installed store: a real package directory inside a store package's own `node_modules`. yarn classic's `yarn.lock` records nothing about bundled copies either (#758), so for a lock with a Socket wiring they are found in the installed `node_modules`: each package a parent's `bundleDependencies` / `bundledDependencies` names inside the parent's own `node_modules`, the dependencies of those beside them, and everything nested under them. Such a copy contests the reference in `yarn.lock` (the diagnostic names the parent's lock entry and the copy's directory); a lock-only `vex` with no `node_modules` can't see it. Hosted and vendored scans skip these copies with `redirect_bun_bundled_instance_skipped` / `redirect_vlt_bundled_instance_skipped` / `vendor_bundled_instance_skipped`, and a yarn classic hosted scan names one with `redirect_copy_unpatched`. When a bundled copy is the only instance, vendoring refuses with `vendor_lock_entry_not_rewritable`. A reference withheld only because such an unreachable copy installs beside it (a bundled npm / Bun / vlt copy, an npm copy beneath a `hasShrinkwrap` package (#753) or one npm installs from a git / url / `file:` spec, or a yarn classic git or `file:` directory block of the same `name@version`) is still the rewriter's own wiring of exactly one package version: it is never attested, but `rollback`, `remove`, `list` and the hosted → vendored takeover treat it as an ordinary hosted pin and restore its upstream registry entry (#828), rather than refusing it with `hosted_wiring_contested`. Another entry of the **same** npm, Bun or yarn lock that resolves the wired `name@version` from a non-Socket source (for example a workspace member added after the rewire, then `npm install` / `bun install` / `yarn install`; for yarn berry, a registry locator such as `left-pad@npm:1.3.0` beside the hosted `…::__archiveUrl=` one) contests the reference too (#588). The package manager installs both entries, and that copy stays unpatched. Re-running `scan` / `vendor` rewires every copy. -* **Unattested references.** Some evidence shows a build may run a copy no wiring reaches, but cannot be tied to the reference's exact `name@version` or cannot say the build runs it. The reference then stays a reference: `list`, `rollback` and `remove` find it, and the ledgers' liveness gates (`vendor --check`, `scan`) keep treating the wiring as live, because re-running `scan` / `vendor` could never clear the evidence. Only `vex` omits it, as a run warning and as the `failed[].reason`. Six cases besides Gradle's (`vex_gradle_lock_above_base`): **pnpm bundled copies** (`vex_pnpm_bundled_copy`): a `packages:` entry's `bundledDependencies:` names the copies pnpm unpacks from that package's own tarball, but pnpm never locks them, so the bundled version is not in the lock. A pnpm reference whose package name a `bundledDependencies` list in the same lock names is omitted whatever its version (a missed attestation when the bundled copy is another version, never a false one), and `bundledDependencies: true` (or a value that cannot be read) omits every reference of that lock. It reaches no other lock. **deno.lock** (`vex_deno_lock_copy`): `deno install` installs a `package.json` project's npm dependencies from `deno.lock` and never reads `package-lock.json` / `pnpm-lock.yaml` / `yarn.lock`, so an npm-family reference whose `name@version` the `deno.lock` npm section also locks is omitted (#406). Whether Deno or another package manager populates `node_modules` (`nodeModulesDir: "manual"` allows either) is not in the files, so this holds whatever `nodeModulesDir` says. **Maven reactor roots** (`vex_maven_reactor_root`): a hosted pin in a root `pom.xml` that declares `` / ``, or whose subprojects Maven 4 discovers (see the hosted Maven rewriter), is omitted, because a module's own literal `` overrides a root pin and module poms are not read (#261); hosted scan refuses such roots (`redirect_maven_multimodule_unsupported`), so this only covers pins an older release wrote. **Maven classifier copies** (`vex_maven_classifier_unpatched`): a `` dependency of a hosted-pinned GA is its own coordinate; the classifier-less pin does not retarget it and the grant serves only the main jar, so Maven fetches it from the next repository. When a live, non-profile classifier copy other than `sources` / `javadoc` (`tests`, a native or JDK-specific build) resolves any version other than the suffixed pin — its own literal ``, a `` entry with the same classifier, or no version in the root pom — the pin is omitted. `sources` / `javadoc` copies never run on a classpath and do not withhold the statement; hosted scan only warns about them (`redirect_maven_classifier_unsupported`) and skips the dep when an executable copy names the patched release. **Unpinned Cargo declarations** (`vex_cargo_unpinned_declaration`): cargo resolves each source on its own, so when one declaration of a crate pins it to `socket-patch-` but another — the root `Cargo.toml`'s or a workspace member's, read as hosted scan reads them — is not pinned, with a version requirement that may select the locked patched version, that declaration builds a separate, unpatched copy, possibly of the very version the pin covers. Not pinned means crates.io or another registry that no Socket `[patch.]` registry entry redirects, a `registry-index`, a path or git — the last three even beside `registry = "socket-patch-"`, which they override (hosted scan's own pinned check). The `Cargo.lock` reference is omitted, naming the unpinned declaration's manifest. Hosted scan pins every declaration, so this is a partial revert or a hand edit; the warning says whether re-running `scan` pins it again (a crates.io declaration whose requirement matches no other locked version of the crate) or cannot (`redirect_cargo_toml_dep_unrewritable`: roll the patch back or change the declaration). A declaration whose requirement excludes the patched version (cargo resolves it to another version), and a `[workspace.dependencies]` entry no manifest inherits, do not withhold the statement. When the lock already holds a same-version copy from another source, the reference is withheld as an unpatched lock copy instead. A declaration pinned to ANOTHER Socket patch, which the lock does not resolve the crate from, adds that patch's `Cargo.toml` reference, so the package is gated as `wiring_conflict`. **Vendored Cargo copies a `[patch]` does not reach** (`vex_cargo_patch_bypassed`): a `[patch.]` entry replaces the crate only in declarations from ``, and `vendor` writes `[patch.crates-io]`. So a vendored reference is omitted when a live declaration of the crate (the root `Cargo.toml`'s or a workspace member's) names another registry, a `registry-index` or a git repository, with a version requirement that may select the patched version, unless a `[patch]` table for its source points at the same copy (`[patch.]` for a registry dependency, `[patch.""]` for a git one, a trailing `/` or `.git` ignored); cargo builds that declaration's own, unpatched copy. Workspace members are read for this whenever the root manifest or the project cargo config wires a Socket copy through `[patch]`, whatever the lock says. It is also omitted when `Cargo.lock` locks the crate at exactly the patched version from any source (a registry, crates.io included, or git) beside the vendored copy; the warning names that lock entry. A path declaration (the user's own fork) does not withhold the statement. Re-running `vendor` does not change this; declare the crate from crates.io there, or roll the patch back. +* **Unattested references.** Some evidence shows a build may run a copy no wiring reaches, but cannot be tied to the reference's exact `name@version` or cannot say the build runs it. The reference then stays a reference: `list`, `rollback` and `remove` find it, and the ledgers' liveness gates (`vendor --check`, `scan`) keep treating the wiring as live, because re-running `scan` / `vendor` could never clear the evidence. Only `vex` omits it, as a run warning and as the `failed[].reason`. Six cases besides Gradle's (`vex_gradle_lock_above_base`): **pnpm bundled copies** (`vex_pnpm_bundled_copy`): a `packages:` entry's `bundledDependencies:` names the copies pnpm unpacks from that package's own tarball, but pnpm never locks them, so the bundled version is not in the lock. A pnpm reference whose package name a `bundledDependencies` list in the same lock names is omitted whatever its version (a missed attestation when the bundled copy is another version, never a false one), and `bundledDependencies: true` (or a value that cannot be read) omits every reference of that lock. It reaches no other lock. **deno.lock** (`vex_deno_lock_copy`): `deno install` installs a `package.json` project's npm dependencies from `deno.lock` and never reads `package-lock.json` / `pnpm-lock.yaml` / `yarn.lock`, so an npm-family reference whose `name@version` the `deno.lock` npm section also locks is omitted (#406). Whether Deno or another package manager populates `node_modules` (`nodeModulesDir: "manual"` allows either) is not in the files, so this holds whatever `nodeModulesDir` says. **Maven reactor roots** (`vex_maven_reactor_root`): a hosted pin in a root `pom.xml` that declares `` / ``, or whose subprojects Maven 4 discovers (see the hosted Maven rewriter), is omitted, because a module's own literal `` overrides a root pin and module poms are not read (#261); hosted scan refuses such roots (`redirect_maven_multimodule_unsupported`), so this only covers pins an older release wrote. **Maven classifier copies** (`vex_maven_classifier_unpatched`): a `` dependency of a hosted-pinned GA is its own coordinate; the classifier-less pin does not retarget it and the grant serves only the main jar, so Maven fetches it from the next repository. When a live, non-profile classifier copy other than `sources` / `javadoc` (`tests`, a native or JDK-specific build) resolves any version other than the suffixed pin — its own literal ``, a `` entry with the same classifier, or no version in the root pom — the pin is omitted. `sources` / `javadoc` copies never run on a classpath and do not withhold the statement; hosted scan only warns about them (`redirect_maven_classifier_unsupported`) and skips the dep when an executable copy names the patched release. **Unpinned Cargo declarations** (`vex_cargo_unpinned_declaration`): cargo resolves each source on its own, so when one declaration of a crate pins it to `socket-patch-` but another — the root `Cargo.toml`'s or a workspace member's, read as hosted scan reads them — is not pinned, with a version requirement that may select the locked patched version, that declaration builds a separate, unpatched copy, possibly of the very version the pin covers. Not pinned means crates.io or another registry that no Socket `[patch.]` registry entry redirects, a `registry-index`, a path or git — the last three even beside `registry = "socket-patch-"`, which they override (hosted scan's own pinned check). The `Cargo.lock` reference is omitted, naming the unpinned declaration's manifest. Hosted scan pins every declaration, so this is a partial revert or a hand edit; the warning says whether re-running `scan` pins it again (a crates.io declaration whose requirement matches no other locked version of the crate) or cannot (`redirect_cargo_toml_dep_unrewritable`: roll the patch back or change the declaration). A declaration whose requirement excludes the patched version (cargo resolves it to another version), and a `[workspace.dependencies]` entry no manifest inherits, do not withhold the statement. When the lock already holds a same-version copy from another source, the reference is withheld as an unpatched lock copy instead. A declaration pinned to ANOTHER Socket patch, which the lock does not resolve the crate from, adds that patch's `Cargo.toml` reference, so the package is gated as `wiring_conflict`. **Vendored Cargo copies a `[patch]` does not reach** (`vex_cargo_patch_bypassed`): a `[patch.]` entry replaces the crate only in declarations from ``, and `vendor` writes `[patch.crates-io]`. So a vendored reference is omitted when a live declaration of the crate (the root `Cargo.toml`'s or a workspace member's) names another registry, a `registry-index` or a git repository, with a version requirement that may select the patched version, unless a `[patch]` table for its source points at the same copy (`[patch.]` for a registry dependency, `[patch.""]` for a git one, compared as cargo canonicalizes it: one trailing `/` and a `.git` suffix ignored, a GitHub URL case-insensitive); cargo builds that declaration's own, unpatched copy. Workspace members are read for this whenever the root manifest or the project cargo config wires a Socket copy through `[patch]`, whatever the lock says. It is also omitted when `Cargo.lock` locks the crate at exactly the patched version from any source (a registry, crates.io included, or git) beside the vendored copy; the warning names that lock entry. A path declaration (the user's own fork) does not withhold the statement. A `registry-index` declaration is covered by a `[patch.""]` table for the same index (a `registry+` prefix, the host's case and a trailing `/` ignored; a `sparse+` index is another source than the git one), and one naming the crates.io git index is crates.io. Only `[patch]` entries cargo honors count: a manifest entry the project config or a crates.io URL table replaces covers nothing. The warning ends with the remedy for its evidence: for a declaration, re-running `vendor` does not help (declare the crate from crates.io there, point a `[patch]` table for its source at the copy, or roll the patch back); for a stale crates.io lock entry beside a `[patch.crates-io]` copy, re-lock (re-run `vendor`, or `cargo update -p `); for another lock entry, `cargo tree -i @` names the dependency that declares it. * **Shrinkwrap without a twin (#899).** npm >= 12 never reads `npm-shrinkwrap.json`: on a project whose only npm lock is the shrinkwrap it resolves the tree from the registry and writes a fresh `package-lock.json`, so a Socket reference there reaches npm <= 11 only. The reference is still discovered, so `list`, `rollback` and `remove` manage it, but `vex` omits the patch (`vex_npm_shrinkwrap_only`, as a run warning and as the `failed[].reason`). A `package-lock.json` beside it (wired the same way) makes it attestable again. Hosted and vendored runs still rewire the shrinkwrap and warn `redirect_npm_shrinkwrap_only` / `vendor_npm_shrinkwrap_only`. * **Lockless pins.** With no lock to name a version, a `Cargo.toml` pin (every declaration on `socket-patch-`, that registry defined on the patch host for the same uuid) or an exclusive nuget exact-id mapping is never a reference on its own, so v5.0 does not attest it (nor does `list` show it, or `rollback` / `remove` restore it — restore those files from version control). Only a pre-v5 redirect-ledger record naming a version the pin admits keeps it live. For Cargo, declarations are read as for a locked pin: a `socket-patch-` registry beside `path`, `git` or `registry-index` is no pin (cargo builds that source), so it admits nothing and its requirement (any version, without one) is excluded from the pin; a `[workspace.dependencies]` template no manifest inherits is no declaration at all. The same holds for a gem wired only in the `Gemfile` (the pre-bundler-2.6 mixed state, lock not converged). diff --git a/crates/socket-patch-cli/src/commands/vex_sources.rs b/crates/socket-patch-cli/src/commands/vex_sources.rs index d99bae325..01cc60a89 100644 --- a/crates/socket-patch-cli/src/commands/vex_sources.rs +++ b/crates/socket-patch-cli/src/commands/vex_sources.rs @@ -282,9 +282,8 @@ fn unattested_note(kind: UnattestedKind) -> (&'static str, &'static str) { ), UnattestedKind::CargoPatchBypassed => ( NOTE_CARGO_PATCH_BYPASSED, - "not attested while that copy builds; the vendored wiring itself is intact, so \ - re-running `vendor` does not change this (declare the crate from crates.io \ - there, or roll the patch back)", + "not attested while that copy can build beside the vendored one; the vendored \ + wiring itself is intact (the warning above says how to clear it)", ), } } @@ -1874,7 +1873,7 @@ mod tests { .iter() .any(|n| n.code == NOTE_CARGO_PATCH_BYPASSED && n.detail.contains("from registry `my-corp`") - && n.detail.contains("re-running `vendor`")), + && n.detail.contains("says how to clear it")), "{:?}", gated.notes ); diff --git a/crates/socket-patch-core/src/vex/discover/cargo.rs b/crates/socket-patch-core/src/vex/discover/cargo.rs index 6d7cc0ed7..0bf1be73d 100644 --- a/crates/socket-patch-core/src/vex/discover/cargo.rs +++ b/crates/socket-patch-core/src/vex/discover/cargo.rs @@ -153,10 +153,11 @@ use crate::patch::redirect::generation::{hosted_pin_name, PIN_NAME_PREFIX}; use crate::patch::redirect::{cargo_req_selects, CargoReqMatch}; use crate::utils::digest::is_hex64_lower; use crate::vendor::cargo_config::{ - effective_config_rel_in, patch_entries, registry_definitions, CargoPatchEntry, CONFIG_LEGACY, - CONFIG_TOML, + effective_config_rel_in, patch_entries, registry_definitions, CONFIG_LEGACY, CONFIG_TOML, +}; +use crate::vendor::cargo_manifest::{ + crates_io_url_alias_tables, is_crates_io_source, is_crates_io_url_alias, }; -use crate::vendor::cargo_manifest::{crates_io_url_alias_tables, is_crates_io_source}; use crate::vendor::cargo_tag; use crate::vendor::lock_inventory::LockIntegrity; @@ -179,14 +180,27 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { let config = read_config(ctx, out).await; // The root manifest, then every workspace member's — the manifests the // hosted rewriter pins a crate in (each that declares it). + // Every `[patch]` entry pointing at a Socket copy, the root manifest's + // (with why cargo ignores it, if it does) and then the config's. + let mut wired_all: Vec<(WiredCopy, Option)> = Vec::new(); + let mut shadow: Option = None; + if let Some(doc) = &root { + let s = Shadow::new(doc, config.as_ref()); + wired_all.extend(socket_copies(doc).into_iter().map(|w| { + let why = s.why(&w.table, &w.key, Some(&w.path)); + (w, why) + })); + shadow = Some(s); + } + if let Some((_, doc)) = &config { + wired_all.extend(socket_copies(doc).into_iter().map(|w| (w, None))); + } let mut manifests: Vec<(String, DocumentMut)> = Vec::new(); if let Some(doc) = root { let root_pins = manifest_has_socket_pin(&doc); // A vendored copy wired in the root or the config is weighed against // every member's declarations ([`patch_bypassed`]). - let vendored = std::iter::once(&doc) - .chain(config.iter().map(|(_, c)| c)) - .any(|d| !socket_copies(d).is_empty()); + let vendored = !wired_all.is_empty(); manifests.push((CARGO_TOML.to_string(), doc)); manifests.extend(read_members(ctx, &lock, root_pins, vendored, out).await); } @@ -232,10 +246,13 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { if let Some(decls) = &decls { unresolved_manifest_pins(ctx, &lock, decls, &definitions, out); } - let wired: Vec<(String, VendorRef)> = manifest - .into_iter() - .chain(config.iter().map(|(_, doc)| doc)) - .flat_map(socket_copies) + // Only the entries cargo honors redirect declarations to a copy: a + // shadowed manifest entry covers nothing (cargo builds whatever replaced + // it). + let wired: Vec = wired_all + .iter() + .filter(|(_, why)| why.is_none()) + .map(|(w, _)| w.clone()) .collect(); let bypass = Bypass { lock: &lock, @@ -243,58 +260,15 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { wired: &wired, }; if let Some(doc) = manifest { - // Cargo drops a manifest crates.io `[patch]` item that the project - // config redefines under the same key (any version), and a - // `[patch.""]` table replaces `[patch.crates-io]` - // wholesale — a Socket entry there is dead wiring, never attested. - let config_keys: Vec<(&str, String, Option)> = config - .iter() - .flat_map(|(file, doc)| { - patch_entries(doc) - .into_iter() - .filter(|e| is_crates_io_source(e.source)) - .map(move |e| (*file, e.key.to_string(), e.path.map(same_path_key))) - }) - .collect(); - let aliases = crates_io_url_alias_tables(doc); - let shadowed = |entry: &CargoPatchEntry<'_>| -> Option { - if !is_crates_io_source(entry.source) { - return None; - } - if let (true, Some(alias)) = (entry.source == "crates-io", aliases.first()) { - return Some(format!( - "the `[patch.\"{alias}\"]` table replaces `[patch.crates-io]` in cargo" - )); - } - // The same key wiring the same copy (a half-migrated project) - // is not a shadow: the config's own ref carries it. - let own = entry.path.map(same_path_key); - config_keys - .iter() - .find(|(_, key, path)| key == entry.key && *path != own) - .map(|(file, key, _)| { - format!("{file} redefines `[patch]` key `{key}`, which replaces it in cargo") - }) - }; - vendored_from_patches(ctx, CARGO_TOML, doc, &lock, &shadowed, &bypass, out).await; + vendored_from_patches(ctx, CARGO_TOML, doc, &lock, shadow.as_ref(), &bypass, out).await; } if let Some((file, doc)) = &config { - vendored_from_patches(ctx, file, doc, &lock, &|_| None, &bypass, out).await; + vendored_from_patches(ctx, file, doc, &lock, None, &bypass, out).await; } - let copies: Vec = wired.into_iter().map(|(_, v)| v).collect(); + let copies: Vec = wired_all.into_iter().map(|(w, _)| w.vref).collect(); unattributed_tags(&lock, &copies, out); } -/// Every Socket copy a file's `[patch]` tables point at, with the -/// `[patch.]` table pointing at it. -fn socket_copies(doc: &DocumentMut) -> Vec<(String, VendorRef)> { - patch_entries(doc) - .into_iter() - .filter_map(|e| Some((e.source.to_string(), e.path.and_then(vendor_ref)?))) - .filter(|(_, v)| v.eco == "cargo") - .collect() -} - /// Diagnose Socket-tagged sourceless lock entries that no visible /// `[patch]` wiring names (`.socket/vendor/cargo//-`): /// the lock records a vendored copy the files discovery reads cannot @@ -550,26 +524,42 @@ impl Decl { } /// Whether the `[patch.
]` table redirects this declaration: - /// a registry dependency of that registry (`crates-io`, or a crates.io - /// URL alias, for one with no `registry`), or a git dependency of that - /// repository URL (a trailing `/` or `.git` ignored; any other - /// difference reads as another repository, so at worst a missed - /// redirect). + /// + /// * a registry dependency of that registry (`crates-io`, or a + /// crates.io URL alias, for one with no `registry`); + /// * a `registry-index` dependency of that index URL ([`index_key`]); + /// one naming the crates.io git index is crates.io itself (the same + /// cargo source), so `[patch.crates-io]` redirects it too; + /// * a git dependency of that repository ([`git_key`], cargo's + /// canonical spelling). + /// + /// Any other difference reads as another source: at worst a missed + /// redirect, so a withheld statement, never a false one. fn patched_by(&self, table: &str) -> bool { - fn git_key(url: &str) -> &str { - let url = url.trim().trim_end_matches('/'); - url.strip_suffix(".git").unwrap_or(url) - } match &self.source { DeclSource::Registry => match self.registry.as_deref() { None => is_crates_io_source(table), Some(reg) => reg.trim() == table, }, - DeclSource::Git(url) => !url.trim().is_empty() && git_key(url) == git_key(table), - _ => false, + DeclSource::RegistryIndex(url) => { + (is_crates_io_url_alias(url) && is_crates_io_source(table)) + || index_key(url).is_some_and(|k| index_key(table) == Some(k)) + } + DeclSource::Git(url) => git_key(url).is_some_and(|k| git_key(table) == Some(k)), + DeclSource::Path | DeclSource::Patch(_) => false, } } + /// "`` declares `` `` from ``", for + /// messages. + fn describe(&self, name: &str) -> String { + let req = self + .version + .as_deref() + .map_or_else(|| "with no version".to_string(), |v| format!("{v:?}")); + format!("{} declares {name} {req} from {}", self.file, self.origin()) + } + /// Where cargo takes this dependency from, for messages. fn origin(&self) -> String { match (&self.source, self.registry.as_deref()) { @@ -582,6 +572,77 @@ impl Decl { } } +/// `url` with its scheme and host lowercased (both are case-insensitive), +/// trimmed, or `None` when empty. +fn lower_host(url: &str) -> Option { + let url = url.trim(); + if url.is_empty() { + return None; + } + let host_end = url.find("://").map(|s| s + 3).map_or(0, |start| { + start + url[start..].find('/').unwrap_or(url.len() - start) + }); + Some(format!( + "{}{}", + url[..host_end].to_ascii_lowercase(), + &url[host_end..] + )) +} + +/// A git repository URL as cargo's `CanonicalUrl` spells it: one trailing +/// `/` dropped, a GitHub URL lowercased whole (GitHub paths are +/// case-insensitive), a trailing `.git` dropped. `None` when empty. +fn git_key(url: &str) -> Option { + let mut url = lower_host(url)?; + if url.ends_with('/') { + url.pop(); + } + let github = url + .split_once("://") + .is_some_and(|(_, rest)| rest.split('/').next() == Some("github.com")); + if github { + url = url.to_ascii_lowercase(); + } + if let Some(stripped) = url.strip_suffix(".git") { + url = stripped.to_string(); + } + Some(url) +} + +/// A registry index URL for comparison: a `registry+` prefix (the lock's +/// spelling of a git index) dropped, a `sparse+` one kept (a sparse index +/// is another cargo source than the git index at the same URL), the host +/// lowercased and a trailing `/` dropped. `None` when empty. +fn index_key(url: &str) -> Option { + let url = url.trim(); + let url = url.strip_prefix("registry+").unwrap_or(url); + let (sparse, url) = match url.strip_prefix("sparse+") { + Some(rest) => ("sparse+", rest), + None => ("", url), + }; + let url = lower_host(url)?; + Some(format!("{sparse}{}", url.trim_end_matches('/'))) +} + +/// The source of a lock entry of `name` at exactly `version` that is not a +/// Socket patch server's: a copy cargo builds from crates.io, another +/// registry or git beside the patched one. +fn same_version_foreign_entry<'l>( + ctx: &DiscoverCtx<'_>, + lock: &'l Lock, + name: &str, + version: &str, +) -> Option<&'l str> { + lock.packages() + .iter() + .filter(|p| p.name == name && p.version == version) + .find_map(|p| { + p.source + .as_deref() + .filter(|s| source_uuid(ctx, s).is_none()) + }) +} + /// Crate name → its declarations ([`Decl`]). type Decls = BTreeMap>; @@ -932,13 +993,7 @@ fn beside_the_pin( out: &mut Discovery, ) { let same_name = || lock.packages().iter().filter(|p| p.name == pkg.name); - let twin = same_name().any(|p| { - p.version == pkg.version - && p.source - .as_deref() - .is_some_and(|s| source_uuid(ctx, s).is_none()) - }); - if twin { + if same_version_foreign_entry(ctx, lock, &pkg.name, &pkg.version).is_some() { return; } let others = distinct( @@ -986,13 +1041,7 @@ fn beside_the_pin( }; let declared = unpinned .iter() - .map(|(d, _)| { - let req = d - .version - .as_deref() - .map_or_else(|| "with no version".to_string(), |v| format!("{v:?}")); - format!("{} declares {} {req} from {}", d.file, pkg.name, d.origin()) - }) + .map(|(d, _)| d.describe(&pkg.name)) .collect::>() .join(", and "); // What a re-scan can do: the hosted rewriter pins a crates.io @@ -1158,19 +1207,52 @@ fn unresolved_manifest_pins( ), ); } + // A well-formed Socket registry that pins nothing: say so, since the + // file reads like wiring. + let mut ineffective: Vec<(&str, String)> = Vec::new(); + for d in regs + .iter() + .filter(|d| d.socket_uuid().is_some() && (d.pin().is_none() || !d.live)) + { + let why = if d.live { + format!( + "cargo builds it from {}, whatever `registry` says", + d.origin() + ) + } else { + "no manifest inherits that `[workspace.dependencies]` entry".to_string() + }; + if !ineffective.contains(&(d.file.as_str(), why.clone())) { + ineffective.push((d.file.as_str(), why)); + } + } + for (file, why) in ineffective { + out.diag( + DIAG_REF_INVALID, + file, + format!("{file}: {name}'s Socket patch registry pins nothing there: {why}"), + ); + } if uuids.is_empty() { continue; } - let socket_sourced = lock.packages().iter().any(|p| { - p.name == *name - && p.source - .as_deref() - .and_then(|s| source_uuid(ctx, s)) - .is_some() - }); - if socket_sourced { - continue; // a ref (or the conflict pair) came from the lock - } + // Patches the lock resolves the crate from, and those a ref + // already carries (the lock's, or a conflict pair beside it). + let locked_from: Vec = lock + .packages() + .iter() + .filter(|p| p.name == *name) + .filter_map(|p| p.source.as_deref().and_then(|s| source_uuid(ctx, s))) + .collect(); + let carried = |uuid: &str| { + locked_from.iter().any(|u| u == uuid) + || out.refs.iter().any(|r| { + r.uuid == uuid + && crate::utils::purl::purl_parts(&r.purl) + .is_some_and(|(ty, n, _)| ty == "cargo" && n == *name) + }) + }; + let uuids: Vec = uuids.into_iter().filter(|u| !carried(u)).collect(); for uuid in &uuids { let pinning: Vec<&Decl> = live .iter() @@ -1225,6 +1307,11 @@ fn unresolved_manifest_pins( } format!("there is no {CARGO_LOCK} to fix its version") } + Lock::Parsed { .. } if !locked_from.is_empty() => format!( + "{CARGO_LOCK} resolves it only from other Socket patches, at versions \ + this pin's requirement excludes (a stale lock: the next build \ + re-resolves this declaration)" + ), Lock::Parsed { .. } => format!( "{CARGO_LOCK} does not resolve it from a Socket patch server (a stale \ lock, or a patch server outside the allowlist — see \ @@ -1282,6 +1369,81 @@ async fn copy_tag(ctx: &DiscoverCtx<'_>, artifact_rel: &str, out: &mut Discovery } } +/// One `[patch.
]` entry that points at a Socket copy. +#[derive(Debug, Clone)] +struct WiredCopy { + table: String, + key: String, + path: String, + vref: VendorRef, +} + +/// Every Socket copy a file's `[patch]` tables point at. +fn socket_copies(doc: &DocumentMut) -> Vec { + patch_entries(doc) + .into_iter() + .filter_map(|e| { + let path = e.path?; + let vref = vendor_ref(path).filter(|v| v.eco == "cargo")?; + Some(WiredCopy { + table: e.source.to_string(), + key: e.key.to_string(), + path: path.to_string(), + vref, + }) + }) + .collect() +} + +/// Why cargo ignores a root-manifest `[patch]` entry, if it does: a +/// `[patch.""]` table replaces `[patch.crates-io]` +/// wholesale, and a project-config crates.io `[patch]` item with the same +/// key replaces the manifest's (unless it wires the same path, the +/// half-migrated shape attested through the config). Config entries are +/// never shadowed. +struct Shadow { + /// The manifest's `[patch.""]` table keys. + aliases: Vec, + /// The config's crates.io `[patch]` items: (file, key, path). + config_keys: Vec<(&'static str, String, Option)>, +} + +impl Shadow { + fn new(manifest: &DocumentMut, config: Option<&(&'static str, DocumentMut)>) -> Self { + let config_keys = config + .into_iter() + .flat_map(|(file, doc)| { + patch_entries(doc) + .into_iter() + .filter(|e| is_crates_io_source(e.source)) + .map(move |e| (*file, e.key.to_string(), e.path.map(same_path_key))) + }) + .collect(); + Shadow { + aliases: crates_io_url_alias_tables(manifest), + config_keys, + } + } + + fn why(&self, table: &str, key: &str, path: Option<&str>) -> Option { + if !is_crates_io_source(table) { + return None; + } + if let (true, Some(alias)) = (table == "crates-io", self.aliases.first()) { + return Some(format!( + "the `[patch.\"{alias}\"]` table replaces `[patch.crates-io]` in cargo" + )); + } + let own = path.map(same_path_key); + self.config_keys + .iter() + .find(|(_, k, p)| k == key && *p != own) + .map(|(file, k, _)| { + format!("{file} redefines `[patch]` key `{k}`, which replaces it in cargo") + }) + } +} + /// What a vendored copy is weighed against for copies its `[patch]` wiring /// does not redirect ([`patch_bypassed`]). struct Bypass<'a> { @@ -1289,30 +1451,37 @@ struct Bypass<'a> { /// Every declaration in the root and member manifests (`None`: no /// usable root manifest). decls: Option<&'a Decls>, - /// Every Socket copy the manifest and config `[patch]` tables point at, - /// with the `[patch.]` table pointing at it. - wired: &'a [(String, VendorRef)], + /// The `[patch]` entries pointing at a Socket copy that cargo honors + /// (a shadowed manifest entry redirects nothing). + wired: &'a [WiredCopy], +} + +/// Whether a `Cargo.lock` `source` is crates.io (its git or sparse index). +fn is_crates_io_lock_source(source: &str) -> bool { + is_crates_io_url_alias(source.trim().trim_start_matches("registry+")) + || index_key(source).as_deref() == Some("sparse+https://index.crates.io") } /// Mark the vendored ref `purl` (the copy `vref` of `name`@`version`) /// [`UnattestedKind::CargoPatchBypassed`] when the build may also compile -/// a copy of that version its wiring does not reach. A `[patch.]` -/// entry replaces the crate only in declarations from `` (v5 +/// a copy of that version its wiring does not reach. A `[patch.
]` +/// entry replaces the crate only in declarations from `
` (v5 /// `vendor` writes `[patch.crates-io]`), so either of these builds /// unpatched bytes: /// -/// * a live dependency declaration (the root's or a workspace member's) -/// from another registry, a `registry-index` or git, whose requirement -/// may select `version`, unless it is a registry or git dependency that a -/// `[patch]` table pointing at this copy redirects ([`Decl::patched_by`]): -/// cargo resolves it as a separate package, maybe at this very version; -/// * a `Cargo.lock` entry of `name` at exactly `version` with a source -/// (any registry or git; a Socket patch server's is hosted wiring, a ref -/// of its own): that copy is already locked beside the vendored one. +/// * a declaration (the root's or a workspace member's) that [`route`] +/// finds [`Route::Unpinned`] against the honored entries pointing at +/// this copy — the classifier hosted pins use, with this copy's tables +/// as the pins: a live dependency that may select `version` from a +/// source no such table redirects (another registry, a +/// `registry-index`, git, or crates.io when the copy is wired for +/// another source only). A path declaration (the user's own fork) is +/// left alone, as the lock's copy claim leaves its sourceless entry +/// alone; a `socket-patch-*` registry dependency is hosted wiring; +/// * a `Cargo.lock` entry of `name` at exactly `version` with a non-Socket +/// source: that copy is already locked beside the vendored one. /// -/// A path declaration (the user's own fork) is left alone, as the lock's -/// copy claim leaves its sourceless entry alone; a `socket-patch-*` -/// registry dependency is hosted wiring, weighed there. The ref stays +/// The detail ends with the remedy for its evidence. The ref stays /// (rollback, remove and the ledgers still see the wiring), but nothing /// attests it. fn patch_bypassed( @@ -1324,36 +1493,29 @@ fn patch_bypassed( purl: &str, out: &mut Discovery, ) { - let redirected = |d: &Decl| { + let pins: Vec<(&str, String)> = bypass + .wired + .iter() + .filter(|w| w.vref.uuid == vref.uuid && w.vref.leaf == vref.leaf) + .map(|w| (w.table.as_str(), vref.uuid.clone())) + .collect(); + let others = distinct( bypass - .wired + .lock + .packages() .iter() - .any(|(table, v)| v.uuid == vref.uuid && v.leaf == vref.leaf && d.patched_by(table)) - }; + .filter(|p| p.name == name && cargo_tag::strip_tag(&p.version) != version) + .map(|p| Some(cargo_tag::strip_tag(&p.version).to_string())), + ); let declared: Vec<&Decl> = bypass .decls .and_then(|d| d.get(name)) .into_iter() .flatten() - .filter(|d| d.live) - .filter(|d| match &d.source { - DeclSource::Registry => !d.socket_shaped() && !redirected(d), - DeclSource::Git(_) => !redirected(d), - DeclSource::RegistryIndex(_) => true, - DeclSource::Path | DeclSource::Patch(_) => false, - }) - .filter(|d| cargo_req_selects(d.version.as_deref(), version, &[]) != CargoReqMatch::NotOurs) + .filter(|d| d.source != DeclSource::Path) + .filter(|d| matches!(route(d, &pins, version, &others), Route::Unpinned(_))) .collect(); - let locked = bypass - .lock - .packages() - .iter() - .filter(|p| p.name == name && p.version == version) - .find_map(|p| { - p.source - .as_deref() - .filter(|s| source_uuid(ctx, s).is_none()) - }); + let locked = same_version_foreign_entry(ctx, bypass.lock, name, version); let redirects = format!( "which no [patch] entry pointing at {} redirects", vref.artifact_rel @@ -1365,13 +1527,7 @@ fn patch_bypassed( }); let declared = declared .iter() - .map(|d| { - let req = d - .version - .as_deref() - .map_or_else(|| "with no version".to_string(), |v| format!("{v:?}")); - format!("{} declares {name} {req} from {}", d.file, d.origin()) - }) + .map(|d| d.describe(name)) .collect::>() .join(", and "); ( @@ -1379,17 +1535,38 @@ fn patch_bypassed( format!( "{declared}, {redirects}: cargo resolves that declaration as a separate \ package, so the crate declaring it builds an unpatched {name} that the \ - requirement allows to be {version}{evidence}" + requirement allows to be {version}{evidence}. Re-running `vendor` does \ + not change this: declare {name} from crates.io there (or point a \ + [patch] table for its source at the copy), or roll the patch back" + ), + ) + } + (None, Some(source)) => { + // A crates.io entry beside a copy `[patch.crates-io]` wires is + // stale (cargo would redirect it); any other is declared by + // some dependency, or left over from one that was removed. + let stale = is_crates_io_lock_source(source) + && pins.iter().any(|(table, _)| is_crates_io_source(table)); + let remedy = if stale { + format!( + "the entry is stale (`[patch.crates-io]` redirects crates.io), so \ + re-lock: re-run `socket-patch vendor`, or `cargo update -p {name}`" + ) + } else { + format!( + "`cargo tree -i {name}@{version}` names the dependency that declares it \ + (change that declaration or roll the patch back); if none does any \ + more, re-lock with `cargo update -p {name}`" + ) + }; + ( + CARGO_LOCK, + format!( + "{CARGO_LOCK} also locks {name} {version} from {source}, {redirects}: \ + cargo builds that copy unpatched beside the vendored one; {remedy}" ), ) } - (None, Some(source)) => ( - CARGO_LOCK, - format!( - "{CARGO_LOCK} also locks {name} {version} from {source}, {redirects}: cargo \ - builds that copy unpatched beside the vendored one" - ), - ), (None, None) => return, }; out.unattested( @@ -1401,14 +1578,15 @@ fn patch_bypassed( ); } -/// Vendored refs from one file's `[patch]` tables. `shadowed` names why -/// cargo ignores an entry (a higher-precedence item replaces it), if so. +/// Vendored refs from one file's `[patch]` tables. `shadow` (the root +/// manifest's) names why cargo ignores an entry (a higher-precedence item +/// replaces it), if so. async fn vendored_from_patches( ctx: &DiscoverCtx<'_>, file: &str, doc: &DocumentMut, lock: &Lock, - shadowed: &(dyn Fn(&CargoPatchEntry<'_>) -> Option + Sync), + shadow: Option<&Shadow>, bypass: &Bypass<'_>, out: &mut Discovery, ) { @@ -1420,7 +1598,7 @@ async fn vendored_from_patches( continue; // a user path, or the uuid-less `.socket/cargo-patches/` } let name = entry.name; - if let Some(why) = shadowed(&entry) { + if let Some(why) = shadow.and_then(|s| s.why(entry.source, entry.key, entry.path)) { out.diag( DIAG_REF_INVALID, file, @@ -2048,6 +2226,59 @@ mod tests { "{:#?}", out.diagnostics ); + // REVIEW: B's pin, which the lock does not reflect, is still + // diagnosed (it used to vanish once any Socket entry was locked). + assert!( + out.diagnostics + .iter() + .any(|d| d.code == DIAG_REF_UNATTRIBUTABLE + && d.file == std::path::Path::new("Cargo.toml") + && d.detail.contains(UUID_B) + && d.detail.contains("only from other Socket patches")), + "{:#?}", + out.diagnostics + ); + } + + /// REVIEW: a well-formed Socket registry that pins nothing — beside + /// `path` / `git` / `registry-index`, or on a `[workspace.dependencies]` + /// entry no manifest inherits — is diagnosed, since the file reads like + /// wiring. + #[tokio::test] + async fn an_ineffective_socket_registry_is_diagnosed() { + let reg = format!("registry = \"socket-patch-{UUID_A}\""); + let p = mixed_workspace( + &pinned("serde", "1", UUID_A), + &format!("serde = {{ path = \"../serde\", version = \"1\", {reg} }}"), + &[], + ); + let out = run(&p).await; + assert!( + out.diagnostics.iter().any(|d| d.code == DIAG_REF_INVALID + && d.file == std::path::Path::new("crates/a/Cargo.toml") + && d.detail.contains("pins nothing there") + && d.detail.contains("a local path")), + "{:#?}", + out.diagnostics + ); + + let p = Project::new(); + p.write( + "Cargo.toml", + format!( + "[workspace]\nmembers = [\"crates/*\"]\n\n[workspace.dependencies]\n{}\n", + pinned("serde", "1", UUID_A) + ), + ); + member(&p, "a", "other = \"1\""); + let out = run(&p).await; + assert!( + out.diagnostics.iter().any(|d| d.code == DIAG_REF_INVALID + && d.file == std::path::Path::new("Cargo.toml") + && d.detail.contains("no manifest inherits")), + "{:#?}", + out.diagnostics + ); } /// A project-config definition of the pinned registry that indexes a @@ -3958,4 +4189,185 @@ mod tests { bypassed_mark(&out, "crates/a/Cargo.toml"); } } + + // ── review findings ────────────────────────────────────────────── + + /// The one vendored cfg-if ref of `out`: attested (no mark) or not. + fn assert_vendored_attested(out: &Discovery, attested: bool, what: &str) { + assert_refs( + out, + &[("pkg:cargo/cfg-if@1.0.4", UUID_A, WiringMode::Vendored)], + ); + assert_eq!( + out.unattested.is_empty(), + attested, + "{what}: {:#?}", + out.unattested + ); + } + + /// REVIEW (false attestation): the project config redefines the + /// manifest's crates.io `[patch]` key with a git fork, so cargo ignores + /// the manifest's entry for the copy and builds the fork for crates.io + /// declarations. That shadowed entry used to count as redirecting them, + /// so the copy's other (honored) `[patch.my-corp]` entry was attested + /// beside the fork. + #[tokio::test] + async fn a_shadowed_manifest_entry_covers_no_declaration() { + let rel = vendor_path(UUID_A, "cfg-if-1.0.4"); + let p = vendored_workspace( + "cfg-if = \"1\"", + &format!("\n[patch.my-corp]\ncfg-if = {{ path = \"{rel}\" }}\n"), + &[], + ); + p.write( + ".cargo/config.toml", + "[patch.crates-io]\ncfg-if-socket-aaaaaaaa = { package = \"cfg-if\", git = \ + \"https://example.com/fork/cfg-if\" }\n", + ); + let out = run(&p).await; + assert_vendored_attested(&out, false, "shadowed"); + let u = bypassed_mark(&out, "Cargo.toml"); + assert!( + u.detail + .contains("Cargo.toml declares cfg-if \"1\" from crates.io"), + "{}", + u.detail + ); + assert!( + diag_codes(&out).contains(&DIAG_REF_INVALID), + "{:#?}", + out.diagnostics + ); + } + + /// REVIEW: a `registry-index` declaration is redirected by a + /// `[patch.""]` table for the same index (host case, a + /// trailing `/` and the lock's `registry+` spelling aside; `sparse+` is + /// another source), and one naming the crates.io git index is + /// crates.io. The sparse crates.io index stays another source: whether + /// `[patch.crates-io]` reaches it is not what the files show, so it is + /// withheld rather than risked. + #[tokio::test] + async fn a_registry_index_patch_table_covers_that_index() { + let rel = vendor_path(UUID_A, "cfg-if-1.0.4"); + let table = |key: &str| format!("\n[patch.\"{key}\"]\ncfg-if = {{ path = \"{rel}\" }}\n"); + let decl = + |index: &str| format!("cfg-if = {{ version = \"1\", registry-index = \"{index}\" }}"); + for (member, extra, attested) in [ + ( + decl("https://My-Corp.example/index/"), + table("https://my-corp.example/index"), + true, + ), + ( + decl("sparse+https://my-corp.example/index/"), + table("sparse+https://my-corp.example/index"), + true, + ), + ( + decl("https://github.com/rust-lang/crates.io-index"), + String::new(), + true, + ), + ( + decl("sparse+https://my-corp.example/index/"), + table("https://my-corp.example/index"), + false, + ), + ( + decl("https://other.example/index"), + table("https://my-corp.example/index"), + false, + ), + ( + decl("sparse+https://index.crates.io/"), + String::new(), + false, + ), + ] { + let out = run(&vendored_workspace(&member, &extra, &[])).await; + assert_vendored_attested(&out, attested, &member); + } + } + + /// REVIEW: the remedy follows the evidence — a declaration cannot be + /// fixed by re-vendoring; a crates.io lock entry beside a + /// `[patch.crates-io]` copy is stale (re-lock); another source's entry + /// is some dependency's (`cargo tree -i` finds it). + #[tokio::test] + async fn the_bypass_remedy_follows_its_evidence() { + let out = run(&vendored_workspace( + "cfg-if = { version = \"1\", registry = \"my-corp\" }", + "", + &[], + )) + .await; + let u = bypassed_mark(&out, "crates/a/Cargo.toml"); + assert!( + u.detail + .contains("Re-running `vendor` does not change this"), + "{}", + u.detail + ); + + let out = run(&vendored_workspace( + "cfg-if = \"1\"", + "", + &[("1.0.4", CRATES_IO)], + )) + .await; + let u = bypassed_mark(&out, "Cargo.lock"); + assert!( + u.detail.contains("stale") + && u.detail.contains("cargo update -p cfg-if") + && !u.detail.contains("Re-running `vendor` does not"), + "{}", + u.detail + ); + + let out = run(&vendored_workspace( + "cfg-if = \"1\"", + "", + &[("1.0.4", MY_CORP)], + )) + .await; + let u = bypassed_mark(&out, "Cargo.lock"); + assert!( + u.detail.contains("cargo tree -i cfg-if@1.0.4") && !u.detail.contains("stale"), + "{}", + u.detail + ); + } + + /// REVIEW: git URLs compare as cargo's `CanonicalUrl` spells them — a + /// GitHub URL case-insensitively, any host's case aside, one trailing + /// `/` and `.git` dropped — so another path on a non-GitHub host is + /// another repository. + #[tokio::test] + async fn a_git_patch_table_matches_cargos_canonical_url() { + let rel = vendor_path(UUID_A, "cfg-if-1.0.4"); + let table = |key: &str| format!("\n[patch.\"{key}\"]\ncfg-if = {{ path = \"{rel}\" }}\n"); + let decl = |url: &str| format!("cfg-if = {{ git = \"{url}\" }}"); + for (member, extra, attested) in [ + ( + decl("https://GitHub.com/Foo/CFG-IF.git"), + table("https://github.com/foo/cfg-if"), + true, + ), + ( + decl("https://EXAMPLE.com/foo/cfg-if/"), + table("https://example.com/foo/cfg-if"), + true, + ), + ( + decl("https://example.com/Foo/cfg-if"), + table("https://example.com/foo/cfg-if"), + false, + ), + ] { + let out = run(&vendored_workspace(&member, &extra, &[])).await; + assert_vendored_attested(&out, attested, &member); + } + } } diff --git a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-cargo.json b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-cargo.json index 99be573f0..c14a317f3 100644 --- a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-cargo.json +++ b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-cargo.json @@ -634,7 +634,13 @@ "lockfile_basis_ok": true } ], - "diagnostics": [], + "diagnostics": [ + { + "code": "patched_ref_invalid", + "file": "Cargo.toml", + "detail": "Cargo.toml: serde's Socket patch registry pins nothing there: no manifest inherits that `[workspace.dependencies]` entry" + } + ], "recognized": [ { "uuid": "11111111-1111-1111-1111-111111111111", From d92dab4de9ce2457521ff764f2393376f615017c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 22:21:37 +0000 Subject: [PATCH 11/22] Read Cargo members whenever a Socket pin could need them Second review round on Cargo member liveness and origin matching. - `read_members` returned nothing for a parsed lock with no Socket-sourced package or an unparseable one, so a member's `socket-patch-` pin beside a stale lock lost its "stale lock, or a server outside the allowlist" diagnostic and its uuid was no longer recognized (rule 11). Members are now parsed whenever the lock is Socket-sourced or a `socket-patch-` name appears in a member's text, the root's or the config's, whatever the lock says; a root or config `[patch]` pin now counts too (it governs members' declarations). - `foreign_hosted_uuid` read any url on any host ending in the record's uuid as a foreign patch server's, so an artifact store's `/blobs//` url kept a dead hosted record retained (and raised a takeover note). It now needs a patch server's path shape (a `patch`, `patch-registry` or `gopatch` segment). - Two inserted functions split their neighbours' doc comments (`hosted_patch_url_uuids`, `log_read`); restored. Co-Authored-By: Claude Opus 5.5 --- .../src/patch/redirect/mod.rs | 12 ++-- .../src/vex/discover/cargo.rs | 55 +++++++++++-------- .../socket-patch-core/src/vex/discover/mod.rs | 21 ++++++- 3 files changed, 57 insertions(+), 31 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 664eaf807..9498fb906 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -7642,12 +7642,6 @@ pub fn hosted_patch_uuid(url: &str, extra_origins: &[String]) -> Option hosted_patch_url_uuids(url, extra_origins)?.pop() } -/// EVERY canonical-uuid path segment of a Socket-HOSTED url, in path order -/// (the grant token first when it is uuid-shaped, the patch uuid last), or -/// `None` when `url` is not on an accepted origin — [`hosted_patch_uuid`]'s -/// exact acceptance rules, without electing one segment. `vex` discovery -/// uses it to RECOGNIZE every Socket identity a lockfile mentions, including -/// the malformed or rejected shapes whose "last uuid" is not a patch. /// `url` normalized the way [`hosted_patch_url_uuids`] reads it: `\/` /// unescaped, a percent-encoded `http(s)://` url decoded, and a Cargo /// `sparse+` / `registry+` source prefix stripped. `None` without a @@ -7672,6 +7666,12 @@ pub(crate) fn normalized_hosted_url(url: &str) -> Option { }) } +/// EVERY canonical-uuid path segment of a Socket-HOSTED url, in path order +/// (the grant token first when it is uuid-shaped, the patch uuid last), or +/// `None` when `url` is not on an accepted origin — [`hosted_patch_uuid`]'s +/// exact acceptance rules, without electing one segment. `vex` discovery +/// uses it to RECOGNIZE every Socket identity a lockfile mentions, including +/// the malformed or rejected shapes whose "last uuid" is not a patch. pub fn hosted_patch_url_uuids(url: &str, extra_origins: &[String]) -> Option> { use crate::patch::path_safety::is_canonical_uuid; use crate::utils::purl::percent_decode_purl_component; diff --git a/crates/socket-patch-core/src/vex/discover/cargo.rs b/crates/socket-patch-core/src/vex/discover/cargo.rs index 3139998ec..d41c2153f 100644 --- a/crates/socket-patch-core/src/vex/discover/cargo.rs +++ b/crates/socket-patch-core/src/vex/discover/cargo.rs @@ -156,7 +156,12 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { // hosted rewriter pins a crate in (each that declares it). let mut manifests: Vec<(String, DocumentMut)> = Vec::new(); if let Some(doc) = root { - let root_pins = manifest_has_socket_pin(&doc); + // Any Socket pin the root or the config carries, a `[patch]` + // entry included: members may hold the declarations it governs. + let root_pins = doc.to_string().contains(PIN_NAME_PREFIX) + || config + .as_ref() + .is_some_and(|c| c.1.to_string().contains(PIN_NAME_PREFIX)); manifests.push((CARGO_TOML.to_string(), doc)); manifests.extend(read_members(ctx, &lock, root_pins, out).await); } @@ -337,30 +342,26 @@ async fn read_toml(ctx: &DiscoverCtx<'_>, file: &str, out: &mut Discovery) -> Op /// not a declaration — or a build `target/`). Unparseable ones are skipped /// (diagnosed). /// -/// Members only ever decide about Socket wiring, so the walk is skipped -/// when there is none they could decide about: a parsed lock with no -/// Socket-sourced package, or an unparseable one (no ref either way). With -/// no lock, a member matters only when some manifest carries a -/// `socket-patch-` pin (a lockless pin, [`UnlockedPin`]); the walk has the -/// texts, so the parse is skipped otherwise. +/// Members only ever decide about Socket wiring, so they are parsed only +/// when there is some they could decide about: a lock with a +/// Socket-sourced package, or a `socket-patch-` name in a member's own +/// text, the root's or the config's (a pin to diagnose or recognize, a +/// `[patch]` entry members declare, a lockless pin, [`UnlockedPin`]), +/// whatever the lock says (stale, unparseable or absent). async fn read_members( ctx: &DiscoverCtx<'_>, lock: &Lock, root_pins: bool, out: &mut Discovery, ) -> Vec<(String, DocumentMut)> { - let lockless = match lock { - Lock::Parsed(_) if has_socket_sourced(ctx, lock) => false, - Lock::Parsed(_) | Lock::Unparseable => return Vec::new(), - Lock::Absent => true, - }; + let socket_sourced = matches!(lock, Lock::Parsed(_)) && has_socket_sourced(ctx, lock); let texts: Vec<(String, String)> = crate::utils::cargo_workspace::member_manifest_texts_in(&ctx.view) .await .into_iter() .filter(|(rel, _)| crate::patch::redirect::is_cargo_member_manifest_key(rel)) .collect(); - if lockless && !root_pins && !texts.iter().any(|(_, t)| t.contains(PIN_NAME_PREFIX)) { + if !socket_sourced && !root_pins && !texts.iter().any(|(_, t)| t.contains(PIN_NAME_PREFIX)) { return Vec::new(); } let mut members = Vec::new(); @@ -373,16 +374,6 @@ async fn read_members( members } -/// Whether some dependency declaration of `doc` names a `socket-patch-` -/// registry. -fn manifest_has_socket_pin(doc: &DocumentMut) -> bool { - dependency_entries(doc).iter().any(|d| { - d.registry - .as_deref() - .is_some_and(|r| r.trim().starts_with(PIN_NAME_PREFIX)) - }) -} - /// Whether the lock resolves some package from a Socket patch server. fn has_socket_sourced(ctx: &DiscoverCtx<'_>, lock: &Lock) -> bool { lock.packages().iter().any(|p| { @@ -1843,6 +1834,24 @@ mod tests { let p = Project::new(); workspace_with_member(&p, &pinned("serde", "1", UUID_A)); assert!(member_read(&p.discover().await)); + // Review on #2012 (round 2): a stale crates.io lock beside a member + // pin still reads the member, so the pin is diagnosed (a stale lock + // or a server outside the allowlist) and its uuid recognized. + let p = Project::new(); + workspace_with_member(&p, &pinned("serde", "1", UUID_A)); + p.write( + "Cargo.lock", + lock(&[("serde", "1.0.1", Some(CRATES_IO), Some(CKSUM))]), + ); + let out = p.discover().await; + assert!(member_read(&out)); + assert!( + out.diagnostics + .iter() + .any(|d| d.code == DIAG_REF_UNATTRIBUTABLE), + "{:?}", + out.diagnostics + ); } /// A rotated grant token in the definition is the same patch. diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index 9ed147621..72a1a2edb 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -1666,7 +1666,6 @@ impl<'a> DiscoverCtx<'a> { } } - /// Log a guarded read of `rel` ([`Discovery::read`]). /// Account for `text`, which a walk through [`Self::view`] read from /// `rel` on this ctx's behalf, exactly as [`Self::read_text`] accounts /// for its own reads (rule 11 recognition and the read log). @@ -1675,6 +1674,7 @@ impl<'a> DiscoverCtx<'a> { self.recognize_text(rel, text); } + /// Log a guarded read of `rel` ([`Discovery::read`]). fn log_read(&self, rel: &str, readable: bool) { self.read .lock() @@ -2933,12 +2933,21 @@ impl RecordLiveness { /// ([`crate::patch::redirect::hosted_patch_uuid`]'s grammar — no userinfo, /// the last canonical-uuid path segment — with the url's origin taken as /// configured): what a lock entry on an origin this run was not given -/// claims to resolve from. +/// claims to resolve from. Only a url in a Socket patch server's path +/// shape (a `patch`, `patch-registry` or `gopatch` segment) counts: any +/// other url merely ending in the uuid (an artifact store's blob path) +/// resolves from elsewhere. fn foreign_hosted_uuid(url: &str) -> Option { // Normalized the way `hosted_patch_uuid` reads it, so an escaped, // percent-encoded or `sparse+` url still yields its origin. let normalized = crate::patch::redirect::normalized_hosted_url(url)?; let parsed = reqwest::Url::parse(&normalized).ok()?; + if !parsed + .path_segments()? + .any(|s| matches!(s, "patch" | "patch-registry" | "gopatch")) + { + return None; + } let origin = parsed.origin().ascii_serialization(); crate::patch::redirect::hosted_patch_uuid(url, &[origin]) } @@ -3638,6 +3647,14 @@ mod tests { ] { assert_eq!(foreign_hosted_uuid(&url).as_deref(), Some(U), "{url}"); } + // Review on #2012 (round 2): a url merely ending in the uuid is not + // a patch server's. + for url in [ + format!("https://artifacts.corp/blobs/{U}/x-1.0.0.tgz"), + format!("https://evil.example/{U}/x.tgz"), + ] { + assert_eq!(foreign_hosted_uuid(&url), None, "{url}"); + } } /// The read-only parse [`toml_or_diag`] hands extractors reads every From ebe84e199f9c6e654f42dc2e67d505bb8335bca1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 22:33:49 +0000 Subject: [PATCH 12/22] Count registry = "crates-io" as re-pinnable in the Cargo remedy Second review round. The unpinned-declaration remedy sent every declaration with an explicit `registry` key to "it names another registry ... roll the patch back", including `registry = "crates-io"`, which the hosted rewriter re-pins (#386); it now says a re-scan pins it. Co-Authored-By: Claude Opus 5.5 --- .../src/vex/discover/cargo.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-core/src/vex/discover/cargo.rs b/crates/socket-patch-core/src/vex/discover/cargo.rs index 216ecddbb..6c62b9878 100644 --- a/crates/socket-patch-core/src/vex/discover/cargo.rs +++ b/crates/socket-patch-core/src/vex/discover/cargo.rs @@ -947,7 +947,10 @@ fn beside_the_pin( let refused: Vec = unpinned .iter() .filter_map(|(d, selects)| { - let why = match (&d.source, d.registry.as_deref(), selects) { + // `registry = "crates-io"` is crates.io itself, which the + // rewriter re-pins like a declaration with no `registry` (#386). + let registry = d.registry.as_deref().filter(|r| r.trim() != "crates-io"); + let why = match (&d.source, registry, selects) { (DeclSource::Registry, None, CargoReqMatch::Ours) => return None, (DeclSource::Registry, None, _) => { let also: Vec<&str> = others @@ -2167,6 +2170,19 @@ mod tests { "{}", u.detail ); + // Review on #2019 (round 2): `registry = "crates-io"` is crates.io, + // which a re-scan re-pins (#386). + let p = mixed_workspace( + &pinned("serde", "=1.0.1", UUID_A), + "serde = { version = \"1\", registry = \"crates-io\" }", + &[], + ); + let u = run(&p).await.unattested.remove(0); + assert!( + u.detail.contains("re-run `socket-patch scan`"), + "{}", + u.detail + ); // `"1"` also matches the crates.io 1.0.200 locked beside it. let p = mixed_workspace( From 52230be5f463b3343c00b998461aaa4e0ec68aa9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 22:55:33 +0000 Subject: [PATCH 13/22] Let a shadowed [patch] Socket pin pin no hosted declaration FALSE ATTESTATION (found reviewing #2019; fixed here, where `Shadow` lives): a root `[patch.crates-io]` Socket registry pin cargo ignores (a `[patch.""]` table replaces it, or the config redefines the key) still counted as pinning every crates.io declaration of the crate, so a member's plain declaration was taken as routed to the patch and the hosted ref attested while cargo builds crates.io. Such an entry stays a declaration (Socket's wiring is present, the ref stays) but is not live, and `route` only weighs live `[patch]` pins, as the vendored bypass check already weighs only honored entries. Co-Authored-By: Claude Opus 5.5 --- .../src/vex/discover/cargo.rs | 44 ++++++++++++++++--- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/crates/socket-patch-core/src/vex/discover/cargo.rs b/crates/socket-patch-core/src/vex/discover/cargo.rs index 83e652565..1404a98dc 100644 --- a/crates/socket-patch-core/src/vex/discover/cargo.rs +++ b/crates/socket-patch-core/src/vex/discover/cargo.rs @@ -221,10 +221,14 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { for patch in patch_entries(doc) { if let Some(reg) = patch.registry { let name = patch.name.to_string(); - decls - .entry(name) - .or_default() - .push(Decl::patch(CARGO_TOML, patch.source, reg)); + // An entry cargo ignores (a crates.io URL table replaces + // `[patch.crates-io]`; the config redefines the key) is + // Socket's wiring still (the ref stays) but pins nothing. + let mut decl = Decl::patch(CARGO_TOML, patch.source, reg); + decl.live = shadow + .as_ref() + .is_none_or(|s| s.why(patch.source, patch.key, None).is_none()); + decls.entry(name).or_default().push(decl); } } decls @@ -995,7 +999,7 @@ fn beside_the_pin( let patch_pins: Vec<(&str, String)> = regs .iter() .filter_map(|d| match &d.source { - DeclSource::Patch(table) => Some((table.as_str(), d.socket_uuid()?)), + DeclSource::Patch(table) if d.live => Some((table.as_str(), d.socket_uuid()?)), _ => None, }) .collect(); @@ -2444,6 +2448,36 @@ mod tests { p } + /// Review on #2019 (round 2, fixed here where `Shadow` lives): a + /// `[patch.crates-io]` Socket pin that cargo ignores (a crates.io URL + /// table replaces it) pins no declaration: the member's crates.io + /// declaration is unpinned, so the ref is not attested. + #[tokio::test] + async fn a_shadowed_patch_pin_pins_no_declaration() { + let patch = format!( + "serde = \"1\"\n\n[patch.crates-io]\nserde = {{ registry = \"socket-patch-{UUID_A}\" }}\n" + ); + let p = mixed_workspace(&patch, "serde = \"1\"", &[]); + let out = run(&p).await; + assert!(out.unattested.is_empty(), "{:#?}", out.unattested); + let shadowed = format!( + "{patch}\n[patch.\"https://github.com/rust-lang/crates.io-index\"]\nother = {{ path = \"other\" }}\n" + ); + let p = mixed_workspace(&shadowed, "serde = \"1\"", &[]); + let out = run(&p).await; + assert_refs( + &out, + &[("pkg:cargo/serde@1.0.1", UUID_A, WiringMode::Hosted)], + ); + assert!( + out.unattested + .iter() + .any(|u| u.kind == UnattestedKind::CargoUnpinnedDeclaration), + "{:#?}", + out.unattested + ); + } + /// The one unattested mark of `out`, asserted to be the hosted serde /// ref's [`UnattestedKind::CargoUnpinnedDeclaration`] naming `file`. fn unpinned_mark<'a>(out: &'a Discovery, file: &str) -> &'a Unattested { From a7611ef32633e17169a67cc6579797e6584f8d46 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 10:25:05 +0000 Subject: [PATCH 14/22] Shadow a manifest [patch] item by any config item for its source and key Cargo replaces a manifest [patch] item with a project-config item of the same source and key, whatever the source table and whether either wires a path. Shadow only looked at crates.io tables and treated two path-less items (a registry pin and a git fork) as the same wiring, so a config fork left the manifest's Socket pin live and its stale lock row could be attested. Only an identical path is now the same wiring, and named and URL tables are compared too (URLs as an index key). The ineffective-registry diagnostic also called a shadowed [patch] pin an uninherited [workspace.dependencies] template; it now says cargo ignores the [patch] entry. Addresses Bugbot 4501098254, 4501098256 and 4501098258. Co-Authored-By: Claude --- .../src/vex/discover/cargo.rs | 80 ++++++++++++++++--- 1 file changed, 67 insertions(+), 13 deletions(-) diff --git a/crates/socket-patch-core/src/vex/discover/cargo.rs b/crates/socket-patch-core/src/vex/discover/cargo.rs index 1404a98dc..f94a11f17 100644 --- a/crates/socket-patch-core/src/vex/discover/cargo.rs +++ b/crates/socket-patch-core/src/vex/discover/cargo.rs @@ -1217,6 +1217,9 @@ fn unresolved_manifest_pins( "cargo builds it from {}, whatever `registry` says", d.origin() ) + } else if matches!(d.source, DeclSource::Patch(_)) { + "cargo ignores that `[patch]` entry (a crates.io URL table or the project config replaces it)" + .to_string() } else { "no manifest inherits that `[workspace.dependencies]` entry".to_string() }; @@ -1395,15 +1398,27 @@ fn socket_copies(doc: &DocumentMut) -> Vec { /// Why cargo ignores a root-manifest `[patch]` entry, if it does: a /// `[patch.""]` table replaces `[patch.crates-io]` -/// wholesale, and a project-config crates.io `[patch]` item with the same -/// key replaces the manifest's (unless it wires the same path, the +/// wholesale, and a project-config `[patch]` item for the same source and +/// key replaces the manifest's (unless both wire the same path, the /// half-migrated shape attested through the config). Config entries are /// never shadowed. struct Shadow { /// The manifest's `[patch.""]` table keys. aliases: Vec, - /// The config's crates.io `[patch]` items: (file, key, path). - config_keys: Vec<(&'static str, String, Option)>, + /// The config's `[patch]` items: (file, source table, key, path). + config_keys: Vec<(&'static str, String, String, Option)>, +} + +/// Whether two `[patch.]` table names patch the same source: +/// crates.io under any spelling, else the same name or URL. +fn same_patch_source(a: &str, b: &str) -> bool { + if is_crates_io_source(a) || is_crates_io_source(b) { + return is_crates_io_source(a) && is_crates_io_source(b); + } + // A URL table compares as an index (host case, a trailing `/`); a + // registry name verbatim. + let norm = |s: &str| index_key(s).unwrap_or_else(|| s.trim().to_string()); + norm(a) == norm(b) } impl Shadow { @@ -1411,10 +1426,14 @@ impl Shadow { let config_keys = config .into_iter() .flat_map(|(file, doc)| { - patch_entries(doc) - .into_iter() - .filter(|e| is_crates_io_source(e.source)) - .map(move |e| (*file, e.key.to_string(), e.path.map(same_path_key))) + patch_entries(doc).into_iter().map(move |e| { + ( + *file, + e.source.to_string(), + e.key.to_string(), + e.path.map(same_path_key), + ) + }) }) .collect(); Shadow { @@ -1424,19 +1443,20 @@ impl Shadow { } fn why(&self, table: &str, key: &str, path: Option<&str>) -> Option { - if !is_crates_io_source(table) { - return None; - } if let (true, Some(alias)) = (table == "crates-io", self.aliases.first()) { return Some(format!( "the `[patch.\"{alias}\"]` table replaces `[patch.crates-io]` in cargo" )); } + // Only an identical path is the same wiring: two path-less items (a + // registry pin and a git fork, say) are not. let own = path.map(same_path_key); self.config_keys .iter() - .find(|(_, k, p)| k == key && *p != own) - .map(|(file, k, _)| { + .find(|(_, src, k, p)| { + k == key && same_patch_source(src, table) && (own.is_none() || *p != own) + }) + .map(|(file, _, k, _)| { format!("{file} redefines `[patch]` key `{k}`, which replaces it in cargo") }) } @@ -4300,6 +4320,40 @@ mod tests { ); } + /// REVIEW (Bugbot on #2024): a config item for the same key replaces + /// the manifest's `[patch]` item in cargo whatever the source table and + /// whether or not either wires a path: a path-less config git fork + /// shadows a path-less manifest registry pin, and a `[patch.my-corp]` + /// config item shadows the manifest's `[patch.my-corp]` copy. + #[test] + fn config_items_shadow_pathless_and_named_source_entries() { + let manifest: toml_edit::DocumentMut = "[package]\nname = \"x\"\n".parse().unwrap(); + let config: toml_edit::DocumentMut = "[patch.crates-io]\n\ + cfg-if-socket-aaaaaaaa = { package = \"cfg-if\", git = \"https://example.com/f\" }\n\ + [patch.my-corp]\n\ + cfg-if = { git = \"https://example.com/f\" }\n\ + [patch.\"https://My-Corp.example/index/\"]\n\ + cfg-if = { path = \"../fork\" }\n" + .parse() + .unwrap(); + let cfg = (".cargo/config.toml", config); + let s = super::Shadow::new(&manifest, Some(&cfg)); + // A path-less registry pin under the same key: shadowed. + assert!(s.why("crates-io", "cfg-if-socket-aaaaaaaa", None).is_some()); + // The same key under another source: not. + assert!(s.why("other", "cfg-if-socket-aaaaaaaa", None).is_none()); + // A named non-crates.io table: shadowed. + assert!(s.why("my-corp", "cfg-if", Some("vendor/x")).is_some()); + // A URL table, compared as an index. + assert!(s + .why("https://my-corp.example/index", "cfg-if", Some("vendor/x")) + .is_some()); + // The half-migrated shape: the same path is the same wiring. + assert!(s + .why("https://my-corp.example/index", "cfg-if", Some("../fork")) + .is_none()); + } + /// REVIEW: a `registry-index` declaration is redirected by a /// `[patch.""]` table for the same index (host case, a /// trailing `/` and the lock's `registry+` spelling aside; `sparse+` is From 3d413af3f9c78367f7a957d5e5d444b303189956 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 10:35:25 +0000 Subject: [PATCH 15/22] Compare [patch] URL tables the way cargo canonicalizes them Shadow compared a config's [patch.""] table with the manifest's through index_key, which keeps a trailing .git and a GitHub path's case. Cargo canonicalizes both, so a config item under a canonical-equivalent git URL replaced the manifest's Socket copy while discovery still counted it. Non-sparse URL tables now compare through git_key; sparse+ ones stay index keys (another source). Addresses Bugbot 4501313607. Co-Authored-By: Claude --- .../src/vex/discover/cargo.rs | 34 +++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/crates/socket-patch-core/src/vex/discover/cargo.rs b/crates/socket-patch-core/src/vex/discover/cargo.rs index f94a11f17..f3db4956e 100644 --- a/crates/socket-patch-core/src/vex/discover/cargo.rs +++ b/crates/socket-patch-core/src/vex/discover/cargo.rs @@ -1415,9 +1415,20 @@ fn same_patch_source(a: &str, b: &str) -> bool { if is_crates_io_source(a) || is_crates_io_source(b) { return is_crates_io_source(a) && is_crates_io_source(b); } - // A URL table compares as an index (host case, a trailing `/`); a - // registry name verbatim. - let norm = |s: &str| index_key(s).unwrap_or_else(|| s.trim().to_string()); + // A URL table compares as cargo's `CanonicalUrl` spells it (host case, + // a trailing `/` or `.git`, a GitHub path's case; `sparse+` kept as + // another source); a registry name verbatim. + let norm = |s: &str| { + let t = s.trim(); + let key = if t.starts_with("sparse+") { + index_key(t) + } else if t.contains("://") { + git_key(t.strip_prefix("registry+").unwrap_or(t)) + } else { + None + }; + key.unwrap_or_else(|| t.to_string()) + }; norm(a) == norm(b) } @@ -4348,6 +4359,23 @@ mod tests { assert!(s .why("https://my-corp.example/index", "cfg-if", Some("vendor/x")) .is_some()); + // A canonical-equivalent git table (Bugbot on a7611ef). + let config: toml_edit::DocumentMut = + "[patch.\"https://GitHub.com/Corp/Fork.git/\"]\ncfg-if = { git = \"https://example.com/f\" }\n" + .parse() + .unwrap(); + let cfg2 = (".cargo/config.toml", config); + let s2 = super::Shadow::new(&manifest, Some(&cfg2)); + assert!(s2 + .why("https://github.com/corp/fork", "cfg-if", Some("vendor/x")) + .is_some()); + assert!(s2 + .why( + "sparse+https://github.com/corp/fork", + "cfg-if", + Some("vendor/x") + ) + .is_none()); // The half-migrated shape: the same path is the same wiring. assert!(s .why("https://my-corp.example/index", "cfg-if", Some("../fork")) From b126508e2865c1263caac85ab67f7a9c955b3d77 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 04:23:49 +0000 Subject: [PATCH 16/22] Treat registry = "crates-io" as crates.io for URL-form [patch] tables Cargo reads an explicit `registry = "crates-io"` the same as no registry, so a Socket pin under `[patch."https://github.com/rust-lang/ crates.io-index"]` redirects such a declaration. `patched_by` only compared the key to the table name, so it missed that case and marked the lock ref CargoUnpinnedDeclaration. Route the explicit spelling through `is_crates_io_source` like a bare declaration (Bugbot on #2019). Co-Authored-By: Claude --- .../src/vex/discover/cargo.rs | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/crates/socket-patch-core/src/vex/discover/cargo.rs b/crates/socket-patch-core/src/vex/discover/cargo.rs index 6c62b9878..41dcc4299 100644 --- a/crates/socket-patch-core/src/vex/discover/cargo.rs +++ b/crates/socket-patch-core/src/vex/discover/cargo.rs @@ -516,12 +516,13 @@ impl Decl { /// Whether the `[patch.
]` table redirects this declaration: /// a registry dependency of that registry (`crates-io`, or a crates.io - /// URL alias, for one with no `registry`). + /// URL alias, for one with no `registry` or `registry = "crates-io"`, + /// which cargo reads as crates.io too). fn patched_by(&self, table: &str) -> bool { self.source == DeclSource::Registry - && match self.registry.as_deref() { - None => is_crates_io_source(table), - Some(reg) => reg.trim() == table, + && match self.registry.as_deref().map(str::trim) { + None | Some("crates-io") => is_crates_io_source(table), + Some(reg) => reg == table, } } @@ -2713,6 +2714,28 @@ mod tests { // The crates.io declaration is what the entry redirects: not unpinned. assert!(out.unattested.is_empty(), "{:#?}", out.unattested); + // Review on #2019 (Bugbot): an explicit `registry = "crates-io"` is + // crates.io, so a `[patch.""]` pin redirects it too. + let p = Project::new(); + p.write( + "Cargo.toml", + format!( + "{}\n[patch.\"https://github.com/rust-lang/crates.io-index\"]\n{}\n", + manifest("serde = { version = \"1\", registry = \"crates-io\" }"), + pinned("serde", "1", UUID_A) + ), + ); + p.write( + "Cargo.lock", + lock(&[("serde", "1.0.1", Some(&index(UUID_A)), Some(CKSUM))]), + ); + let out = run(&p).await; + assert_refs( + &out, + &[("pkg:cargo/serde@1.0.1", UUID_A, WiringMode::Hosted)], + ); + assert!(out.unattested.is_empty(), "{:#?}", out.unattested); + // A declaration from ANOTHER registry is not redirected by // `[patch.crates-io]`: cargo builds my-corp's serde for it. let p = Project::new(); From 3695ddc6f80da135e84d891dde0748b12983b1a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 06:27:31 +0000 Subject: [PATCH 17/22] Withhold a vendored Cargo copy a stale pin to another patch bypasses patch_bypassed only withheld the vendored ref for declarations route() finds Unpinned. A member declaring the crate from another socket-patch registry routes to Pin(other) and was skipped. When the lock resolves the crate from that patch, its hosted ref sits beside the vendored one and the CLI's wiring_conflict gate refuses both. When the lock is stale and holds no entry from that patch, only a patched_ref_unattributable diagnostic was left, and patch A was attested even though the member re-resolves to patch B. Mark the vendored copy CargoPatchBypassed in that case instead. Co-Authored-By: Claude --- .../src/vex/discover/cargo.rs | 94 ++++++++++++++++++- 1 file changed, 93 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-core/src/vex/discover/cargo.rs b/crates/socket-patch-core/src/vex/discover/cargo.rs index 3e927cd68..6c79a9934 100644 --- a/crates/socket-patch-core/src/vex/discover/cargo.rs +++ b/crates/socket-patch-core/src/vex/discover/cargo.rs @@ -1545,6 +1545,29 @@ fn patch_bypassed( .filter(|d| d.source != DeclSource::Path) .filter(|d| matches!(route(d, &pins, version, &others), Route::Unpinned(_))) .collect(); + // A declaration pinned to ANOTHER Socket patch builds that patch's + // copy, not this one. When the lock resolves the crate from that patch, + // its hosted ref stands beside this one and the CLI's conflict gate + // refuses both; when it does not (a stale lock), nothing else says so. + let locked_from: Vec = bypass + .lock + .packages() + .iter() + .filter(|p| p.name == name) + .filter_map(|p| p.source.as_deref().and_then(|s| source_uuid(ctx, s))) + .collect(); + let other_patch: Vec<(&Decl, String)> = bypass + .decls + .and_then(|d| d.get(name)) + .into_iter() + .flatten() + .filter_map(|d| match route(d, &pins, version, &others) { + Route::Pin(uuid) if uuid != vref.uuid && !locked_from.contains(&uuid) => { + Some((d, uuid)) + } + _ => None, + }) + .collect(); let locked = same_version_foreign_entry(ctx, bypass.lock, name, version); let redirects = format!( "which no [patch] entry pointing at {} redirects", @@ -1597,7 +1620,28 @@ fn patch_bypassed( ), ) } - (None, None) => return, + (None, None) => { + let Some((first, _)) = other_patch.first() else { + return; + }; + let declared = other_patch + .iter() + .map(|(d, uuid)| format!("{} (Socket patch {uuid})", d.describe(name))) + .collect::>() + .join(", and "); + ( + first.file.as_str(), + format!( + "{declared}, which {CARGO_LOCK} does not resolve {name} from (a stale \ + lock): cargo re-resolves that declaration from that patch's registry, so \ + the crate declaring it builds {name} with another patch than the copy at \ + {}, and the requirement allows it to be {version}. Keep one patch for \ + {name}: pin that declaration to this patch (or drop it), or roll this \ + patch back", + vref.artifact_rel + ), + ) + } }; out.unattested( purl, @@ -4534,4 +4578,52 @@ mod tests { assert_vendored_attested(&out, attested, &member); } } + /// REGRESSION (Bugbot on #2024): a member pinned to ANOTHER Socket + /// patch builds that patch's copy. When the lock resolves cfg-if from + /// it, its hosted ref stands beside the vendored one and the CLI's + /// conflict gate refuses both; when the lock does not (stale), the + /// vendored copy is marked bypassed rather than attested alone. + #[tokio::test] + async fn a_member_pinned_to_another_patch_bypasses_the_vendored_copy() { + let p = vendored_workspace(&pinned("cfg-if", "1", UUID_B), "", &[]); + let out = run(&p).await; + assert_refs( + &out, + &[("pkg:cargo/cfg-if@1.0.4", UUID_A, WiringMode::Vendored)], + ); + let u = bypassed_mark(&out, "crates/a/Cargo.toml"); + assert!( + u.detail.contains("crates/a/Cargo.toml declares cfg-if") + && u.detail.contains(UUID_B) + && u.detail.contains("stale"), + "{}", + u.detail + ); + + // Locked from that patch: the conflict pair, no bypass mark on A. + let b = index(UUID_B); + let p = vendored_workspace(&pinned("cfg-if", "1", UUID_B), "", &[("1.0.4", b.as_str())]); + let out = run(&p).await; + assert_refs( + &out, + &[ + ("pkg:cargo/cfg-if@1.0.4", UUID_B, WiringMode::Hosted), + ("pkg:cargo/cfg-if@1.0.4", UUID_A, WiringMode::Vendored), + ], + ); + assert!( + out.unattested.iter().all(|u| u.uuid != UUID_A), + "{:#?}", + out.unattested + ); + + // A requirement that excludes the vendored version says nothing. + let p = vendored_workspace(&pinned("cfg-if", "0.1", UUID_B), "", &[]); + let out = run(&p).await; + assert!( + out.unattested.iter().all(|u| u.uuid != UUID_A), + "{:#?}", + out.unattested + ); + } } From bc39a271be43ab138c595e9983e12639a5c5edc3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 06:41:50 +0000 Subject: [PATCH 18/22] Pair the stale-pin bypass with an emitted ref, not any lock line The exemption for a member pinned to another Socket patch trusted any lock line from that patch. hosted_from_lock drops a line whose project registry definition disagrees, and a line at another version is no ref for this package, so wiring_conflicts never fired and the vendored copy stayed attested. Exempt only when the hosted pass emitted a ref for that patch at this very purl that is not itself withheld. Co-Authored-By: Claude --- .../src/vex/discover/cargo.rs | 55 +++++++++++++------ 1 file changed, 37 insertions(+), 18 deletions(-) diff --git a/crates/socket-patch-core/src/vex/discover/cargo.rs b/crates/socket-patch-core/src/vex/discover/cargo.rs index 6c79a9934..63b9267f5 100644 --- a/crates/socket-patch-core/src/vex/discover/cargo.rs +++ b/crates/socket-patch-core/src/vex/discover/cargo.rs @@ -1546,25 +1546,28 @@ fn patch_bypassed( .filter(|d| matches!(route(d, &pins, version, &others), Route::Unpinned(_))) .collect(); // A declaration pinned to ANOTHER Socket patch builds that patch's - // copy, not this one. When the lock resolves the crate from that patch, - // its hosted ref stands beside this one and the CLI's conflict gate - // refuses both; when it does not (a stale lock), nothing else says so. - let locked_from: Vec = bypass - .lock - .packages() - .iter() - .filter(|p| p.name == name) - .filter_map(|p| p.source.as_deref().and_then(|s| source_uuid(ctx, s))) - .collect(); + // copy, not this one. Only a ref for that patch at this very package, + // emitted by the hosted pass (from the lock, or a conflict pair beside + // a lock pin) and not itself withheld, puts the pair before the CLI's + // conflict gate; otherwise (a stale lock, a lock line whose config + // index disagrees, another locked version) nothing else says so. + let gated = |uuid: &str| { + out.refs.iter().any(|r| { + r.uuid == uuid + && r.purl == purl + && !out + .unattested + .iter() + .any(|u| u.uuid == uuid && u.purl == purl && u.file == r.source_file) + }) + }; let other_patch: Vec<(&Decl, String)> = bypass .decls .and_then(|d| d.get(name)) .into_iter() .flatten() .filter_map(|d| match route(d, &pins, version, &others) { - Route::Pin(uuid) if uuid != vref.uuid && !locked_from.contains(&uuid) => { - Some((d, uuid)) - } + Route::Pin(uuid) if uuid != vref.uuid && !gated(&uuid) => Some((d, uuid)), _ => None, }) .collect(); @@ -1632,10 +1635,11 @@ fn patch_bypassed( ( first.file.as_str(), format!( - "{declared}, which {CARGO_LOCK} does not resolve {name} from (a stale \ - lock): cargo re-resolves that declaration from that patch's registry, so \ - the crate declaring it builds {name} with another patch than the copy at \ - {}, and the requirement allows it to be {version}. Keep one patch for \ + "{declared}, which no wiring of that patch for {name} {version} pairs \ + with this copy (a stale {CARGO_LOCK}, or a registry definition that \ + disagrees): cargo resolves that declaration from that patch's registry, \ + so the crate declaring it builds {name} with another patch than the copy \ + at {}, and the requirement allows it to be {version}. Keep one patch for \ {name}: pin that declaration to this patch (or drop it), or roll this \ patch back", vref.artifact_rel @@ -4595,7 +4599,7 @@ mod tests { assert!( u.detail.contains("crates/a/Cargo.toml declares cfg-if") && u.detail.contains(UUID_B) - && u.detail.contains("stale"), + && u.detail.contains("stale Cargo.lock"), "{}", u.detail ); @@ -4617,6 +4621,21 @@ mod tests { out.unattested ); + // Locked from that patch, but the project's registry definition + // indexes another patch: the hosted pass emits no ref for it, so + // nothing pairs with the vendored copy and it is withheld. + let p = vendored_workspace(&pinned("cfg-if", "1", UUID_B), "", &[("1.0.4", b.as_str())]); + p.write( + ".cargo/config.toml", + format!( + "[registries.socket-patch-{UUID_B}]\nindex = \"{}\"\n", + index(UUID_A) + ), + ); + let out = run(&p).await; + assert!(out.refs.iter().all(|r| r.uuid != UUID_B), "{:#?}", out.refs); + bypassed_mark(&out, "crates/a/Cargo.toml"); + // A requirement that excludes the vendored version says nothing. let p = vendored_workspace(&pinned("cfg-if", "0.1", UUID_B), "", &[]); let out = run(&p).await; From cb98b9b3eb7db3bb3202dc854941d5c8f8e874c1 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Mon, 9 Nov 2026 15:32:32 -0500 Subject: [PATCH 19/22] Fail closed on hosted wiring from an unconfigured patch server Owner decision on #2012: a pre-v5 redirect-ledger record whose wiring resolves the package from a hosted-shaped url on an origin this run was not given (a staging server used without --patch-server-url, or a look-alike host) is never attested. vex reports it as redirect_unwired, whatever is installed and even under --no-verify. Step 1 of redirect_record_verdict already returned Undecided for a lock inventory entry carrying such a url. The formats whose inventory has no url (yarn berry __archiveUrl bindings, Cargo.lock registry sources, pom repositories, Gemfile source blocks, go.mod replace module paths) fell through to the recorded-wiring step 3 (or the gem step 0) and attested. Those steps now return Undecided when any recorded file spells a hosted-shaped url for the uuid on an unconfigured origin. A Socket-host spelling elsewhere in the files does not clear it. foreign_hosted_uuid drops userinfo before reading the origin, so a https://patch.socket.dev@evil.example/... look-alike is Undecided in every step rather than Dead in step 1 and Live in step 3. vex adds a vex_claim_unwired note for such a record naming the cause and --patch-server-url. CLI_CONTRACT.md "Patch hosts (manifest-less VEX)" and the vex_sources module docs state the new rule. The yarn, cargo and maven e2e cells that asserted the old staging fallback now expect redirect_unwired. Co-Authored-By: Claude Opus 5.5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../src/commands/vex_sources.rs | 35 ++- .../tests/e2e_vex_lockfile/cargo.rs | 70 ++--- .../tests/e2e_vex_lockfile/maven.rs | 66 ++-- .../tests/e2e_vex_lockfile/yarn.rs | 79 ++--- .../socket-patch-core/src/vex/discover/mod.rs | 283 +++++++++++++++++- 6 files changed, 410 insertions(+), 125 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 75b31e8a3..76a159a1c 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -510,7 +510,7 @@ Every VEX statement's impact string records which patch-application mode persist `vendored` and `redirected` are disjoint in practice (the modes conflict); if a PURL somehow appears in both sets, `vendored` wins. -**Patch hosts (manifest-less VEX).** A hosted lockfile reference counts only when it points at Socket's patch server or the operator's `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin. A hosted URL on any OTHER host — a staging patch server used without `--patch-server-url`, or a look-alike host — is not a hosted pin at all (v5.0: there is no redirect ledger to vouch for it), so `vex` does not attest it, and `list`, `rollback`, `remove`, `vendor` and `repair` do not see it either. Pass `--patch-server-url` for a non-production patch server. (A pre-v5 redirect ledger still on disk can keep such a record in play under the pre-v5 rule: judged by the ledger's own recorded wiring, attesting only an installed tree that hashes to the record unless `--no-verify` / `--vex-no-verify` trusts it; do not combine `--no-verify` with lockfiles you do not trust.) +**Patch hosts (manifest-less VEX).** A hosted lockfile reference counts only when it points at Socket's patch server or the operator's `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin. A hosted URL on any OTHER host — a staging patch server used without `--patch-server-url`, or a look-alike host — is not a hosted pin at all (v5.0: there is no redirect ledger to vouch for it), so `vex` does not attest it, and `list`, `rollback`, `remove`, `vendor` and `repair` do not see it either. Pass `--patch-server-url` for a non-production patch server. A pre-v5 redirect ledger still on disk does not change this: a record whose lockfile or recorded wiring resolves the package from a hosted-shaped URL on an origin this run was not given (or one carrying userinfo) is never attested from the ledger, whatever is installed and even under `--no-verify` / `--vex-no-verify`: `vex` reports it as `redirect_unwired`, with a `vex_claim_unwired` note naming the cause. (A patch `apply`'s manifest also records is still verified as the agent-mode patch it is, on that record.) `scan` does not treat such wiring as gone: its takeover and hosted-wiring advisories still see it. ### Manifest-less VEX (lockfile discovery) diff --git a/crates/socket-patch-cli/src/commands/vex_sources.rs b/crates/socket-patch-cli/src/commands/vex_sources.rs index 822e2e679..aa7d4c97a 100644 --- a/crates/socket-patch-cli/src/commands/vex_sources.rs +++ b/crates/socket-patch-cli/src/commands/vex_sources.rs @@ -44,7 +44,10 @@ //! whatever raw text survives. Only for a uuid no read file mentions //! (formats no extractor reads, patch servers outside the host allowlist) //! does the ledger's own recorded wiring decide — only files that PIN the -//! resolution, never a leftover registry definition; a vendor entry +//! resolution, never a leftover registry definition — and a hosted +//! record whose lock entry or recorded wiring routes the uuid through a +//! hosted-shaped url on an origin this run was not given is never +//! attested (core `RecordLiveness::Undecided`, fail closed); a vendor entry //! recording no wiring (repair's reconstruction) probes its ecosystem's //! root locks; a lock inventory entry resolving the purl elsewhere is //! dead. A reverted lockfile plus a leftover ledger or artifact must not @@ -82,7 +85,7 @@ use socket_patch_core::utils::purl::strip_purl_qualifiers; use socket_patch_core::utils::purl_key::{canonical_base_purl, PurlKey}; use socket_patch_core::vendor::state::{VendorArtifact, VendorEntry, VendorState}; use socket_patch_core::vex::discover::{ - vendor_ref, Discovery, LedgerLiveness, PatchedRef, WiringMode, + vendor_ref, Discovery, LedgerLiveness, PatchedRef, RecordLiveness, WiringMode, }; use socket_patch_core::vex::{FailedPatch, Unattested, UnattestedKind}; @@ -657,9 +660,14 @@ pub(crate) async fn plan( continue; } } else if cand.redirected { - let live = assumed.contains(strip_purl_qualifiers(&cand.key)) - || liveness.redirect_record(&cand.key, &cand.uuid).await; - if live { + let verdict = if assumed.contains(strip_purl_qualifiers(&cand.key)) { + RecordLiveness::Live + } else { + liveness + .redirect_record_verdict(&cand.key, &cand.uuid) + .await + }; + if verdict.attests() { Basis::Hosted { lockfile_basis: false, } @@ -668,6 +676,9 @@ pub(crate) async fn plan( // purl: verify it as the agent-mode patch it now is. Basis::Installed } else { + if verdict == RecordLiveness::Undecided { + notes.push(foreign_origin_note(&cand.key, &cand.uuid)); + } notes.extend(dead_claim_notes( &discovery, &cand.key, @@ -1093,6 +1104,20 @@ fn dead_claim_notes( notes } +/// Why a hosted-ledger record whose wiring resolves from a patch server +/// this run was not given ([`RecordLiveness::Undecided`]) is not attested: +/// fail closed, and say how a staging server is admitted. +fn foreign_origin_note(key: &str, uuid: &str) -> PlanNote { + PlanNote { + code: NOTE_CLAIM_UNWIRED, + detail: format!( + "{key}: the hosted ledger records patch {uuid}, but its wiring resolves it from a \ + patch server this run was not given; pass --patch-server-url for a non-production \ + patch server; not attested" + ), + } +} + /// A local record for `uuid` (manifest, then redirect ledger, then vendor /// entries) with the key it is filed under. fn local_record_by_uuid( diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/cargo.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/cargo.rs index dbcef78ef..223b64b3e 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/cargo.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/cargo.rs @@ -913,48 +913,36 @@ fn cargo_hosted_f_spoofed_references_never_attest() { let run = fx.vex(&["--proxy-url", &api.uri()]); assert_nothing_discovered(&run, "foreign host"); assert_eq!(api.hits(), 0, "a foreign-host uuid is never even fetched"); - // DELIBERATE (the staging fallback): a uuid on a host outside the - // allowlist is not a lockfile reference, but it is not "recognized" - // either, so a redirect-ledger record for it falls back to the ledger's - // own recorded wiring files — which still name it — so that a staging - // `scan --mode hosted` run without `--patch-server-url` keeps - // attesting. Hash verification still judges the copy the build - // consumes (the foreign registry's): nothing is installed here, so the - // default run omits it; only `--no-verify`, which trusts the ledger's - // record by definition, attests. + // FAIL CLOSED: a redirect-ledger record whose recorded wiring routes + // the uuid through a hosted-shaped url on an origin this run was not + // given (here the `Cargo.lock` `sparse+` source and the + // `.cargo/config.toml` index) is never attested — whatever is + // installed, with or without `--no-verify` — and is reported as + // `redirect_unwired` (core `RecordLiveness::Undecided`; CLI_CONTRACT.md, + // "Patch hosts (manifest-less VEX)"). A staging patch server needs + // `--patch-server-url`, below. write_redirect_ledger(&fx, CARGO_PURL, cargo_record(U), CARGO_HOSTED_EDITS); - let run = fx.vex(&["--offline"]); - assert_omitted( - &run, - CARGO_PURL, - "package_not_found", - "foreign host + ledger", - ); - fx.install_crate(SOCKET_CARGO_SRC, PATCHED_RS); - let run = fx.vex(&["--offline"]); - assert_omitted( - &run, - CARGO_PURL, - "package_not_found", - "foreign host + ledger: Socket's copy is not what this lock builds", - ); - fx.install_crate("evil.example-0123456789abcdef", PRISTINE_RS); - let run = fx.vex(&["--offline"]); - assert_eq!( - run.code, - Some(1), - "foreign registry's pristine copy: {}", - run.env - ); - let run = fx.vex(&["--offline", "--no-verify"]); - assert_attested( - &fx, - &run, - CARGO_PURL, - U, - "redirected", - "staging fallback, --no-verify", - ); + for (state, install) in [ + ("not installed", None), + ("Socket's patched copy", Some(SOCKET_CARGO_SRC)), + ( + "the foreign registry's patched copy", + Some("evil.example-0123456789abcdef"), + ), + ] { + if let Some(src) = install { + fx.install_crate(src, PATCHED_RS); + } + for extra in [&["--offline"][..], &["--offline", "--no-verify"][..]] { + let run = fx.vex(extra); + assert_omitted( + &run, + CARGO_PURL, + "redirect_unwired", + &format!("foreign host + ledger, {state} {extra:?}"), + ); + } + } // The same foreign origin configured as the operator's patch server. let fx = Fx::new(); diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/maven.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/maven.rs index 64c46fcf9..9d04b11f0 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/maven.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/maven.rs @@ -37,9 +37,9 @@ //! manifest and ledgers are deleted and the standalone `vex` re-attests. //! //! Documented limitations are asserted explicitly (never skipped): the -//! legacy same-GAV hosted fallback, a staging (non-allowlisted) host kept -//! alive by a ledger under `--no-verify`, and agent-mode (`apply`) patches, -//! which have no lockfile wiring to discover. +//! legacy same-GAV hosted fallback and agent-mode (`apply`) patches, which +//! have no lockfile wiring to discover. A staging (non-allowlisted) host is +//! never attested, even by a ledger under `--no-verify` (fail closed). #[path = "../prebuilt_common/mod.rs"] mod prebuilt_common; @@ -736,7 +736,8 @@ fn maven_hosted_pin_without_trusted_checksums_still_attests() { /// Socket url whose uuid segment differs from the id) is not a patch /// reference — with no manifest and no ledger there is nothing to attest, /// and the API is never asked about the uuid. A ledger record for the -/// same uuid is kept dead by the rejected text (rule 11). +/// same uuid is never attested (`redirect_unwired`): dead by the rejected +/// text (rule 11), or on a foreign origin. #[test] fn maven_hosted_spoofed_repositories_never_attest() { let golden = std::fs::read_to_string(fixture_dir("maven/pom/basic/expected/pom.xml")).unwrap(); @@ -775,37 +776,34 @@ fn maven_hosted_spoofed_repositories_never_attest() { } continue; } - // DOCUMENTED LIMITATION (vex_sources.rs module docs, "only for a - // uuid no read file mentions ... patch servers outside the host - // allowlist ... does the ledger's own recorded wiring decide"): a - // NON-allowlisted host is how a staging patch server run without - // `--patch-server-url` looks, so a ledger whose pom.xml still pins - // `-socket.` under a `socket-patch-` repository keeps - // the claim alive. With verification on, the installed tree is - // still the evidence — nothing installed is `package_not_found` - // (the not-installed lockfile basis is only for DISCOVERED refs) - // and a pristine served jar is `not_applied` — so nothing attests. - // Under `--no-verify` the ledger record attests: `--no-verify` - // trusts the records, and the wiring gate cannot tell a staging - // host from a hostile one — the limitation CLI_CONTRACT.md records - // under "Patch hosts (manifest-less VEX)". - let (code, env) = fx.vex(&["--offline"]); - assert_omitted(code, &env, MVN_PURL, "package_not_found", label); + // FAIL CLOSED: a NON-allowlisted host is how a staging patch server + // run without `--patch-server-url` looks, and how a look-alike + // looks. A ledger whose pom.xml still pins `-socket.` under a + // `socket-patch-` repository on such a host is never attested + // — not with nothing installed, not with the served jar in place, + // not under `--no-verify` — and is reported as `redirect_unwired` + // (core `RecordLiveness::Undecided`; CLI_CONTRACT.md, "Patch hosts + // (manifest-less VEX)"). let sfx_dir = fx.m2().join(format!("org/slf4j/slf4j-api/{MVN_SUFFIXED}")); - put( - &sfx_dir, - &format!("slf4j-api-{MVN_SUFFIXED}.pom"), - b"", - ); - put( - &sfx_dir, - &format!("slf4j-api-{MVN_SUFFIXED}.jar"), - MVN_PRISTINE, - ); - let (code, env) = fx.vex(&["--offline"]); - assert_omitted(code, &env, MVN_PURL, "not_applied", label); - let (code, env) = fx.vex(&["--offline", "--no-verify"]); - assert_eq!(code, Some(0), "{label}: documented staging fallback: {env}"); + for installed in [false, true] { + if installed { + put( + &sfx_dir, + &format!("slf4j-api-{MVN_SUFFIXED}.pom"), + b"", + ); + put( + &sfx_dir, + &format!("slf4j-api-{MVN_SUFFIXED}.jar"), + MVN_PRISTINE, + ); + } + for extra in [&["--offline"][..], &["--offline", "--no-verify"][..]] { + let (code, env) = fx.vex(extra); + let cell = format!("{label} (installed: {installed}) {extra:?}"); + assert_omitted(code, &env, MVN_PURL, "redirect_unwired", &cell); + } + } } } diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/yarn.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/yarn.rs index 520900e4f..d56820094 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/yarn.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/yarn.rs @@ -1045,43 +1045,50 @@ fn uuid_on_a_foreign_host_is_not_a_hosted_reference() { assert_eq!(env["error"]["code"], "manifest_not_found", "{cell}: {env}"); assert_eq!(request_count(&rt, &server), 0, "{cell}: network call"); - // DOCUMENTED FALLBACK (vex_sources `redirect_record_live`): a - // uuid the discovery allowlist does not recognize is judged by - // the ledger's raw-text fallback, which accepts ANY host so a - // staging patch server used without `--patch-server-url` keeps - // working. The fallback never trusts the look-alike lock's pin: - // with verification on it attests only an installed tree that - // hashes to the record. (`--no-verify` trusts the ledger by - // definition — CLI_CONTRACT.md, "Patch hosts (manifest-less - // VEX)".) + // FAIL CLOSED (`Discovery::redirect_record_verdict`, + // `RecordLiveness::Undecided`): a pre-v5 redirect-ledger record + // whose lock entry resolves from a hosted-shaped url on an + // origin this run was not given (a staging patch server used + // without `--patch-server-url`, or a look-alike host) is never + // attested — not even by an installed tree that hashes to the + // record, not even under `--no-verify` — and is reported as + // `redirect_unwired` (CLI_CONTRACT.md, "Patch hosts + // (manifest-less VEX)"). Pass `--patch-server-url` for a + // non-production patch server. write_redirect_ledger(cwd, flavor, record(UUID)); - let (code, env) = vex_json(cwd, &["--offline"]); - assert_omitted( - cwd, - code, - &env, - "package_not_found", - &format!("{cell} + ledger, not installed"), - ); - install(cwd, b"tampered bytes\n"); - let (code, env) = vex_json(cwd, &["--offline"]); - assert_omitted( - cwd, - code, - &env, - "hash_mismatch", - &format!("{cell} + ledger, tampered"), - ); - install(cwd, PATCHED); - let (code, env) = vex_json(cwd, &["--offline"]); - assert_attested( - cwd, - code, - &env, - UUID, - "redirected", - &format!("{cell} + ledger, verified"), - ); + for (state, bytes) in [ + ("not installed", None), + ("tampered", Some(&b"tampered bytes\n"[..])), + ("hashes to the record", Some(PATCHED)), + ] { + if let Some(bytes) = bytes { + install(cwd, bytes); + } + for extra in [&["--offline"][..], &["--offline", "--no-verify"][..]] { + let (code, env) = vex_json(cwd, extra); + assert_omitted( + cwd, + code, + &env, + "redirect_unwired", + &format!("{cell} + ledger, {state} {extra:?}"), + ); + if !origin.contains('@') { + // The note names the cause and the remedy. + assert!( + env["warnings"] + .as_array() + .is_some_and(|w| w.iter().any(|w| { + w["code"] == "vex_claim_unwired" + && w["detail"] + .as_str() + .is_some_and(|d| d.contains("--patch-server-url")) + })), + "{cell} + ledger, {state}: {env}" + ); + } + } + } } } } diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index a98677d24..e4f887e2d 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -2488,6 +2488,12 @@ impl Discovery { /// route nothing once the dependency pin is reverted, and reads a /// Gemfile / `gems.rb` with discovery's source-block grammar). /// + /// A record steps 0 or 3 find pinned is [`RecordLiveness::Undecided`] + /// instead of live when its recorded files route the uuid through a + /// hosted-shaped url on an origin this run was not given — the step-1 + /// rule, for the formats whose inventory carries no url + /// ([`foreign_hosted_url_in_files`]). + /// /// `inventory` is the project's EVERY-instance lock inventory /// ([`inventory_project_every_lock`] — step 2 is negative evidence, so a /// collapsed view that dropped the hosted instance must not feed it), @@ -2544,7 +2550,7 @@ impl Discovery { continue; }; if gem::gemfile_source_block_pins(&text, uuid, Some(&gem_name)) { - return RecordLiveness::Live; + return self.unless_foreign_hosted(root, recorded_files, uuid).await; } } } @@ -2574,7 +2580,34 @@ impl Discovery { if entries.iter().any(|e| resolves_elsewhere(e)) { return RecordLiveness::Dead; } - RecordLiveness::from_bool(hosted_wiring_in_files(root, &recorded, uuid).await) + if !hosted_wiring_in_files(root, &recorded, uuid).await { + return RecordLiveness::Dead; + } + self.unless_foreign_hosted(root, recorded_files, uuid).await + } + + /// The verdict for a record whose ledger-recorded wiring still pins + /// `uuid` (steps 0 and 3 of [`Self::redirect_record_live`]): `Live`, + /// unless those files route it through a url with the hosted shape for + /// `uuid` on an origin this run was not given — the same wiring step 1 + /// judges `Undecided` when a lock inventory entry carries the url, here + /// for the formats whose inventory does not (a yarn berry + /// `__archiveUrl=` binding, a `Cargo.lock` registry `source`, a pom + /// ``, a Gemfile `source` block). Fail closed: a pin to a + /// staging server used without `--patch-server-url`, or to a look-alike + /// host, is never attested ([`foreign_hosted_url_in_files`]). + async fn unless_foreign_hosted( + &self, + root: &Path, + recorded_files: &[&str], + uuid: &str, + ) -> RecordLiveness { + if foreign_hosted_url_in_files(root, recorded_files, uuid, &self.patch_server_origins).await + { + RecordLiveness::Undecided + } else { + RecordLiveness::Live + } } } @@ -2615,26 +2648,107 @@ impl RecordLiveness { } /// The patch uuid `url` names when read as a hosted url of its OWN origin -/// ([`crate::origin::hosted_patch_uuid`]'s grammar — no userinfo, -/// the last canonical-uuid path segment — with the url's origin taken as +/// ([`crate::origin::hosted_patch_uuid`]'s grammar — the last +/// canonical-uuid path segment — with the url's origin taken as /// configured): what a lock entry on an origin this run was not given /// claims to resolve from. Only a url in a Socket patch server's path /// shape (a `patch`, `patch-registry` or `gopatch` segment) counts: any /// other url merely ending in the uuid (an artifact store's blob path) -/// resolves from elsewhere. +/// resolves from elsewhere. Userinfo is dropped first: Socket never writes +/// one, so `https://patch.socket.dev@evil.example/patch/…` is a look-alike +/// on the host after the `@`, never an accepted origin. fn foreign_hosted_uuid(url: &str) -> Option { // Normalized the way `hosted_patch_uuid` reads it, so an escaped, // percent-encoded or `sparse+` url still yields its origin. let normalized = crate::origin::normalized_hosted_url(url)?; - let parsed = reqwest::Url::parse(&normalized).ok()?; + let mut parsed = reqwest::Url::parse(&normalized).ok()?; if !parsed .path_segments()? .any(|s| matches!(s, "patch" | "patch-registry" | "gopatch")) { return None; } + let _ = parsed.set_username(""); + let _ = parsed.set_password(None); let origin = parsed.origin().ascii_serialization(); - crate::origin::hosted_patch_uuid(url, &[origin]) + crate::origin::hosted_patch_uuid(parsed.as_str(), &[origin]) +} + +/// Whether any of `files` — the root-relative files a REDIRECT ledger +/// recorded editing — spells a url with the hosted shape for `uuid` +/// ([`foreign_hosted_uuid`]) on an origin outside Socket's host and +/// `origins`. Fail closed: one such url is enough, whatever else the files +/// spell (a Socket-host url beside it — a leftover definition, a comment — +/// does not clear it). Each occurrence of the uuid is widened to the url +/// token around it (bounded by whitespace, quotes, markup and `=` / `&`, +/// so a berry `__archiveUrl=` binding value or a `Cargo.lock` `sparse+` +/// source reads whole), then read the way +/// [`crate::origin::hosted_patch_uuid`] reads a url; a scheme-less token +/// (a go.mod `replace` module path, `/gopatch/`) is read as +/// `https://`. Every recorded file counts, registry definitions included: +/// a definition routes nothing on its own, but it names the host the pin +/// routes to. `bun.lockb` is binary and is skipped (its inventory carries +/// the url to step 1). Same path guards as [`hosted_wiring_in_files`]. +async fn foreign_hosted_url_in_files( + root: &Path, + files: &[&str], + uuid: &str, + origins: &[String], +) -> bool { + if !is_canonical_uuid(uuid) { + return false; + } + for file in unique_safe_rel_files(files) { + if file == "bun.lockb" || file.ends_with("/bun.lockb") { + continue; + } + let Ok(text) = crate::utils::fs::read_regular_to_string(&root.join(&file)).await else { + continue; + }; + for token in url_tokens_around(&text, uuid) { + let schemed; + let url = if token.contains("://") || token.contains("%3A%2F%2F") { + token + } else { + schemed = format!("https://{token}"); + &schemed + }; + if foreign_hosted_uuid(url).as_deref() == Some(uuid) + && crate::origin::hosted_patch_uuid(url, origins).as_deref() != Some(uuid) + { + return true; + } + } + } + false +} + +/// The url-shaped token around each occurrence of `needle` in `text`: +/// widened in both directions up to whitespace, a quote, markup or a +/// key / binding separator (`=`, `&`, `,`, brackets). +fn url_tokens_around<'a>(text: &'a str, needle: &str) -> Vec<&'a str> { + let stop = |c: char| { + c.is_whitespace() + || matches!( + c, + '"' | '\'' | '`' | '<' | '>' | '(' | ')' | '[' | ']' | '{' | '}' | '=' | '&' | ',' + ) + }; + let mut out = Vec::new(); + let mut from = 0; + while let Some(pos) = text[from..].find(needle) { + let idx = from + pos; + let end = idx + needle.len(); + let start = text[..idx] + .char_indices() + .rev() + .find(|&(_, c)| stop(c)) + .map_or(0, |(i, c)| i + c.len_utf8()); + let stop_at = text[end..].find(stop).map_or(text.len(), |i| end + i); + out.push(&text[start..stop_at]); + from = end; + } + out } /// Whether root-relative `file` is one of `ecosystem`'s lockfiles — what an @@ -3343,6 +3457,8 @@ mod tests { format!("https%3A%2F%2Fmirror.example%2Fpatch%2F{U}%2Fx.tgz"), format!("sparse+https://mirror.example/patch/{U}/"), format!("registry+https://mirror.example/patch/{U}/"), + format!("https://patch.socket.dev@mirror.example/patch/{U}/x.tgz"), + format!("https://user:pw@patch.socket.dev/patch/{U}/x.tgz"), ] { assert_eq!(foreign_hosted_uuid(&url).as_deref(), Some(U), "{url}"); } @@ -3731,7 +3847,9 @@ mod tests { /// would). #[tokio::test] async fn gemfile_liveness_reads_live_source_blocks_only() { - let index = format!("https://patches.example.com/patch-registry/gem/{TOKEN}/{UUID_A}/"); + // Socket's host: a source block on an origin this run was not given + // is never live (`recorded_wiring_on_a_foreign_origin_is_undecided`). + let index = format!("https://patch.socket.dev/patch-registry/gem/{TOKEN}/{UUID_A}/"); let p = Project::new(); p.write( "Gemfile.lock", @@ -3852,6 +3970,9 @@ mod tests { let purl = "pkg:npm/x@1.0.0"; let elsewhere = format!("https://evil.example/patch/npm/x/1.0.0/{TOKEN}/{UUID_A}/x-1.0.0.tgz"); + let userinfo = format!( + "https://patch.socket.dev@evil.example/patch/npm/x/1.0.0/{TOKEN}/{UUID_A}/x-1.0.0.tgz" + ); let in_name = format!("https://evil.example/files/x-{UUID_A}.tgz"); let in_query = format!("https://evil.example/files/x-1.0.0.tgz?id={UUID_A}"); let other_patch = @@ -3866,6 +3987,7 @@ mod tests { for (resolved, discovery, verdict) in [ (&elsewhere, &with_origin, Undecided), (&configured, &without, Undecided), + (&userinfo, &without, Undecided), (&in_name, &with_origin, Dead), (&in_query, &with_origin, Dead), (&other_patch, &with_origin, Dead), @@ -3922,6 +4044,151 @@ mod tests { } } + /// Steps 0 and 3 (the ledger's own recorded wiring) fail closed the + /// way step 1 does: a pin whose recorded files route the uuid through + /// a hosted-shaped url on an origin this run was not given — here a + /// yarn berry `__archiveUrl=` binding and a `Cargo.lock` `sparse+` + /// source, neither of which the lock inventory carries as a url — is + /// `Undecided`, never attested; the same url on Socket's host or a + /// configured origin is live, and a userinfo look-alike reads as the + /// host after its `@`. + #[tokio::test] + async fn recorded_wiring_on_a_foreign_origin_is_undecided() { + use RecordLiveness::{Live, Undecided}; + let origin = "http://127.0.0.1:4545"; + let berry = |base: &str| { + let url = format!("{base}/patch/npm/x/1.0.0/{TOKEN}/{UUID_A}/x-1.0.0.tgz"); + let encoded = url.replace(':', "%3A").replace('/', "%2F"); + format!( + "__metadata:\n version: 8\n\n\"x@npm:1.0.0\":\n version: 1.0.0\n \ + resolution: \"x@npm:1.0.0::__archiveUrl={encoded}\"\n languageName: node\n \ + linkType: hard\n" + ) + }; + let cargo = |base: &str| { + format!( + "version = 3\n\n[[package]]\nname = \"x\"\nversion = \"1.0.0\"\n\ + source = \"sparse+{base}/patch-registry/cargo/{TOKEN}/{UUID_A}/index/\"\n" + ) + }; + let with_origin = Discovery { + patch_server_origins: vec![origin.to_string()], + ..Discovery::default() + }; + let without = Discovery::default(); + for (file, purl, text, discovery, verdict) in [ + ( + "yarn.lock", + "pkg:npm/x@1.0.0", + berry("https://evil.example"), + &without, + Undecided, + ), + ( + "yarn.lock", + "pkg:npm/x@1.0.0", + berry("https://patch.socket.dev@evil.example"), + &without, + Undecided, + ), + ( + "yarn.lock", + "pkg:npm/x@1.0.0", + berry(origin), + &without, + Undecided, + ), + ( + "yarn.lock", + "pkg:npm/x@1.0.0", + berry(origin), + &with_origin, + Live, + ), + ( + "yarn.lock", + "pkg:npm/x@1.0.0", + berry("https://patch.socket.dev"), + &without, + Live, + ), + ( + "Cargo.lock", + "pkg:cargo/x@1.0.0", + cargo("https://evil.example"), + &without, + Undecided, + ), + ( + "Cargo.lock", + "pkg:cargo/x@1.0.0", + cargo(origin), + &with_origin, + Live, + ), + // Review on #2012 (round 3): a Socket-host spelling elsewhere in + // the files (a comment, a leftover definition) does not clear a + // foreign pin. + ( + "yarn.lock", + "pkg:npm/x@1.0.0", + format!( + "# https://patch.socket.dev/patch/npm/x/1.0.0/{TOKEN}/{UUID_A}/x-1.0.0.tgz\n{}", + berry("https://evil.example") + ), + &without, + Undecided, + ), + // A go.mod `replace` names a scheme-less module path. + ( + "go.mod", + "pkg:golang/example.com/x@v1.0.0", + format!( + "module example.com/app\n\ngo 1.21\n\nrequire example.com/x v1.0.0\n\n\ + replace example.com/x v1.0.0 => staging.example/gopatch/{UUID_A} v1.0.0\n" + ), + &without, + Undecided, + ), + ( + "go.mod", + "pkg:golang/example.com/x@v1.0.0", + format!( + "module example.com/app\n\ngo 1.21\n\nrequire example.com/x v1.0.0\n\n\ + replace example.com/x v1.0.0 => patch.socket.dev/gopatch/{UUID_A} v1.0.0\n" + ), + &without, + Live, + ), + ] { + let p = Project::new(); + p.write(file, &text); + let why = format!( + "{file}: {text} (origins {:?})", + discovery.patch_server_origins + ); + let mut inventory = None; + assert_eq!( + discovery + .redirect_record_verdict(p.root(), purl, UUID_A, &[file], &mut inventory) + .await, + verdict, + "{why}" + ); + } + } + + /// The url token around each needle occurrence stops at quotes, markup + /// and binding separators, and never splits a multi-byte character. + #[test] + fn url_tokens_around_widen_to_the_url() { + let text = "a=\"sparse+https://h/x/U/i/\" https://h/U\u{a0}https://h/U#f&b"; + assert_eq!( + url_tokens_around(text, "U"), + vec!["sparse+https://h/x/U/i/", "https://h/U", "https://h/U#f"] + ); + } + /// Discovery records the origins it classified with, for liveness. #[tokio::test] async fn discovery_carries_its_patch_server_origins() { From 0c831059649d9eb5403f8ac4d5f0caf229b7da19 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Mon, 9 Nov 2026 15:47:11 -0500 Subject: [PATCH 20/22] Ask the hosted rewriter whether a re-scan can pin a Cargo declaration Review on #2019 (/code-review high): - The vex_cargo_unpinned_declaration remedy said "re-run socket-patch scan" whenever every unpinned declaration was a crates.io one the rewriter would pin. The rewriter skips the whole dependency on its first refusal, including one over a declaration discovery doesn't count as unpinned (the pin's own requirement matching another locked version, a path template nobody inherits). Discovery now runs the rewriter's own Cargo.toml planner dry over the same manifests (plan_cargo_tomls, extracted from rewrite_cargo_with) before it promises a re-scan. - A declaration naming registry = "socket-patch-" beside a path, git or registry-index read as if the registry were missing. The detail now says the source overrides it, and says "those declarations" when there are several. - manifest_declarations looks up inherited template keys in a set. Co-Authored-By: Claude Opus 5.5 --- .../src/patch/redirect/mod.rs | 121 ++++++++++-------- .../src/vex/discover/cargo.rs | 98 ++++++++++++-- 2 files changed, 153 insertions(+), 66 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 93338acad..92ed9d4c4 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -2142,60 +2142,21 @@ fn rewrite_cargo_with( } let other_versions = cargo_lock_other_versions(cargo_lock.as_deref(), &dep.name, &dep.version); - // The root manifest's `[workspace.dependencies]` verdicts feed its - // members' `workspace = true` inheritors. - let mut root_workspace: BTreeMap = BTreeMap::new(); - let mut toml_plans: Vec<(usize, CargoTomlPlan)> = Vec::new(); - let mut excluded: Vec<(String, String)> = Vec::new(); - let mut refused: Option<(String, String)> = None; - for (i, (path, text)) in manifests.iter().enumerate() { - match plan_cargo_toml( - text, - path, - &dep.name, - &dep.version, - &other_versions, - ®, - &root_workspace, - ) { - Ok(plan) => { - if let Err(reason) = validate_cargo_toml_pins( - &plan.content, - &dep.name, - &dep.version, - &other_versions, - ®, - &plan.workspace, - &root_workspace, - ) { - refused = Some((path.clone(), reason)); - break; - } - if path == "Cargo.toml" { - root_workspace = plan.workspace.clone(); - } - excluded.extend(plan.excluded.iter().map(|req| (path.clone(), req.clone()))); - if plan.found { - toml_plans.push((i, plan)); - } - } - Err(reason) => { - refused = Some((path.clone(), reason)); - break; + let (toml_plans, excluded) = + match plan_cargo_tomls(&manifests, &dep.name, &dep.version, &other_versions, ®) { + Ok(planned) => planned, + Err((path, reason)) => { + result.warnings.push(RewriteWarning { + code: "redirect_cargo_toml_dep_unrewritable".into(), + detail: format!( + "{} in {path} cannot be pinned ({reason}); dependency skipped \ + (nothing rewritten)", + dep.name + ), + }); + continue; } - } - } - if let Some((path, reason)) = refused { - result.warnings.push(RewriteWarning { - code: "redirect_cargo_toml_dep_unrewritable".into(), - detail: format!( - "{} in {path} cannot be pinned ({reason}); dependency skipped \ - (nothing rewritten)", - dep.name - ), - }); - continue; - } + }; // Declared, but no declaration's requirement accepts the patched // version: cargo resolves each to another version, so a pin cannot // reach the locked one (the TS twin's requirement-coverage refusal). @@ -3634,7 +3595,7 @@ fn validate_cargo_toml_pins( Ok(()) } -struct CargoTomlPlan { +pub(crate) struct CargoTomlPlan { content: String, edits: Vec, /// `false` when every occurrence already carried our registry (idempotent @@ -3778,6 +3739,58 @@ fn cargo_lock_other_versions(lock: Option<&str>, crate_name: &str, version: &str versions } +/// The Cargo.toml half of a hosted pin of `crate_name`@`version` to +/// registry `reg` across `manifests` (root `Cargo.toml` first, then the +/// members, as `(path, text)`): each manifest's plan (by index) that found +/// a declaration, and every requirement that excludes the version, by +/// path — or the first refusal, `(path, reason)`, which skips the whole +/// dependency. `vex` discovery runs it dry to say whether a re-scan can +/// pin a declaration it found unpinned. +#[allow(clippy::type_complexity)] +pub(crate) fn plan_cargo_tomls( + manifests: &[(String, String)], + crate_name: &str, + version: &str, + other_versions: &[String], + reg: &str, +) -> Result<(Vec<(usize, CargoTomlPlan)>, Vec<(String, String)>), (String, String)> { + // The root manifest's `[workspace.dependencies]` verdicts feed its + // members' `workspace = true` inheritors. + let mut root_workspace: BTreeMap = BTreeMap::new(); + let mut toml_plans: Vec<(usize, CargoTomlPlan)> = Vec::new(); + let mut excluded: Vec<(String, String)> = Vec::new(); + for (i, (path, text)) in manifests.iter().enumerate() { + let plan = plan_cargo_toml( + text, + path, + crate_name, + version, + other_versions, + reg, + &root_workspace, + ) + .map_err(|reason| (path.clone(), reason))?; + validate_cargo_toml_pins( + &plan.content, + crate_name, + version, + other_versions, + reg, + &plan.workspace, + &root_workspace, + ) + .map_err(|reason| (path.clone(), reason))?; + if path == "Cargo.toml" { + root_workspace = plan.workspace.clone(); + } + excluded.extend(plan.excluded.iter().map(|req| (path.clone(), req.clone()))); + if plan.found { + toml_plans.push((i, plan)); + } + } + Ok((toml_plans, excluded)) +} + /// `Err` carries the refusal reason: an occurrence exists that cannot be /// pinned to the managed registry, so the whole dep must be skipped. /// `inherited` is the workspace root's verdicts when planning a member. diff --git a/crates/socket-patch-core/src/vex/discover/cargo.rs b/crates/socket-patch-core/src/vex/discover/cargo.rs index 41dcc4299..46af02c95 100644 --- a/crates/socket-patch-core/src/vex/discover/cargo.rs +++ b/crates/socket-patch-core/src/vex/discover/cargo.rs @@ -131,7 +131,7 @@ //! that cargo ignores (because `.cargo/config` exists) are diagnosed, never //! read. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use toml_edit::{DocumentMut, Item, TableLike}; @@ -219,7 +219,7 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { .map(|(_, doc)| registry_definitions(doc).into_iter().collect()) .unwrap_or_default(); - hosted_from_lock(ctx, &lock, decls.as_ref(), &definitions, out); + hosted_from_lock(ctx, &lock, &manifests, decls.as_ref(), &definitions, out); unpatched_twins(ctx, &lock, out); if let Some(decls) = &decls { unresolved_manifest_pins(ctx, &lock, decls, &definitions, out); @@ -555,7 +555,7 @@ fn manifest_declarations(manifests: &[(String, DocumentMut)]) -> Decls { .map(move |d| (file.as_str(), d)) }) .collect(); - let inherited: Vec<&str> = entries + let inherited: BTreeSet<&str> = entries .iter() .filter(|(_, d)| d.inherits) .map(|(_, d)| d.key.as_str()) @@ -701,6 +701,7 @@ fn source_uuid(ctx: &DiscoverCtx<'_>, source: &str) -> Option { fn hosted_from_lock( ctx: &DiscoverCtx<'_>, lock: &Lock, + manifests: &[(String, DocumentMut)], decls: Option<&Decls>, definitions: &BTreeMap, out: &mut Discovery, @@ -766,7 +767,7 @@ fn hosted_from_lock( .any(|d| d.registry.as_deref() == Some(reg.as_str())) { out.push(lock_ref); - beside_the_pin(ctx, lock, pkg, &purl, &uuid, regs, out); + beside_the_pin(ctx, lock, manifests, pkg, &purl, &uuid, regs, out); continue; } let others: Vec<(&str, String)> = regs @@ -869,9 +870,11 @@ fn route(d: &Decl, patch_pins: &[(&str, String)], version: &str, others: &[Strin /// When the lock already holds a same-version copy from another source, /// [`unpatched_twins`] withholds the ref and names that entry (#679), so /// nothing is added here. +#[allow(clippy::too_many_arguments)] fn beside_the_pin( ctx: &DiscoverCtx<'_>, lock: &Lock, + manifests: &[(String, DocumentMut)], pkg: &LockedPackage, purl: &str, uuid: &str, @@ -938,10 +941,31 @@ fn beside_the_pin( .version .as_deref() .map_or_else(|| "with no version".to_string(), |v| format!("{v:?}")); - format!("{} declares {} {req} from {}", d.file, pkg.name, d.origin()) + // A `path` / `git` / `registry-index` overrides a Socket + // registry named beside it: say so, or the note reads as if the + // registry were missing (review on #2019). + let beside = match d.registry.as_deref() { + Some(reg) + if reg.starts_with(PIN_NAME_PREFIX) && d.source != DeclSource::Registry => + { + format!(", which overrides its registry = \"{reg}\"") + } + _ => String::new(), + }; + format!( + "{} declares {} {req} from {}{beside}", + d.file, + pkg.name, + d.origin() + ) }) .collect::>() .join(", and "); + let (that, each) = if unpinned.len() == 1 { + ("that declaration", "the crate declaring it") + } else { + ("those declarations", "each crate declaring one") + }; // What a re-scan can do: the hosted rewriter pins a crates.io // declaration whose requirement selects only the patched version among // the locked ones, and refuses every other kind. @@ -976,23 +1000,48 @@ fn beside_the_pin( Some(format!("{}'s declaration ({why})", d.file)) }) .collect(); - let remedy = if refused.is_empty() { - "re-run `socket-patch scan` to pin it too".to_string() - } else { + // Every declaration may pass on its own and the rewriter still refuse + // the dependency over another one (the pin's own requirement matching + // another locked version, a `path` template nobody inherits): it skips + // the whole dependency on the first refusal, so ask its planner, dry, + // over the same manifests (review on #2019). + let planner_refusal = || { + let texts: Vec<(String, String)> = manifests + .iter() + .map(|(file, doc)| (file.clone(), doc.to_string())) + .collect(); + crate::patch::redirect::plan_cargo_tomls( + &texts, + &pkg.name, + &pkg.version, + &others, + &hosted_pin_name(uuid), + ) + .err() + }; + let remedy = if !refused.is_empty() { format!( "`socket-patch scan` cannot pin {}, so change that declaration or roll the \ patch back", refused.join(", or ") ) + } else if let Some((file, why)) = planner_refusal() { + format!( + "`socket-patch scan` cannot pin {} ({file}: {why}), so change that declaration \ + or roll the patch back", + pkg.name + ) + } else { + "re-run `socket-patch scan` to pin it too".to_string() }; out.unattested( purl, uuid, &first.file, format!( - "{declared}, not registry \"{}\": cargo resolves that declaration as a \ - separate package, so the crate declaring it builds an unpatched {} that the \ - requirement allows to be {}; {remedy}", + "{declared}, not registry \"{}\": cargo resolves {that} as a separate \ + package, so {each} builds an unpatched {} that the requirement allows to be \ + {}; {remedy}", hosted_pin_name(uuid), pkg.name, pkg.version @@ -2227,6 +2276,24 @@ mod tests { u.detail ); } + // Review on #2019 (round 3): every unpinned declaration passes on + // its own, but the rewriter refuses the dependency over the PIN's + // own requirement (`"1"` also matches the crates.io 1.0.200), so a + // re-scan pins nothing. + let p = mixed_workspace( + &pinned("serde", "1", UUID_A), + "serde = \"=1.0.1\"", + &[("1.0.200", CRATES_IO)], + ); + let out = run(&p).await; + let u = unpinned_mark(&out, "crates/a/Cargo.toml"); + assert!( + u.detail.contains("cannot pin serde (Cargo.toml:") + && u.detail.contains("roll the patch back") + && !u.detail.contains("re-run `socket-patch scan`"), + "{}", + u.detail + ); } /// A declaration that names the Socket registry but also a `path`, @@ -2249,7 +2316,14 @@ mod tests { &out, &[("pkg:cargo/serde@1.0.1", UUID_A, WiringMode::Hosted)], ); - unpinned_mark(&out, "crates/a/Cargo.toml"); + let u = unpinned_mark(&out, "crates/a/Cargo.toml"); + assert!( + u.detail.contains(&format!( + "which overrides its registry = \"socket-patch-{UUID_A}\"" + )), + "{extra}: {}", + u.detail + ); let p = Project::new(); p.write("Cargo.toml", manifest(&member)); From f87fc9f77d589490c276b3057cf80846f3c9e4cd Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Mon, 9 Nov 2026 16:01:34 -0500 Subject: [PATCH 21/22] Fix review findings on Cargo [patch] shadowing and bypass details Review on #2024 (/code-review high), round 4: - The "resolves it only from other Socket patches" diagnostic fired beside a same-version crates.io twin; it now needs a lock with no non-Socket entry of the crate. - Shadow resolves a [patch.] table through the config's [registries] index, so a config [patch.""] item shadows it. - An identical path-less Socket registry [patch] pin in the manifest and the config is the same wiring, not a shadow. - patched_by (registry-index) and same_patch_source share patch_source_key, with CanonicalUrl rules on both. - patch_bypassed's conflict-gate check matches the CLI's bypassed_ref (file-scoped marks only); it routes each declaration once; its detail also names other-patch declarations; a git lock entry a git [patch] table redirects gets the stale/re-lock remedy. - Reuse patch::redirect::is_crates_io_source for lock sources. - Move the manifests comment back above its vector. Co-Authored-By: Claude Opus 5.5 --- .../src/patch/redirect/mod.rs | 2 +- .../src/vex/discover/cargo.rs | 297 ++++++++++++++---- 2 files changed, 229 insertions(+), 70 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 92ed9d4c4..d2f21c1af 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -2538,7 +2538,7 @@ fn cargo_manifest_package_id( /// in either the git-index or the sparse spelling. Compared the way cargo's /// `CanonicalUrl` does: host casing, a trailing `/` and a `.git` suffix do /// not make a different source (and github paths are case-insensitive). -fn is_crates_io_source(source: &str) -> bool { +pub(crate) fn is_crates_io_source(source: &str) -> bool { let url = source.trim(); let url = url .strip_prefix("registry+") diff --git a/crates/socket-patch-core/src/vex/discover/cargo.rs b/crates/socket-patch-core/src/vex/discover/cargo.rs index e624ed5ef..564be0ec4 100644 --- a/crates/socket-patch-core/src/vex/discover/cargo.rs +++ b/crates/socket-patch-core/src/vex/discover/cargo.rs @@ -178,8 +178,6 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { let lock = load_lock(ctx, out).await; let root = read_toml(ctx, CARGO_TOML, out).await; let config = read_config(ctx, out).await; - // The root manifest, then every workspace member's — the manifests the - // hosted rewriter pins a crate in (each that declares it). // Every `[patch]` entry pointing at a Socket copy, the root manifest's // (with why cargo ignores it, if it does) and then the config's. let mut wired_all: Vec<(WiredCopy, Option)> = Vec::new(); @@ -187,7 +185,7 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { if let Some(doc) = &root { let s = Shadow::new(doc, config.as_ref()); wired_all.extend(socket_copies(doc).into_iter().map(|w| { - let why = s.why(&w.table, &w.key, Some(&w.path)); + let why = s.why(&w.table, &w.key, Some(&w.path), None); (w, why) })); shadow = Some(s); @@ -195,6 +193,8 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { if let Some((_, doc)) = &config { wired_all.extend(socket_copies(doc).into_iter().map(|w| (w, None))); } + // The root manifest, then every workspace member's — the manifests the + // hosted rewriter pins a crate in (each that declares it). let mut manifests: Vec<(String, DocumentMut)> = Vec::new(); if let Some(doc) = root { // Any Socket pin the root or the config carries, a `[patch]` @@ -227,7 +227,7 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { let mut decl = Decl::patch(CARGO_TOML, patch.source, reg); decl.live = shadow .as_ref() - .is_none_or(|s| s.why(patch.source, patch.key, None).is_none()); + .is_none_or(|s| s.why(patch.source, patch.key, None, Some(reg)).is_none()); decls.entry(name).or_default().push(decl); } } @@ -523,7 +523,8 @@ impl Decl { /// * a registry dependency of that registry (`crates-io`, or a /// crates.io URL alias, for one with no `registry` or /// `registry = "crates-io"`, which cargo reads as crates.io too); - /// * a `registry-index` dependency of that index URL ([`index_key`]); + /// * a `registry-index` dependency of that index URL + /// ([`patch_source_key`], the spelling `[patch]` tables compare in); /// one naming the crates.io git index is crates.io itself (the same /// cargo source), so `[patch.crates-io]` redirects it too; /// * a git dependency of that repository ([`git_key`], cargo's @@ -539,7 +540,7 @@ impl Decl { }, DeclSource::RegistryIndex(url) => { (is_crates_io_url_alias(url) && is_crates_io_source(table)) - || index_key(url).is_some_and(|k| index_key(table) == Some(k)) + || patch_source_key(url) == patch_source_key(table) } DeclSource::Git(url) => git_key(url).is_some_and(|k| git_key(table) == Some(k)), DeclSource::Path | DeclSource::Patch(_) => false, @@ -1293,6 +1294,15 @@ fn unresolved_manifest_pins( .filter(|p| p.name == *name) .filter_map(|p| p.source.as_deref().and_then(|s| source_uuid(ctx, s))) .collect(); + // Whether the lock also resolves the crate from a non-Socket source + // (or a path): then "only from other Socket patches" is not what it + // says (review on #2024). + let locked_elsewhere = lock.packages().iter().any(|p| { + p.name == *name + && p.source + .as_deref() + .is_none_or(|s| source_uuid(ctx, s).is_none()) + }); let carried = |uuid: &str| { locked_from.iter().any(|u| u == uuid) || out.refs.iter().any(|r| { @@ -1356,7 +1366,7 @@ fn unresolved_manifest_pins( } format!("there is no {CARGO_LOCK} to fix its version") } - Lock::Parsed { .. } if !locked_from.is_empty() => format!( + Lock::Parsed { .. } if !locked_from.is_empty() && !locked_elsewhere => format!( "{CARGO_LOCK} resolves it only from other Socket patches, at versions \ this pin's requirement excludes (a stale lock: the next build \ re-resolves this declaration)" @@ -1453,31 +1463,42 @@ fn socket_copies(doc: &DocumentMut) -> Vec { struct Shadow { /// The manifest's `[patch.""]` table keys. aliases: Vec, - /// The config's `[patch]` items: (file, source table, key, path). - config_keys: Vec<(&'static str, String, String, Option)>, + /// The config's `[patch]` items. + config_keys: Vec, + /// The config's `[registries.] index` values: a `[patch.]` + /// table patches the same source as `[patch.""]`. + registries: BTreeMap, +} + +/// One config `[patch]` item as [`Shadow`] compares it: (file, source +/// table, key, path, registry). +type ConfigPatchItem = (&'static str, String, String, Option, Option); + +/// A `[patch.]` table name, a `registry-index` or a lock `source` +/// as cargo's `CanonicalUrl` spells it, for comparison: a URL's host case, +/// a trailing `/` or `.git` and a GitHub path's case ignored, a lock's +/// `registry+` prefix dropped, `sparse+` kept (another source); a registry +/// name verbatim. +fn patch_source_key(source: &str) -> String { + let t = source.trim(); + let key = if t.starts_with("sparse+") { + index_key(t) + } else if t.contains("://") { + git_key(t.strip_prefix("registry+").unwrap_or(t)) + } else { + None + }; + key.unwrap_or_else(|| t.to_string()) } /// Whether two `[patch.]` table names patch the same source: -/// crates.io under any spelling, else the same name or URL. +/// crates.io under any spelling, else the same name or URL +/// ([`patch_source_key`]). fn same_patch_source(a: &str, b: &str) -> bool { if is_crates_io_source(a) || is_crates_io_source(b) { return is_crates_io_source(a) && is_crates_io_source(b); } - // A URL table compares as cargo's `CanonicalUrl` spells it (host case, - // a trailing `/` or `.git`, a GitHub path's case; `sparse+` kept as - // another source); a registry name verbatim. - let norm = |s: &str| { - let t = s.trim(); - let key = if t.starts_with("sparse+") { - index_key(t) - } else if t.contains("://") { - git_key(t.strip_prefix("registry+").unwrap_or(t)) - } else { - None - }; - key.unwrap_or_else(|| t.to_string()) - }; - norm(a) == norm(b) + patch_source_key(a) == patch_source_key(b) } impl Shadow { @@ -1491,31 +1512,67 @@ impl Shadow { e.source.to_string(), e.key.to_string(), e.path.map(same_path_key), + e.registry.map(|r| r.trim().to_string()), ) }) }) .collect(); + let registries = config + .and_then(|(_, doc)| doc.get("registries")) + .and_then(toml_edit::Item::as_table_like) + .into_iter() + .flat_map(|t| t.iter()) + .filter_map(|(name, item)| { + let index = item.as_table_like()?.get("index")?.as_str()?; + Some((name.to_string(), index.to_string())) + }) + .collect(); Shadow { aliases: crates_io_url_alias_tables(manifest), config_keys, + registries, } } - fn why(&self, table: &str, key: &str, path: Option<&str>) -> Option { + /// `table`, a registry name resolved to its configured index URL. + fn resolved<'s>(&'s self, table: &'s str) -> &'s str { + self.registries + .get(table.trim()) + .map_or(table, String::as_str) + } + + /// Why cargo ignores the manifest's `[patch.
]` item `key` + /// (wiring `path`, or Socket registry `registry`), if it does. + fn why( + &self, + table: &str, + key: &str, + path: Option<&str>, + registry: Option<&str>, + ) -> Option { if let (true, Some(alias)) = (table == "crates-io", self.aliases.first()) { return Some(format!( "the `[patch.\"{alias}\"]` table replaces `[patch.crates-io]` in cargo" )); } - // Only an identical path is the same wiring: two path-less items (a - // registry pin and a git fork, say) are not. + // Only identical wiring is the same: the same path, or the same + // registry for path-less items (review on #2024). Two path-less + // items otherwise (a registry pin and a git fork, say) are not. let own = path.map(same_path_key); + let own_reg = registry.map(str::trim); + let same_wiring = |p: &Option, r: &Option| match (&own, own_reg) { + (Some(_), _) => *p == own, + (None, Some(reg)) => p.is_none() && r.as_deref() == Some(reg), + (None, None) => false, + }; self.config_keys .iter() - .find(|(_, src, k, p)| { - k == key && same_patch_source(src, table) && (own.is_none() || *p != own) + .find(|(_, src, k, p, r)| { + k == key + && same_patch_source(self.resolved(src), self.resolved(table)) + && !same_wiring(p, r) }) - .map(|(file, _, k, _)| { + .map(|(file, _, k, _, _)| { format!("{file} redefines `[patch]` key `{k}`, which replaces it in cargo") }) } @@ -1533,12 +1590,6 @@ struct Bypass<'a> { wired: &'a [WiredCopy], } -/// Whether a `Cargo.lock` `source` is crates.io (its git or sparse index). -fn is_crates_io_lock_source(source: &str) -> bool { - is_crates_io_url_alias(source.trim().trim_start_matches("registry+")) - || index_key(source).as_deref() == Some("sparse+https://index.crates.io") -} - /// Mark the vendored ref `purl` (the copy `vref` of `name`@`version`) /// [`UnattestedKind::CargoPatchBypassed`] when the build may also compile /// a copy of that version its wiring does not reach. A `[patch.
]` @@ -1584,40 +1635,54 @@ fn patch_bypassed( .filter(|p| p.name == name && cargo_tag::strip_tag(&p.version) != version) .map(|p| Some(cargo_tag::strip_tag(&p.version).to_string())), ); - let declared: Vec<&Decl> = bypass - .decls - .and_then(|d| d.get(name)) - .into_iter() - .flatten() - .filter(|d| d.source != DeclSource::Path) - .filter(|d| matches!(route(d, &pins, version, &others), Route::Unpinned(_))) - .collect(); + let mut declared: Vec<&Decl> = Vec::new(); + let mut pinned_elsewhere: Vec<(&Decl, String)> = Vec::new(); + for d in bypass.decls.and_then(|d| d.get(name)).into_iter().flatten() { + match route(d, &pins, version, &others) { + Route::Unpinned(_) if d.source != DeclSource::Path => declared.push(d), + Route::Pin(uuid) if uuid != vref.uuid => pinned_elsewhere.push((d, uuid)), + _ => {} + } + } // A declaration pinned to ANOTHER Socket patch builds that patch's // copy, not this one. Only a ref for that patch at this very package, // emitted by the hosted pass (from the lock, or a conflict pair beside // a lock pin) and not itself withheld, puts the pair before the CLI's // conflict gate; otherwise (a stale lock, a lock line whose config // index disagrees, another locked version) nothing else says so. + // "Withheld" here is exactly the CLI's `bypassed_ref`: a file-scoped + // mark on the ref's own file (review on #2024); any other mark leaves + // the ref in the conflict gate. let gated = |uuid: &str| { out.refs.iter().any(|r| { r.uuid == uuid && r.purl == purl - && !out - .unattested - .iter() - .any(|u| u.uuid == uuid && u.purl == purl && u.file == r.source_file) + && !out.unattested.iter().any(|u| { + u.kind.file_scoped() + && u.uuid == uuid + && u.purl == purl + && u.file == r.source_file + }) }) }; - let other_patch: Vec<(&Decl, String)> = bypass - .decls - .and_then(|d| d.get(name)) + let other_patch: Vec<(&Decl, String)> = pinned_elsewhere .into_iter() - .flatten() - .filter_map(|d| match route(d, &pins, version, &others) { - Route::Pin(uuid) if uuid != vref.uuid && !gated(&uuid) => Some((d, uuid)), - _ => None, - }) + .filter(|(_, uuid)| !gated(uuid)) .collect(); + // Named in every detail, so one `vex` run shows each cause (review on + // #2024). + let also_other = if other_patch.is_empty() { + String::new() + } else { + format!( + ". Also: {}, which no wiring of that patch pairs with this copy", + other_patch + .iter() + .map(|(d, uuid)| format!("{} (Socket patch {uuid})", d.describe(name))) + .collect::>() + .join(", and ") + ) + }; let locked = same_version_foreign_entry(ctx, bypass.lock, name, version); let redirects = format!( "which no [patch] entry pointing at {} redirects", @@ -1640,7 +1705,8 @@ fn patch_bypassed( package, so the crate declaring it builds an unpatched {name} that the \ requirement allows to be {version}{evidence}. Re-running `vendor` does \ not change this: declare {name} from crates.io there (or point a \ - [patch] table for its source at the copy), or roll the patch back" + [patch] table for its source at the copy), or roll the patch \ + back{also_other}" ), ) } @@ -1648,8 +1714,16 @@ fn patch_bypassed( // A crates.io entry beside a copy `[patch.crates-io]` wires is // stale (cargo would redirect it); any other is declared by // some dependency, or left over from one that was removed. - let stale = is_crates_io_lock_source(source) - && pins.iter().any(|(table, _)| is_crates_io_source(table)); + // Likewise a git entry a `[patch.""]` table + // pointing at the copy redirects (review on #2024). + let stale = (crate::patch::redirect::is_crates_io_source(source) + && pins.iter().any(|(table, _)| is_crates_io_source(table))) + || source.trim().strip_prefix("git+").is_some_and(|repo| { + let repo = repo.split(['?', '#']).next().unwrap_or(repo); + pins.iter().any(|(table, _)| { + git_key(table).is_some() && git_key(table) == git_key(repo) + }) + }); let remedy = if stale { format!( "the entry is stale (`[patch.crates-io]` redirects crates.io), so \ @@ -1666,7 +1740,8 @@ fn patch_bypassed( CARGO_LOCK, format!( "{CARGO_LOCK} also locks {name} {version} from {source}, {redirects}: \ - cargo builds that copy unpatched beside the vendored one; {remedy}" + cargo builds that copy unpatched beside the vendored one; \ + {remedy}{also_other}" ), ) } @@ -1723,7 +1798,7 @@ async fn vendored_from_patches( continue; // a user path, or the uuid-less `.socket/cargo-patches/` } let name = entry.name; - if let Some(why) = shadow.and_then(|s| s.why(entry.source, entry.key, entry.path)) { + if let Some(why) = shadow.and_then(|s| s.why(entry.source, entry.key, entry.path, None)) { out.diag( DIAG_REF_INVALID, file, @@ -4474,6 +4549,29 @@ mod tests { ); } + /// Review on #2024 (round 3): a pin to another patch beside a lock that + /// also resolves the crate from crates.io is not described as "only + /// from other Socket patches". + #[tokio::test] + async fn another_patch_pin_beside_a_foreign_twin_is_not_called_socket_only() { + let p = mixed_workspace( + &pinned("serde", "1", UUID_A), + &pinned("serde", "1", UUID_B), + &[("1.0.1", CRATES_IO)], + ); + let out = run(&p).await; + let diag = out + .diagnostics + .iter() + .find(|d| d.code == DIAG_REF_UNATTRIBUTABLE && d.detail.contains(UUID_B)) + .unwrap_or_else(|| panic!("{:#?}", out.diagnostics)); + assert!( + !diag.detail.contains("only from other Socket patches"), + "{}", + diag.detail + ); + } + /// REVIEW (Bugbot on #2024): a config item for the same key replaces /// the manifest's `[patch]` item in cargo whatever the source table and /// whether or not either wires a path: a path-less config git fork @@ -4493,14 +4591,33 @@ mod tests { let cfg = (".cargo/config.toml", config); let s = super::Shadow::new(&manifest, Some(&cfg)); // A path-less registry pin under the same key: shadowed. - assert!(s.why("crates-io", "cfg-if-socket-aaaaaaaa", None).is_some()); + assert!(s + .why( + "crates-io", + "cfg-if-socket-aaaaaaaa", + None, + Some("socket-patch-aaaaaaaa") + ) + .is_some()); // The same key under another source: not. - assert!(s.why("other", "cfg-if-socket-aaaaaaaa", None).is_none()); + assert!(s + .why( + "other", + "cfg-if-socket-aaaaaaaa", + None, + Some("socket-patch-aaaaaaaa") + ) + .is_none()); // A named non-crates.io table: shadowed. - assert!(s.why("my-corp", "cfg-if", Some("vendor/x")).is_some()); + assert!(s.why("my-corp", "cfg-if", Some("vendor/x"), None).is_some()); // A URL table, compared as an index. assert!(s - .why("https://my-corp.example/index", "cfg-if", Some("vendor/x")) + .why( + "https://my-corp.example/index", + "cfg-if", + Some("vendor/x"), + None + ) .is_some()); // A canonical-equivalent git table (Bugbot on a7611ef). let config: toml_edit::DocumentMut = @@ -4510,18 +4627,60 @@ mod tests { let cfg2 = (".cargo/config.toml", config); let s2 = super::Shadow::new(&manifest, Some(&cfg2)); assert!(s2 - .why("https://github.com/corp/fork", "cfg-if", Some("vendor/x")) + .why( + "https://github.com/corp/fork", + "cfg-if", + Some("vendor/x"), + None + ) .is_some()); assert!(s2 .why( "sparse+https://github.com/corp/fork", "cfg-if", - Some("vendor/x") + Some("vendor/x"), + None ) .is_none()); // The half-migrated shape: the same path is the same wiring. assert!(s - .why("https://my-corp.example/index", "cfg-if", Some("../fork")) + .why( + "https://my-corp.example/index", + "cfg-if", + Some("../fork"), + None + ) + .is_none()); + // Review on #2024: the identical path-less Socket registry pin in + // both files is the same wiring, not a shadow. + let config: toml_edit::DocumentMut = + "[patch.crates-io]\nserde = { registry = \"socket-patch-aaaaaaaa\" }\n" + .parse() + .unwrap(); + let cfg3 = (".cargo/config.toml", config); + let s3 = super::Shadow::new(&manifest, Some(&cfg3)); + assert!(s3 + .why("crates-io", "serde", None, Some("socket-patch-aaaaaaaa")) + .is_none()); + assert!(s3 + .why("crates-io", "serde", None, Some("socket-patch-bbbbbbbb")) + .is_some()); + // Review on #2024: a `[patch.""]` config table patches the + // same source as the manifest's `[patch.]` for a registry + // defined on that index. + let config: toml_edit::DocumentMut = "[registries.my-corp]\n\ + index = \"https://my-corp.example/index\"\n\ + [patch.\"https://my-corp.example/index\"]\n\ + cfg-if = { git = \"https://example.com/fork\" }\n" + .parse() + .unwrap(); + let cfg4 = (".cargo/config.toml", config); + let s4 = super::Shadow::new(&manifest, Some(&cfg4)); + assert!(s4 + .why("my-corp", "cfg-if", Some("vendor/x"), None) + .is_some()); + assert!(s4 + .why("other-corp", "cfg-if", Some("vendor/x"), None) .is_none()); } From f1eb764f46e5eccad5343219f20d519383610e7a Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Mon, 9 Nov 2026 16:47:28 -0500 Subject: [PATCH 22/22] Simplify the folded Cargo VEX stack Behavior-preserving cleanup of #2012 after #2019 and #2024 were folded in: - One `distinct` replaces four hand-written order-preserving dedups; `describe_all` and one `other_declared` replace repeated describe/join chains. - `DeclSource::patched_by` is the one redirect rule; the bypass check's stale-entry test maps the lock source to a `DeclSource` instead of re-deriving crates.io and git matching. `index_key` is folded into `patch_source_key`. - `beside_the_pin` collects the lock's Socket uuids once; the manifests are serialized for the rewriter's dry planner once per discovery (`ManifestTexts`), not per crate. `vendored_from_patches` takes the lock from `Bypass`; the two `decls` passes in `extract` are one. - `foreign_hosted_uuid` moves to `origin::hosted_shape_uuid`, so `normalized_hosted_url` is private again; the cheap accepted-origin check runs first in `foreign_hosted_url_in_files`. - Comments and docs no longer narrate review history; the cargo module doc points at `beside_the_pin` / `patch_bypassed` instead of restating them; CLI_CONTRACT.md states the registry-index rule once and the `apply` sentence parses. - Tests share `workspace_root`, `member`, `one_mark` and `shadow` instead of respelling manifests and config parsing; the e2e fail-closed comments point at CLI_CONTRACT.md. Review of the simplify diff (/code-review high): the stale-lock remedy named `[patch.crates-io]` and `socket-patch vendor` for a git entry too; it now names the table that redirects the entry and offers `vendor` only for crates.io. CLI_CONTRACT.md keeps the member-walk and uninherited- template rules the trim had dropped. Co-Authored-By: Claude Opus 5.5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 6 +- .../tests/e2e_vex_lockfile/cargo.rs | 11 +- .../tests/e2e_vex_lockfile/maven.rs | 11 +- .../tests/e2e_vex_lockfile/yarn.rs | 13 +- crates/socket-patch-core/src/origin.rs | 26 +- .../src/vex/discover/cargo.rs | 735 +++++++++--------- .../socket-patch-core/src/vex/discover/mod.rs | 102 +-- 7 files changed, 425 insertions(+), 479 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 1339b4076..1e3d072bb 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -510,7 +510,7 @@ Every VEX statement's impact string records which patch-application mode persist `vendored` and `redirected` are disjoint in practice (the modes conflict); if a PURL somehow appears in both sets, `vendored` wins. -**Patch hosts (manifest-less VEX).** A hosted lockfile reference counts only when it points at Socket's patch server or the operator's `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin. A hosted URL on any OTHER host — a staging patch server used without `--patch-server-url`, or a look-alike host — is not a hosted pin at all (v5.0: there is no redirect ledger to vouch for it), so `vex` does not attest it, and `list`, `rollback`, `remove`, `vendor` and `repair` do not see it either. Pass `--patch-server-url` for a non-production patch server. A pre-v5 redirect ledger still on disk does not change this: a record whose lockfile or recorded wiring resolves the package from a hosted-shaped URL on an origin this run was not given (or one carrying userinfo) is never attested from the ledger, whatever is installed and even under `--no-verify` / `--vex-no-verify`: `vex` reports it as `redirect_unwired`, with a `vex_claim_unwired` note naming the cause. (A patch `apply`'s manifest also records is still verified as the agent-mode patch it is, on that record.) `scan` does not treat such wiring as gone: its takeover and hosted-wiring advisories still see it. +**Patch hosts (manifest-less VEX).** A hosted lockfile reference counts only when it points at Socket's patch server or the operator's `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin. A hosted URL on any OTHER host — a staging patch server used without `--patch-server-url`, or a look-alike host — is not a hosted pin at all (v5.0: there is no redirect ledger to vouch for it), so `vex` does not attest it, and `list`, `rollback`, `remove`, `vendor` and `repair` do not see it either. Pass `--patch-server-url` for a non-production patch server. A pre-v5 redirect ledger still on disk does not change this: a record whose lockfile or recorded wiring resolves the package from a hosted-shaped URL on an origin this run was not given (or one carrying userinfo) is never attested from the ledger, whatever is installed and even under `--no-verify` / `--vex-no-verify`: `vex` reports it as `redirect_unwired`, with a `vex_claim_unwired` note naming the cause. (A patch that `apply`'s manifest also records is still verified as an agent-mode patch, on that record.) `scan` does not treat such wiring as gone: its takeover and hosted-wiring advisories still see it. ### Manifest-less VEX (lockfile discovery) @@ -547,9 +547,9 @@ Recognition rules that hold for every ecosystem: * **Patch hosts.** A hosted reference counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin, with no userinfo. The uuid is the URL's LAST canonical-uuid path segment, because grant tokens may themselves be uuid-shaped. The Go module prefix is fixed. `socket-patch-` registry / repository / source names count only through a pin. For a URL on any other host, see **Patch hosts** above. * **Pins, not definitions.** A registry, index or source *definition* alone (cargo `[registries]`, nuget ``, pom ``, uv index tables, `.npmrc`) never makes a reference, because it survives a reverted pin. Sections the package manager ignores are not read: npm's v2 `dependencies` mirror, a `.cargo/config.toml` shadowed by `.cargo/config`. A Socket pin inside a maven `` is diagnosed, never a reference. * **Contested locks.** When one lock wires a package to a patch and another lock resolves the same `name@version` from a non-Socket source, the build's bytes depend on which package manager runs. The reference is then dropped with a `patched_ref_unattributable` diagnostic naming both files. This applies across npm / pnpm / yarn / bun and across uv / pylock / poetry / pdm / Pipfile.lock / requirements. PEP 723 script locks neither contest nor are contested. The root `requirements.txt` and its in-root `-r` includes count as one lock here: pip reads them as one requirement set, where a direct reference wins over a compatible `==` pin of the same version in another file of the set (#1086). A PyPI version matches under PEP 440 equality, so a requirements line pinned in any spelling that can only install the wired release (`==1.2.3.0`, `==v1.2.3`, `===1.2.3`) contests it like `==1.2.3` does; a range or wildcard (`~=1.2.3`, `==1.2.*`) does not (#1602). A **bundled** npm copy (`inBundle: true`, or v1 `bundled: true`, beneath a dependency that bundles it; the root project's own bundles install from the lock, #1618) of the same `name@version` contests the reference too, in the same lock, in the other npm lock of a shrinkwrap/package-lock pair, or in any other lock. npm unpacks it from the parent package's tarball, so no rewire reaches it and it stays unpatched. Bun and vlt unpack bundled copies the same way (#469, #471). pnpm unpacks bundled copies too, but its lock cannot tie one to a reference (see **Unattested references** below). For Bun, that is a `bun.lock` entry whose meta is `{ "bundled": true }`, or a `bun.lockb` record that a dependency edge with the `bundled` behavior bit reaches, including one Bun shares with a regular install. Such an entry is never a reference, and it contests the reference the same way. For vlt, the lock records no node for a bundled copy, so the copy is found in the installed store: a real package directory inside a store package's own `node_modules`. yarn classic's `yarn.lock` records nothing about bundled copies either (#758), so for a lock with a Socket wiring they are found in the installed `node_modules`: each package a parent's `bundleDependencies` / `bundledDependencies` names inside the parent's own `node_modules`, the dependencies of those beside them, and everything nested under them. Such a copy contests the reference in `yarn.lock` (the diagnostic names the parent's lock entry and the copy's directory); a lock-only `vex` with no `node_modules` can't see it. Hosted and vendored scans skip these copies with `redirect_bun_bundled_instance_skipped` / `redirect_vlt_bundled_instance_skipped` / `vendor_bundled_instance_skipped`, and a yarn classic hosted scan names one with `redirect_copy_unpatched`. When a bundled copy is the only instance, vendoring refuses with `vendor_lock_entry_not_rewritable`. A reference withheld only because such an unreachable copy installs beside it (a bundled npm / Bun / vlt copy, an npm copy beneath a `hasShrinkwrap` package (#753) or one npm installs from a git / url / `file:` spec, or a yarn classic git or `file:` directory block of the same `name@version`) is still the rewriter's own wiring of exactly one package version: it is never attested, but `rollback`, `remove`, `list` and the hosted → vendored takeover treat it as an ordinary hosted pin and restore its upstream registry entry (#828), rather than refusing it with `hosted_wiring_contested`. Another entry of the **same** npm, Bun or yarn lock that resolves the wired `name@version` from a non-Socket source (for example a workspace member added after the rewire, then `npm install` / `bun install` / `yarn install`; for yarn berry, a registry locator such as `left-pad@npm:1.3.0` beside the hosted `…::__archiveUrl=` one) contests the reference too (#588). The package manager installs both entries, and that copy stays unpatched. Re-running `scan` / `vendor` rewires every copy. -* **Unattested references.** Some evidence shows a build may run a copy no wiring reaches, but cannot be tied to the reference's exact `name@version` or cannot say the build runs it. The reference then stays a reference: `list`, `rollback` and `remove` find it, and the ledgers' liveness gates (`vendor --check`, `scan`) keep treating the wiring as live, because re-running `scan` / `vendor` could never clear the evidence. Only `vex` omits it, as a run warning and as the `failed[].reason`. Ten cases besides Gradle's (`vex_gradle_lock_above_base`) and NuGet's (`vex_nuget_lock_other_version`, see the table above); one of them, **hosted sbt pins a build bypasses** (`vex_sbt_pin_bypassed`), is under **Hosted sbt** above: **pnpm bundled copies** (`vex_pnpm_bundled_copy`): a `packages:` entry's `bundledDependencies:` names the copies pnpm unpacks from that package's own tarball, but pnpm never locks them, so the bundled version is not in the lock. A pnpm reference whose package name a `bundledDependencies` list in the same lock names is omitted whatever its version (a missed attestation when the bundled copy is another version, never a false one), and `bundledDependencies: true` (or a value that cannot be read) omits every reference of that lock. It reaches no other lock. **deno.lock** (`vex_deno_lock_copy`): `deno install` installs a `package.json` project's npm dependencies from `deno.lock` and never reads `package-lock.json` / `pnpm-lock.yaml` / `yarn.lock`, so an npm-family reference whose `name@version` the `deno.lock` npm section also locks is omitted (#406). Whether Deno or another package manager populates `node_modules` (`nodeModulesDir: "manual"` allows either) is not in the files, so this holds whatever `nodeModulesDir` says. **Maven reactor roots** (`vex_maven_reactor_root`): a hosted pin in a root `pom.xml` that declares `` / ``, or whose subprojects Maven 4 discovers (see the hosted Maven rewriter), is omitted, because a module's own literal `` overrides a root pin and module poms are not read (#261); hosted scan refuses such roots (`redirect_maven_multimodule_unsupported`), so this only covers pins an older release wrote. **Maven classifier copies** (`vex_maven_classifier_unpatched`): a `` dependency of a hosted-pinned GA is its own coordinate; the classifier-less pin does not retarget it and the grant serves only the main jar, so Maven fetches it from the next repository. When a live, non-profile classifier copy other than `sources` / `javadoc` (`tests`, a native or JDK-specific build) resolves any version other than the suffixed pin — its own literal ``, a `` entry with the same classifier, or no version in the root pom — the pin is omitted. `sources` / `javadoc` copies never run on a classpath and do not withhold the statement; hosted scan only warns about them (`redirect_maven_classifier_unsupported`) and skips the dep when an executable copy names the patched release. **pnpm branch locks** (`vex_pnpm_git_branch_lockfile`): with `gitBranchLockfile` on, pnpm installs a git branch from its own `pnpm-lock..yaml` (the **pnpm per-branch locks** state above), which no wiring reaches. `vex` does not decide whether the setting is on: the presence of any branch lock (at the root, or in a workspace member that installs from its own lock under `sharedWorkspaceLockfile: false`, read from the project's files and never from the environment) omits every reference of the root pnpm lock (`pnpm-lock.yaml`, or `shrinkwrap.yaml` without it) and of each member's own lock, hosted and vendored alike, whatever `gitBranchLockfile` or pnpm's `lockfile` setting says (#556; deterministic and fail-closed). This is checked only when a root or member pnpm lock wires a patch. Hosted mode refuses every pnpm pin when the setting is on and vendored mode refuses the project, so this also covers wiring written before the branch lock appeared. The run warning names the branch lock in the marked lock's own directory. Rush locks are not affected. **Self-patched npm and Bun packages** (`vex_npm_patched_dependency`): a reference in `package-lock.json` / `npm-shrinkwrap.json` / `bun.lock` / `bun.lockb` to a package the project patches itself is omitted, by the hosted rewriters' own per-dependency rule (`redirect_npm_patched_dependency_skipped`, #711; `redirect_bun_patched_dependency_skipped`, #367): a root `package.json` `patchedDependencies` key for its `name@version`, its bare name, or `name@` of a tarball other than the one the reference pins (npm and Bun alike, as both hosted gates read it); for npm also a non-null `patched` record on any present npm lock's entry for that `name@version`; for `bun.lock` also the keys it mirrors (the run warning then names `bun.lock` as the key's source). npm applies the user's diff on top of whatever tarball the lock names and Bun applies it only to the resolution it is keyed on, so both modes leave such a package unwired; this covers pins an earlier release wrote, and an npm one fails `npm ci` with `EPATCHFAILED` until `socket-patch rollback ` restores the registry entry (the remedy says so). A key naming the pinned tarball itself is no conflict; a reference with neither a hosted url nor a vendored tarball is matched by its `name@version` and counts every tarball key. Only a reference that survives the lock contests carries this reason. **Go workspaces** (`go_workspace_override`): a hosted or vendored Go `replace` that the workspace go resolves (an ancestor's `go.work`, one `GOWORK` names, members outside the project included) overrides, ignores or rejects, by the same check hosted and vendored modes run before writing one (`redirect_golang_workspace_conflict`, `vendor_go_workspace_conflict`), is omitted under the reason `vex`'s own go.mod gate reports. So is a replace in the root `go.work` while go builds another one. **Unpinned Cargo declarations** (`vex_cargo_unpinned_declaration`): cargo resolves each source on its own, so when one declaration of a crate pins it to `socket-patch-` but another — the root `Cargo.toml`'s or a workspace member's, read as hosted scan reads them — is not pinned, with a version requirement that may select the locked patched version, that declaration builds a separate, unpatched copy, possibly of the very version the pin covers. Not pinned means crates.io or another registry that no Socket `[patch.]` registry entry redirects, a `registry-index`, a path or git — the last three even beside `registry = "socket-patch-"`, which they override (hosted scan's own pinned check). The `Cargo.lock` reference is omitted, naming the unpinned declaration's manifest. Hosted scan pins every declaration, so this is a partial revert or a hand edit; the warning says whether re-running `scan` pins it again (a crates.io declaration whose requirement matches no other locked version of the crate) or cannot (`redirect_cargo_toml_dep_unrewritable`: roll the patch back or change the declaration). A declaration whose requirement excludes the patched version (cargo resolves it to another version), and a `[workspace.dependencies]` entry no manifest inherits, do not withhold the statement. When the lock already holds a same-version copy from another source, the reference is withheld as an unpatched lock copy instead. A declaration pinned to ANOTHER Socket patch, which the lock does not resolve the crate from, adds that patch's `Cargo.toml` reference, so the package is gated as `wiring_conflict`. **Vendored Cargo copies a `[patch]` does not reach** (`vex_cargo_patch_bypassed`): a `[patch.]` entry replaces the crate only in declarations from ``, and `vendor` writes `[patch.crates-io]`. So a vendored reference is omitted when a live declaration of the crate (the root `Cargo.toml`'s or a workspace member's) names another registry, a `registry-index` or a git repository, with a version requirement that may select the patched version, unless a `[patch]` table for its source points at the same copy (`[patch.]` for a registry dependency, `[patch.""]` for a git one, compared as cargo canonicalizes it: one trailing `/` and a `.git` suffix ignored, a GitHub URL case-insensitive); cargo builds that declaration's own, unpatched copy. Workspace members are read for this whenever the root manifest or the project cargo config wires a Socket copy through `[patch]`, whatever the lock says. It is also omitted when `Cargo.lock` locks the crate at exactly the patched version from any source (a registry, crates.io included, or git) beside the vendored copy; the warning names that lock entry. A path declaration (the user's own fork) does not withhold the statement. A `registry-index` declaration is covered by a `[patch.""]` table for the same index (a `registry+` prefix, the host's case and a trailing `/` ignored; a `sparse+` index is another source than the git one), and one naming the crates.io git index is crates.io. Only `[patch]` entries cargo honors count: a manifest entry the project config or a crates.io URL table replaces covers nothing. The warning ends with the remedy for its evidence: for a declaration, re-running `vendor` does not help (declare the crate from crates.io there, point a `[patch]` table for its source at the copy, or roll the patch back); for a stale crates.io lock entry beside a `[patch.crates-io]` copy, re-lock (re-run `vendor`, or `cargo update -p `); for another lock entry, `cargo tree -i @` names the dependency that declares it. +* **Unattested references.** Some evidence shows a build may run a copy no wiring reaches, but cannot be tied to the reference's exact `name@version` or cannot say the build runs it. The reference then stays a reference: `list`, `rollback` and `remove` find it, and the ledgers' liveness gates (`vendor --check`, `scan`) keep treating the wiring as live, because re-running `scan` / `vendor` could never clear the evidence. Only `vex` omits it, as a run warning and as the `failed[].reason`. Ten cases besides Gradle's (`vex_gradle_lock_above_base`) and NuGet's (`vex_nuget_lock_other_version`, see the table above); one of them, **hosted sbt pins a build bypasses** (`vex_sbt_pin_bypassed`), is under **Hosted sbt** above: **pnpm bundled copies** (`vex_pnpm_bundled_copy`): a `packages:` entry's `bundledDependencies:` names the copies pnpm unpacks from that package's own tarball, but pnpm never locks them, so the bundled version is not in the lock. A pnpm reference whose package name a `bundledDependencies` list in the same lock names is omitted whatever its version (a missed attestation when the bundled copy is another version, never a false one), and `bundledDependencies: true` (or a value that cannot be read) omits every reference of that lock. It reaches no other lock. **deno.lock** (`vex_deno_lock_copy`): `deno install` installs a `package.json` project's npm dependencies from `deno.lock` and never reads `package-lock.json` / `pnpm-lock.yaml` / `yarn.lock`, so an npm-family reference whose `name@version` the `deno.lock` npm section also locks is omitted (#406). Whether Deno or another package manager populates `node_modules` (`nodeModulesDir: "manual"` allows either) is not in the files, so this holds whatever `nodeModulesDir` says. **Maven reactor roots** (`vex_maven_reactor_root`): a hosted pin in a root `pom.xml` that declares `` / ``, or whose subprojects Maven 4 discovers (see the hosted Maven rewriter), is omitted, because a module's own literal `` overrides a root pin and module poms are not read (#261); hosted scan refuses such roots (`redirect_maven_multimodule_unsupported`), so this only covers pins an older release wrote. **Maven classifier copies** (`vex_maven_classifier_unpatched`): a `` dependency of a hosted-pinned GA is its own coordinate; the classifier-less pin does not retarget it and the grant serves only the main jar, so Maven fetches it from the next repository. When a live, non-profile classifier copy other than `sources` / `javadoc` (`tests`, a native or JDK-specific build) resolves any version other than the suffixed pin — its own literal ``, a `` entry with the same classifier, or no version in the root pom — the pin is omitted. `sources` / `javadoc` copies never run on a classpath and do not withhold the statement; hosted scan only warns about them (`redirect_maven_classifier_unsupported`) and skips the dep when an executable copy names the patched release. **pnpm branch locks** (`vex_pnpm_git_branch_lockfile`): with `gitBranchLockfile` on, pnpm installs a git branch from its own `pnpm-lock..yaml` (the **pnpm per-branch locks** state above), which no wiring reaches. `vex` does not decide whether the setting is on: the presence of any branch lock (at the root, or in a workspace member that installs from its own lock under `sharedWorkspaceLockfile: false`, read from the project's files and never from the environment) omits every reference of the root pnpm lock (`pnpm-lock.yaml`, or `shrinkwrap.yaml` without it) and of each member's own lock, hosted and vendored alike, whatever `gitBranchLockfile` or pnpm's `lockfile` setting says (#556; deterministic and fail-closed). This is checked only when a root or member pnpm lock wires a patch. Hosted mode refuses every pnpm pin when the setting is on and vendored mode refuses the project, so this also covers wiring written before the branch lock appeared. The run warning names the branch lock in the marked lock's own directory. Rush locks are not affected. **Self-patched npm and Bun packages** (`vex_npm_patched_dependency`): a reference in `package-lock.json` / `npm-shrinkwrap.json` / `bun.lock` / `bun.lockb` to a package the project patches itself is omitted, by the hosted rewriters' own per-dependency rule (`redirect_npm_patched_dependency_skipped`, #711; `redirect_bun_patched_dependency_skipped`, #367): a root `package.json` `patchedDependencies` key for its `name@version`, its bare name, or `name@` of a tarball other than the one the reference pins (npm and Bun alike, as both hosted gates read it); for npm also a non-null `patched` record on any present npm lock's entry for that `name@version`; for `bun.lock` also the keys it mirrors (the run warning then names `bun.lock` as the key's source). npm applies the user's diff on top of whatever tarball the lock names and Bun applies it only to the resolution it is keyed on, so both modes leave such a package unwired; this covers pins an earlier release wrote, and an npm one fails `npm ci` with `EPATCHFAILED` until `socket-patch rollback ` restores the registry entry (the remedy says so). A key naming the pinned tarball itself is no conflict; a reference with neither a hosted url nor a vendored tarball is matched by its `name@version` and counts every tarball key. Only a reference that survives the lock contests carries this reason. **Go workspaces** (`go_workspace_override`): a hosted or vendored Go `replace` that the workspace go resolves (an ancestor's `go.work`, one `GOWORK` names, members outside the project included) overrides, ignores or rejects, by the same check hosted and vendored modes run before writing one (`redirect_golang_workspace_conflict`, `vendor_go_workspace_conflict`), is omitted under the reason `vex`'s own go.mod gate reports. So is a replace in the root `go.work` while go builds another one. **Unpinned Cargo declarations** (`vex_cargo_unpinned_declaration`): cargo resolves each source on its own, so when one declaration of a crate pins it to `socket-patch-` but another — the root `Cargo.toml`'s or a workspace member's, read as hosted scan reads them — is not pinned, with a version requirement that may select the locked patched version, that declaration builds a separate, unpatched copy, possibly of the very version the pin covers. Not pinned means crates.io or another registry that no Socket `[patch.]` registry entry redirects, a `registry-index`, a path or git — the last three even beside `registry = "socket-patch-"`, which they override (hosted scan's own pinned check). The `Cargo.lock` reference is omitted, naming the unpinned declaration's manifest. Hosted scan pins every declaration, so this is a partial revert or a hand edit; the warning says whether re-running `scan` pins it again (a crates.io declaration whose requirement matches no other locked version of the crate) or cannot (`redirect_cargo_toml_dep_unrewritable`: roll the patch back or change the declaration). A declaration whose requirement excludes the patched version (cargo resolves it to another version), and a `[workspace.dependencies]` entry no manifest inherits, do not withhold the statement. When the lock already holds a same-version copy from another source, the reference is withheld as an unpatched lock copy instead. A declaration pinned to ANOTHER Socket patch, which the lock does not resolve the crate from, adds that patch's `Cargo.toml` reference, so the package is gated as `wiring_conflict`. **Vendored Cargo copies a `[patch]` does not reach** (`vex_cargo_patch_bypassed`): a `[patch.]` entry replaces the crate only in declarations from ``, and `vendor` writes `[patch.crates-io]`. So a vendored reference is omitted when a live declaration of the crate (the root `Cargo.toml`'s or a workspace member's) names another registry, a `registry-index` or a git repository, with a version requirement that may select the patched version, unless a `[patch]` table for its source points at the same copy (`[patch.]` for a registry dependency, `[patch.""]` for a `registry-index` one, `[patch.""]` for a git one; URLs compare as cargo canonicalizes them: the host's case, a lock's `registry+` prefix, one trailing `/` and a `.git` suffix ignored, a GitHub URL case-insensitive, a `sparse+` index another source than the git one, and the crates.io git index is crates.io); cargo builds that declaration's own, unpatched copy. Workspace members are read for this whenever the root manifest or the project cargo config wires a Socket copy through `[patch]`, whatever the lock says. It is also omitted when `Cargo.lock` locks the crate at exactly the patched version from any source (a registry, crates.io included, or git) beside the vendored copy; the warning names that lock entry. A path declaration (the user's own fork) does not withhold the statement. Only `[patch]` entries cargo honors count: a manifest entry the project config or a crates.io URL table replaces covers nothing. The warning ends with the remedy for its evidence: for a declaration, re-running `vendor` does not help (declare the crate from crates.io there, point a `[patch]` table for its source at the copy, or roll the patch back); for a stale lock entry a `[patch]` table at the copy redirects (crates.io under `[patch.crates-io]`, or a git repository under its own table), re-lock (`cargo update -p `, or re-run `vendor` for crates.io); for another lock entry, `cargo tree -i @` names the dependency that declares it. * **Shrinkwrap without a twin (#899).** npm >= 12 never reads `npm-shrinkwrap.json`: on a project whose only npm lock is the shrinkwrap it resolves the tree from the registry and writes a fresh `package-lock.json`, so a Socket reference there reaches npm <= 11 only. The reference is still discovered, so `list`, `rollback` and `remove` manage it, but `vex` omits the patch (`vex_npm_shrinkwrap_only`, as a run warning and as the `failed[].reason`). A `package-lock.json` beside it (wired the same way) makes it attestable again. Hosted and vendored runs still rewire the shrinkwrap and warn `redirect_npm_shrinkwrap_only` / `vendor_npm_shrinkwrap_only`. -* **Lockless pins.** With no lock to name a version, a `Cargo.toml` pin (every declaration on `socket-patch-`, that registry defined on the patch host for the same uuid) or an exclusive nuget exact-id mapping is never a reference on its own, so v5.0 does not attest it (nor does `list` show it, or `rollback` / `remove` restore it — restore those files from version control). Only a pre-v5 redirect-ledger record naming a version the pin admits keeps it live. For Cargo, declarations are read as for a locked pin: a `socket-patch-` registry beside `path`, `git` or `registry-index` is no pin (cargo builds that source), so it admits nothing and its requirement (any version, without one) is excluded from the pin; a `[workspace.dependencies]` template no manifest inherits is no declaration at all. The same holds for a gem wired only in the `Gemfile` (the pre-bundler-2.6 mixed state, lock not converged). +* **Lockless pins.** With no lock to name a version, a `Cargo.toml` pin (every declaration on `socket-patch-`, that registry defined on the patch host for the same uuid) or an exclusive nuget exact-id mapping is never a reference on its own, so v5.0 does not attest it (nor does `list` show it, or `rollback` / `remove` restore it — restore those files from version control). Only a pre-v5 redirect-ledger record naming a version the pin admits keeps it live. For Cargo, declarations are read as for a locked pin (**Unpinned Cargo declarations**): one that is no pin admits nothing, and its requirement (any version, without one) is excluded from the pin; a `[workspace.dependencies]` template no manifest inherits is no declaration at all. The same holds for a gem wired only in the `Gemfile` (the pre-bundler-2.6 mixed state, lock not converged). **Record resolution.** A candidate's record must carry the patch uuid the lockfile actually **wires**. It is taken from the first source that has one: the manifest (matched qualifier-insensitively), the hosted records above (this run's, then a pre-v5 ledger's), then the vendor ledger's embedded records. If none has it and the run is online, `vex` fetches the patch view by uuid from the patch API — for a v5 hosted checkout this is the normal path. The fetch uses `get`'s API client: the public proxy when no token is configured, and a one-shot 401/403 fallback to the proxy (free patches only). At most 10 fetches run concurrently. Fetched records stay in memory: `vex` never writes the manifest. A candidate still has no record under `--offline`, after a transport error or a 404, or when the patch is refused (paid without an entitled token); it is then omitted as `record_unavailable`, and the run is not aborted. A record whose uuid or package disagrees with the wiring is omitted as `record_mismatch`. The informational `socket-patch.vendor.json` marker is never a record source. When the lockfile wires a package to patch U, a manifest or ledger record for that package under another uuid is superseded, and a human-mode `Note:` says so. diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/cargo.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/cargo.rs index 223b64b3e..09436e756 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/cargo.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/cargo.rs @@ -913,14 +913,9 @@ fn cargo_hosted_f_spoofed_references_never_attest() { let run = fx.vex(&["--proxy-url", &api.uri()]); assert_nothing_discovered(&run, "foreign host"); assert_eq!(api.hits(), 0, "a foreign-host uuid is never even fetched"); - // FAIL CLOSED: a redirect-ledger record whose recorded wiring routes - // the uuid through a hosted-shaped url on an origin this run was not - // given (here the `Cargo.lock` `sparse+` source and the - // `.cargo/config.toml` index) is never attested — whatever is - // installed, with or without `--no-verify` — and is reported as - // `redirect_unwired` (core `RecordLiveness::Undecided`; CLI_CONTRACT.md, - // "Patch hosts (manifest-less VEX)"). A staging patch server needs - // `--patch-server-url`, below. + // Fail closed: a ledger record wired through a hosted-shaped url on an + // unconfigured origin is `redirect_unwired` whatever is installed, even + // under `--no-verify` (CLI_CONTRACT.md, "Patch hosts (manifest-less VEX)"). write_redirect_ledger(&fx, CARGO_PURL, cargo_record(U), CARGO_HOSTED_EDITS); for (state, install) in [ ("not installed", None), diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/maven.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/maven.rs index 9d04b11f0..c7128d6ae 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/maven.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/maven.rs @@ -776,14 +776,9 @@ fn maven_hosted_spoofed_repositories_never_attest() { } continue; } - // FAIL CLOSED: a NON-allowlisted host is how a staging patch server - // run without `--patch-server-url` looks, and how a look-alike - // looks. A ledger whose pom.xml still pins `-socket.` under a - // `socket-patch-` repository on such a host is never attested - // — not with nothing installed, not with the served jar in place, - // not under `--no-verify` — and is reported as `redirect_unwired` - // (core `RecordLiveness::Undecided`; CLI_CONTRACT.md, "Patch hosts - // (manifest-less VEX)"). + // Fail closed: a ledger record wired through a hosted-shaped url on an + // unconfigured origin is `redirect_unwired` whatever is installed, even + // under `--no-verify` (CLI_CONTRACT.md, "Patch hosts (manifest-less VEX)"). let sfx_dir = fx.m2().join(format!("org/slf4j/slf4j-api/{MVN_SUFFIXED}")); for installed in [false, true] { if installed { diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/yarn.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/yarn.rs index d56820094..9e6ce4db7 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/yarn.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/yarn.rs @@ -1045,16 +1045,9 @@ fn uuid_on_a_foreign_host_is_not_a_hosted_reference() { assert_eq!(env["error"]["code"], "manifest_not_found", "{cell}: {env}"); assert_eq!(request_count(&rt, &server), 0, "{cell}: network call"); - // FAIL CLOSED (`Discovery::redirect_record_verdict`, - // `RecordLiveness::Undecided`): a pre-v5 redirect-ledger record - // whose lock entry resolves from a hosted-shaped url on an - // origin this run was not given (a staging patch server used - // without `--patch-server-url`, or a look-alike host) is never - // attested — not even by an installed tree that hashes to the - // record, not even under `--no-verify` — and is reported as - // `redirect_unwired` (CLI_CONTRACT.md, "Patch hosts - // (manifest-less VEX)"). Pass `--patch-server-url` for a - // non-production patch server. + // Fail closed: a ledger record wired through a hosted-shaped url on an + // unconfigured origin is `redirect_unwired` whatever is installed, even + // under `--no-verify` (CLI_CONTRACT.md, "Patch hosts (manifest-less VEX)"). write_redirect_ledger(cwd, flavor, record(UUID)); for (state, bytes) in [ ("not installed", None), diff --git a/crates/socket-patch-core/src/origin.rs b/crates/socket-patch-core/src/origin.rs index a404ece26..2635edf84 100644 --- a/crates/socket-patch-core/src/origin.rs +++ b/crates/socket-patch-core/src/origin.rs @@ -78,7 +78,7 @@ pub fn hosted_patch_uuid(url: &str, extra_origins: &[String]) -> Option /// unescaped, a percent-encoded `http(s)://` url decoded, and a Cargo /// `sparse+` / `registry+` source prefix stripped. `None` without a /// `://`. -pub(crate) fn normalized_hosted_url(url: &str) -> Option { +fn normalized_hosted_url(url: &str) -> Option { use crate::utils::purl::percent_decode_purl_component; let unescaped = url.trim().replace("\\/", "/"); @@ -98,6 +98,30 @@ pub(crate) fn normalized_hosted_url(url: &str) -> Option { }) } +/// The patch uuid `url` names when read as a hosted url of its OWN origin +/// ([`hosted_patch_uuid`]'s grammar with the url's origin taken as +/// configured): what a lock entry on an origin a run was not given claims +/// to resolve from. Only a url in a Socket patch server's path shape (a +/// `patch`, `patch-registry` or `gopatch` segment) counts: any other url +/// merely ending in the uuid (an artifact store's blob path) resolves from +/// elsewhere. Userinfo is dropped first: Socket never writes one, so +/// `https://patch.socket.dev@evil.example/patch/…` is a look-alike on the +/// host after the `@`, never an accepted origin. +pub(crate) fn hosted_shape_uuid(url: &str) -> Option { + let normalized = normalized_hosted_url(url)?; + let mut parsed = reqwest::Url::parse(&normalized).ok()?; + if !parsed + .path_segments()? + .any(|s| matches!(s, "patch" | "patch-registry" | "gopatch")) + { + return None; + } + let _ = parsed.set_username(""); + let _ = parsed.set_password(None); + let origin = parsed.origin().ascii_serialization(); + hosted_patch_uuid(parsed.as_str(), &[origin]) +} + /// EVERY canonical-uuid path segment of a Socket-HOSTED url, in path order /// (the grant token first when it is uuid-shaped, the patch uuid last), or /// `None` when `url` is not on an accepted origin — [`hosted_patch_uuid`]'s diff --git a/crates/socket-patch-core/src/vex/discover/cargo.rs b/crates/socket-patch-core/src/vex/discover/cargo.rs index 564be0ec4..be574e805 100644 --- a/crates/socket-patch-core/src/vex/discover/cargo.rs +++ b/crates/socket-patch-core/src/vex/discover/cargo.rs @@ -41,22 +41,12 @@ //! * not declared in any of them (a transitive-only crate; no / unparseable //! root manifest) → ref; //! * some declaration names `socket-patch-` (the same uuid) → ref; -//! the other declarations whose requirement may select the locked -//! version then decide whether it is attested. One routed to ANOTHER -//! Socket patch (that the lock does not resolve the crate from) adds a -//! `Cargo.toml` ref for it — the conflict pair below. One cargo builds -//! its own copy for — crates.io or another registry not redirected by a -//! Socket `[patch.]` entry, a `registry-index`, a path or git -//! (even beside a `socket-patch-` registry, which those override: -//! the hosted rewriter's own pinned check) — marks the ref -//! [`super::UnattestedKind::CargoUnpinnedDeclaration`], naming its file -//! and whether a re-scan can pin it: cargo resolves each source on its -//! own, so that copy is unpatched and may be this very version. The -//! hosted rewriter pins every declaration, so this is a partial revert or -//! a hand edit. A `[workspace.dependencies]` template counts only when a -//! manifest inherits it. When the lock already holds a same-version copy -//! from another source, the twin rule (`unpatched_twins`, #679) withholds -//! the ref instead; +//! the other declarations that may select the locked version then decide +//! whether it is attested ([`beside_the_pin`]: one pinned to ANOTHER +//! patch adds its conflict ref, one cargo builds its own copy for marks +//! the ref [`super::UnattestedKind::CargoUnpinnedDeclaration`]). A +//! `[workspace.dependencies]` template counts only when a manifest +//! inherits it; //! * declarations pin only OTHER `socket-patch-` registries → the lock //! ref AND a `Cargo.toml` ref for `` at the locked version (no pin): //! the files disagree and the CLI gates the package as `wiring_conflict`; @@ -126,14 +116,10 @@ //! config), and a `[patch.""]` table in the manifest //! replaces `[patch.crates-io]` wholesale — both leave the lock looking //! exactly like live Socket wiring. -//! A `[patch.]` entry replaces the crate only in declarations from -//! ``, so a ref whose build may also compile a copy of the version -//! the wiring does not reach — a live declaration (root or member) from -//! another registry, a `registry-index` or git that may select the version -//! and that no `[patch]` table pointing at this copy redirects, or a -//! same-version lock entry from any source — stays a ref but is marked -//! [`super::UnattestedKind::CargoPatchBypassed`] ([`patch_bypassed`]); the -//! members are read for this whenever a `[patch]` wires a Socket copy. +//! A ref whose build may also compile a copy of the version the wiring does +//! not reach (a `[patch.]` entry replaces the crate only in +//! declarations from ``) stays a ref but is marked +//! [`super::UnattestedKind::CargoPatchBypassed`] ([`patch_bypassed`]). //! A `.socket/cargo-patches/` path (the retired redirect backend) carries no //! uuid and is not a ref. Socket-shaped entries in a `.cargo/config.toml` //! that cargo ignores (because `.cargo/config` exists) are diagnosed, never @@ -143,6 +129,8 @@ use std::collections::{BTreeMap, BTreeSet}; use toml_edit::{DocumentMut, Item, TableLike}; +use crate::patch::redirect::is_crates_io_source as is_crates_io_lock_source; + use super::{ names_vendor_dir, simple_purl, socket_patch_name_uuid, toml_or_diag, vendor_ref, DiscoverCtx, Discovery, PatchedRef, TomlDiag, UnattestedKind, UnlockedPin, VendorRef, DIAG_REF_INVALID, @@ -231,11 +219,8 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { decls.entry(name).or_default().push(decl); } } - decls - }); - // The config's `[patch]` registry pins are also declarations cargo - // honors (only matters for crates the manifests also declare). - let decls = decls.map(|mut decls| { + // The config's `[patch]` registry pins are also declarations cargo + // honors (only matters for crates the manifests also declare). if let Some((file, doc)) = &config { for patch in patch_entries(doc) { if let (Some(reg), Some(existing)) = (patch.registry, decls.get_mut(patch.name)) { @@ -269,10 +254,10 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { wired: &wired, }; if let Some(doc) = manifest { - vendored_from_patches(ctx, CARGO_TOML, doc, &lock, shadow.as_ref(), &bypass, out).await; + vendored_from_patches(ctx, CARGO_TOML, doc, shadow.as_ref(), &bypass, out).await; } if let Some((file, doc)) = &config { - vendored_from_patches(ctx, file, doc, &lock, None, &bypass, out).await; + vendored_from_patches(ctx, file, doc, None, &bypass, out).await; } let copies: Vec = wired_all.into_iter().map(|(w, _)| w.vref).collect(); unattributed_tags(&lock, &copies, out); @@ -465,6 +450,38 @@ enum DeclSource { Git(String), } +impl DeclSource { + /// [`Decl::patched_by`] for a declaration from this source with `registry` + /// (`None` = no registry key). + fn patched_by(&self, registry: Option<&str>, table: &str) -> bool { + match self { + DeclSource::Registry => match registry.map(str::trim) { + None | Some("crates-io") => is_crates_io_source(table), + Some(reg) => reg == table, + }, + DeclSource::RegistryIndex(url) => { + (is_crates_io_url_alias(url) && is_crates_io_source(table)) + || patch_source_key(url) == patch_source_key(table) + } + DeclSource::Git(url) => git_key(url).is_some_and(|k| git_key(table) == Some(k)), + DeclSource::Path | DeclSource::Patch(_) => false, + } + } + + /// The source a `Cargo.lock` `source` names, as far as a `[patch]` table + /// can redirect it: crates.io, or a git repository (its `?query` / `#rev` + /// cut). `None` for anything else. + fn of_lock_source(source: &str) -> Option { + if is_crates_io_lock_source(source) { + return Some(DeclSource::Registry); + } + let repo = source.trim().strip_prefix("git+")?; + Some(DeclSource::Git( + repo.split(['?', '#']).next().unwrap_or(repo).to_string(), + )) + } +} + /// One declaration of a crate: the root-relative file holding it, its /// `registry` value (`None` = no registry key), its version requirement /// and where cargo takes it from. @@ -533,24 +550,13 @@ impl Decl { /// Any other difference reads as another source: at worst a missed /// redirect, so a withheld statement, never a false one. fn patched_by(&self, table: &str) -> bool { - match &self.source { - DeclSource::Registry => match self.registry.as_deref().map(str::trim) { - None | Some("crates-io") => is_crates_io_source(table), - Some(reg) => reg == table, - }, - DeclSource::RegistryIndex(url) => { - (is_crates_io_url_alias(url) && is_crates_io_source(table)) - || patch_source_key(url) == patch_source_key(table) - } - DeclSource::Git(url) => git_key(url).is_some_and(|k| git_key(table) == Some(k)), - DeclSource::Path | DeclSource::Patch(_) => false, - } + self.source.patched_by(self.registry.as_deref(), table) } /// "`` declares `` `` from ``", for /// messages. A `path` / `git` / `registry-index` overrides a Socket - /// registry named beside it: that is said too, or the message reads as - /// if the registry were missing (review on #2019). + /// registry named beside it: that is said too, or the message reads as if + /// the registry were missing. fn describe(&self, name: &str) -> String { let req = self .version @@ -620,21 +626,6 @@ fn git_key(url: &str) -> Option { Some(url) } -/// A registry index URL for comparison: a `registry+` prefix (the lock's -/// spelling of a git index) dropped, a `sparse+` one kept (a sparse index -/// is another cargo source than the git index at the same URL), the host -/// lowercased and a trailing `/` dropped. `None` when empty. -fn index_key(url: &str) -> Option { - let url = url.trim(); - let url = url.strip_prefix("registry+").unwrap_or(url); - let (sparse, url) = match url.strip_prefix("sparse+") { - Some(rest) => ("sparse+", rest), - None => ("", url), - }; - let url = lower_host(url)?; - Some(format!("{sparse}{}", url.trim_end_matches('/'))) -} - /// The source of a lock entry of `name` at exactly `version` that is not a /// Socket patch server's: a copy cargo builds from crates.io, another /// registry or git beside the patched one. @@ -690,10 +681,10 @@ fn manifest_declarations(manifests: &[(String, DocumentMut)]) -> Decls { decls } -/// The distinct present values, in first-seen order. -fn distinct(values: impl IntoIterator>) -> Vec { - let mut out: Vec = Vec::new(); - for value in values.into_iter().flatten() { +/// The distinct values, in first-seen order. +fn distinct(values: impl IntoIterator) -> Vec { + let mut out: Vec = Vec::new(); + for value in values { if !out.contains(&value) { out.push(value); } @@ -703,13 +694,16 @@ fn distinct(values: impl IntoIterator>) -> Vec { /// The distinct files among `decls`, in order, joined for a message. fn decl_files<'a>(decls: impl IntoIterator) -> String { - let mut files: Vec<&str> = Vec::new(); - for d in decls { - if !files.contains(&d.file.as_str()) { - files.push(&d.file); - } - } - files.join(", ") + distinct(decls.into_iter().map(|d| d.file.as_str())).join(", ") +} + +/// Each of `decls` described ([`Decl::describe`]), joined for a message. +fn describe_all<'a>(decls: impl IntoIterator, name: &str) -> String { + decls + .into_iter() + .map(|d| d.describe(name)) + .collect::>() + .join(", and ") } /// One manifest dependency entry (see [`manifest_declarations`]). @@ -822,6 +816,10 @@ fn hosted_from_lock( definitions: &BTreeMap, out: &mut Discovery, ) { + let manifests = ManifestTexts { + docs: manifests, + texts: std::cell::OnceCell::new(), + }; for pkg in lock.packages() { let Some(source) = pkg.source.as_deref() else { continue; @@ -883,7 +881,7 @@ fn hosted_from_lock( .any(|d| d.registry.as_deref() == Some(reg.as_str())) { out.push(lock_ref); - beside_the_pin(ctx, lock, manifests, pkg, &purl, &uuid, regs, out); + beside_the_pin(ctx, lock, &manifests, pkg, &purl, &uuid, regs, out); continue; } // Pins to another Socket patch that may select this version; one @@ -935,6 +933,24 @@ fn hosted_from_lock( } } +/// The root and member manifests, and their texts as the hosted rewriter's +/// planner reads them, serialized once on first need. +struct ManifestTexts<'a> { + docs: &'a [(String, DocumentMut)], + texts: std::cell::OnceCell>, +} + +impl ManifestTexts<'_> { + fn texts(&self) -> &[(String, String)] { + self.texts.get_or_init(|| { + self.docs + .iter() + .map(|(file, doc)| (file.clone(), doc.to_string())) + .collect() + }) + } +} + /// What one declaration of a crate means for its Socket-locked /// `name@version` ([`route`]). enum Route { @@ -999,7 +1015,7 @@ fn route(d: &Decl, patch_pins: &[(&str, String)], version: &str, others: &[Strin fn beside_the_pin( ctx: &DiscoverCtx<'_>, lock: &Lock, - manifests: &[(String, DocumentMut)], + manifests: &ManifestTexts<'_>, pkg: &LockedPackage, purl: &str, uuid: &str, @@ -1013,8 +1029,12 @@ fn beside_the_pin( let others = distinct( same_name() .filter(|p| p.version != pkg.version) - .map(|p| Some(p.version.clone())), + .map(|p| p.version.clone()), ); + // The patches the lock resolves the crate from. + let locked_from: Vec = same_name() + .filter_map(|p| p.source.as_deref().and_then(|s| source_uuid(ctx, s))) + .collect(); let patch_pins: Vec<(&str, String)> = regs .iter() .filter_map(|d| match &d.source { @@ -1028,15 +1048,7 @@ fn beside_the_pin( // A patch the lock resolves the crate from at all is its own // ref (that registry serves only its own version, a separate, // patched package): no conflict. - Route::Pin(other) - if other != uuid - && !same_name().any(|p| { - p.source - .as_deref() - .and_then(|s| source_uuid(ctx, s)) - .is_some_and(|u| u == other) - }) => - { + Route::Pin(other) if other != uuid && !locked_from.contains(&other) => { out.push(PatchedRef::hosted( purl.to_string(), other, @@ -1053,11 +1065,7 @@ fn beside_the_pin( let Some((first, _)) = unpinned.first() else { return; }; - let declared = unpinned - .iter() - .map(|(d, _)| d.describe(&pkg.name)) - .collect::>() - .join(", and "); + let declared = describe_all(unpinned.iter().map(|(d, _)| *d), &pkg.name); let (that, each) = if unpinned.len() == 1 { ("that declaration", "the crate declaring it") } else { @@ -1097,18 +1105,14 @@ fn beside_the_pin( Some(format!("{}'s declaration ({why})", d.file)) }) .collect(); - // Every declaration may pass on its own and the rewriter still refuse - // the dependency over another one (the pin's own requirement matching - // another locked version, a `path` template nobody inherits): it skips - // the whole dependency on the first refusal, so ask its planner, dry, - // over the same manifests (review on #2019). + // Every declaration may pass on its own and the rewriter still refuse the + // dependency over another one (the pin's own requirement matching another + // locked version, a `path` template nobody inherits): it skips the whole + // dependency on the first refusal, so ask its planner, dry, over the same + // manifests. let planner_refusal = || { - let texts: Vec<(String, String)> = manifests - .iter() - .map(|(file, doc)| (file.clone(), doc.to_string())) - .collect(); crate::patch::redirect::plan_cargo_tomls( - &texts, + manifests.texts(), &pkg.name, &pkg.version, &others, @@ -1224,26 +1228,15 @@ fn unresolved_manifest_pins( out: &mut Discovery, ) { for (name, regs) in decls { - let mut malformed: Vec<(&str, &str)> = Vec::new(); - let mut uuids: Vec = Vec::new(); - for decl in regs.iter().filter(|d| d.socket_shaped()) { - let reg = decl.registry.as_deref().unwrap_or_default(); - if decl.socket_uuid().is_none() && !malformed.contains(&(decl.file.as_str(), reg)) { - malformed.push((decl.file.as_str(), reg)); - } - } - // Classified like a locked pin ([`route`]): only a live registry - // dependency or `[patch]` entry naming `socket-patch-` pins - // (beside `path` / `git` / `registry-index` the registry key does - // not decide, as in the rewriter's own pinned check), and a - // `[workspace.dependencies]` template nobody inherits declares - // nothing. + let malformed = distinct( + regs.iter() + .filter(|d| d.socket_shaped() && d.socket_uuid().is_none()) + .map(|d| (d.file.as_str(), d.registry.as_deref().unwrap_or_default())), + ); + // Classified like a locked pin ([`route`]): only a live [`Decl::pin`] + // pins. let live: Vec<&Decl> = regs.iter().filter(|d| d.live).collect(); - for uuid in live.iter().filter_map(|d| d.pin()) { - if !uuids.contains(&uuid) { - uuids.push(uuid); - } - } + let uuids = distinct(live.iter().filter_map(|d| d.pin())); for (file, reg) in malformed { out.diag( DIAG_REF_INVALID, @@ -1256,26 +1249,25 @@ fn unresolved_manifest_pins( } // A well-formed Socket registry that pins nothing: say so, since the // file reads like wiring. - let mut ineffective: Vec<(&str, String)> = Vec::new(); - for d in regs - .iter() - .filter(|d| d.socket_uuid().is_some() && (d.pin().is_none() || !d.live)) - { - let why = if d.live { - format!( - "cargo builds it from {}, whatever `registry` says", - d.origin() - ) - } else if matches!(d.source, DeclSource::Patch(_)) { - "cargo ignores that `[patch]` entry (a crates.io URL table or the project config replaces it)" - .to_string() - } else { - "no manifest inherits that `[workspace.dependencies]` entry".to_string() - }; - if !ineffective.contains(&(d.file.as_str(), why.clone())) { - ineffective.push((d.file.as_str(), why)); - } - } + let ineffective = distinct( + regs.iter() + .filter(|d| d.socket_uuid().is_some() && (d.pin().is_none() || !d.live)) + .map(|d| { + let why = if d.live { + format!( + "cargo builds it from {}, whatever `registry` says", + d.origin() + ) + } else if matches!(d.source, DeclSource::Patch(_)) { + "cargo ignores that `[patch]` entry (a crates.io URL table or the project \ + config replaces it)" + .to_string() + } else { + "no manifest inherits that `[workspace.dependencies]` entry".to_string() + }; + (d.file.as_str(), why) + }), + ); for (file, why) in ineffective { out.diag( DIAG_REF_INVALID, @@ -1296,7 +1288,7 @@ fn unresolved_manifest_pins( .collect(); // Whether the lock also resolves the crate from a non-Socket source // (or a path): then "only from other Socket patches" is not what it - // says (review on #2024). + // says. let locked_elsewhere = lock.packages().iter().any(|p| { p.name == *name && p.source @@ -1336,7 +1328,7 @@ fn unresolved_manifest_pins( .get(reg) .and_then(|index| source_uuid(ctx, index)) .is_some_and(|u| u == *uuid); - let version_reqs = distinct(pinning.iter().map(|d| d.version.clone())); + let version_reqs = distinct(pinning.iter().filter_map(|d| d.version.clone())); // Every live dependency declaration this patch does // not pin (a `path` / `git` / `registry-index` one // beside a `socket-patch-` registry included); `None` @@ -1353,7 +1345,7 @@ fn unresolved_manifest_pins( version_reqs.is_empty(), excluded_reqs, ) { - let excluded_reqs = distinct(excluded_reqs.into_iter().map(Some)); + let excluded_reqs = distinct(excluded_reqs); out.unlocked_pin(UnlockedPin { ecosystem: "cargo".to_string(), name: name.clone(), @@ -1481,8 +1473,10 @@ type ConfigPatchItem = (&'static str, String, String, Option, Option String { let t = source.trim(); - let key = if t.starts_with("sparse+") { - index_key(t) + // A sparse index keeps its `sparse+` (another cargo source than the git + // index at the same URL); only the host case and a trailing `/` differ. + let key = if let Some(rest) = t.strip_prefix("sparse+") { + lower_host(rest).map(|url| format!("sparse+{}", url.trim_end_matches('/'))) } else if t.contains("://") { git_key(t.strip_prefix("registry+").unwrap_or(t)) } else { @@ -1556,8 +1550,8 @@ impl Shadow { )); } // Only identical wiring is the same: the same path, or the same - // registry for path-less items (review on #2024). Two path-less - // items otherwise (a registry pin and a git fork, say) are not. + // registry for path-less items. Two path-less items otherwise (a + // registry pin and a git fork, say) are not. let own = path.map(same_path_key); let own_reg = registry.map(str::trim); let same_wiring = |p: &Option, r: &Option| match (&own, own_reg) { @@ -1633,7 +1627,7 @@ fn patch_bypassed( .packages() .iter() .filter(|p| p.name == name && cargo_tag::strip_tag(&p.version) != version) - .map(|p| Some(cargo_tag::strip_tag(&p.version).to_string())), + .map(|p| cargo_tag::strip_tag(&p.version).to_string()), ); let mut declared: Vec<&Decl> = Vec::new(); let mut pinned_elsewhere: Vec<(&Decl, String)> = Vec::new(); @@ -1644,15 +1638,14 @@ fn patch_bypassed( _ => {} } } - // A declaration pinned to ANOTHER Socket patch builds that patch's - // copy, not this one. Only a ref for that patch at this very package, - // emitted by the hosted pass (from the lock, or a conflict pair beside - // a lock pin) and not itself withheld, puts the pair before the CLI's - // conflict gate; otherwise (a stale lock, a lock line whose config - // index disagrees, another locked version) nothing else says so. - // "Withheld" here is exactly the CLI's `bypassed_ref`: a file-scoped - // mark on the ref's own file (review on #2024); any other mark leaves - // the ref in the conflict gate. + // A declaration pinned to ANOTHER Socket patch builds that patch's copy, + // not this one. Only a ref for that patch at this very package, emitted by + // the hosted pass (from the lock, or a conflict pair beside a lock pin) + // and not itself withheld, puts the pair before the CLI's conflict gate; + // otherwise (a stale lock, a lock line whose config index disagrees, + // another locked version) nothing else says so. "Withheld" here is exactly + // the CLI's `bypassed_ref`: a file-scoped mark on the ref's own file; any + // other mark leaves the ref in the conflict gate. let gated = |uuid: &str| { out.refs.iter().any(|r| { r.uuid == uuid @@ -1669,19 +1662,16 @@ fn patch_bypassed( .into_iter() .filter(|(_, uuid)| !gated(uuid)) .collect(); - // Named in every detail, so one `vex` run shows each cause (review on - // #2024). + let other_declared = other_patch + .iter() + .map(|(d, uuid)| format!("{} (Socket patch {uuid})", d.describe(name))) + .collect::>() + .join(", and "); + // Named in every detail, so one `vex` run shows each cause. let also_other = if other_patch.is_empty() { String::new() } else { - format!( - ". Also: {}, which no wiring of that patch pairs with this copy", - other_patch - .iter() - .map(|(d, uuid)| format!("{} (Socket patch {uuid})", d.describe(name))) - .collect::>() - .join(", and ") - ) + format!(". Also: {other_declared}, which no wiring of that patch pairs with this copy") }; let locked = same_version_foreign_entry(ctx, bypass.lock, name, version); let redirects = format!( @@ -1693,11 +1683,7 @@ fn patch_bypassed( let evidence = locked.map_or_else(String::new, |source| { format!("; {CARGO_LOCK} also locks {name} {version} from {source}") }); - let declared = declared - .iter() - .map(|d| d.describe(name)) - .collect::>() - .join(", and "); + let declared = describe_all(declared.iter().copied(), name); ( first.file.as_str(), format!( @@ -1711,23 +1697,32 @@ fn patch_bypassed( ) } (None, Some(source)) => { - // A crates.io entry beside a copy `[patch.crates-io]` wires is - // stale (cargo would redirect it); any other is declared by - // some dependency, or left over from one that was removed. - // Likewise a git entry a `[patch.""]` table - // pointing at the copy redirects (review on #2024). - let stale = (crate::patch::redirect::is_crates_io_source(source) - && pins.iter().any(|(table, _)| is_crates_io_source(table))) - || source.trim().strip_prefix("git+").is_some_and(|repo| { - let repo = repo.split(['?', '#']).next().unwrap_or(repo); - pins.iter().any(|(table, _)| { - git_key(table).is_some() && git_key(table) == git_key(repo) - }) - }); - let remedy = if stale { + // An entry a `[patch]` table pointing at the copy redirects + // (crates.io under `[patch.crates-io]`, a git repository under its + // own table) is stale: cargo would redirect it. Any other is + // declared by some dependency, or left over from one that was + // removed. + let stale = DeclSource::of_lock_source(source).and_then(|src| { + pins.iter() + .find(|(table, _)| src.patched_by(None, table)) + .map(|(table, _)| *table) + }); + let remedy = if let Some(table) = stale { + // `vendor` writes `[patch.crates-io]` only, so it re-locks + // only a crates.io entry. + let vendor = if is_crates_io_source(table) { + "re-run `socket-patch vendor`, or " + } else { + "" + }; + let table = if table.contains("://") { + format!("\"{table}\"") + } else { + table.to_string() + }; format!( - "the entry is stale (`[patch.crates-io]` redirects crates.io), so \ - re-lock: re-run `socket-patch vendor`, or `cargo update -p {name}`" + "the entry is stale (`[patch.{table}]` redirects its source), so \ + re-lock: {vendor}`cargo update -p {name}`" ) } else { format!( @@ -1749,15 +1744,10 @@ fn patch_bypassed( let Some((first, _)) = other_patch.first() else { return; }; - let declared = other_patch - .iter() - .map(|(d, uuid)| format!("{} (Socket patch {uuid})", d.describe(name))) - .collect::>() - .join(", and "); ( first.file.as_str(), format!( - "{declared}, which no wiring of that patch for {name} {version} pairs \ + "{other_declared}, which no wiring of that patch for {name} {version} pairs \ with this copy (a stale {CARGO_LOCK}, or a registry definition that \ disagrees): cargo resolves that declaration from that patch's registry, \ so the crate declaring it builds {name} with another patch than the copy \ @@ -1785,11 +1775,11 @@ async fn vendored_from_patches( ctx: &DiscoverCtx<'_>, file: &str, doc: &DocumentMut, - lock: &Lock, shadow: Option<&Shadow>, bypass: &Bypass<'_>, out: &mut Discovery, ) { + let lock = bypass.lock; for entry in patch_entries(doc) { let Some(path) = entry.path else { continue; // a git / registry patch @@ -1949,6 +1939,15 @@ mod tests { format!("[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\n{deps}\n") } + /// A workspace root over `crates/*` that is also a package declaring + /// `deps`. + fn workspace_root(deps: &str) -> String { + format!( + "[workspace]\nmembers = [\"crates/*\"]\n\n{}", + manifest(deps) + ) + } + fn pinned(name: &str, version: &str, uuid: &str) -> String { format!("{name} = {{ version = \"{version}\", registry = \"socket-patch-{uuid}\" }}") } @@ -2426,8 +2425,7 @@ mod tests { "{:#?}", out.diagnostics ); - // REVIEW: B's pin, which the lock does not reflect, is still - // diagnosed (it used to vanish once any Socket entry was locked). + // B's pin, which the lock does not reflect, is still diagnosed. assert!( out.diagnostics .iter() @@ -2440,10 +2438,9 @@ mod tests { ); } - /// REVIEW: a well-formed Socket registry that pins nothing — beside - /// `path` / `git` / `registry-index`, or on a `[workspace.dependencies]` - /// entry no manifest inherits — is diagnosed, since the file reads like - /// wiring. + /// A well-formed Socket registry that pins nothing — beside `path`, + /// `git` or `registry-index`, or on a `[workspace.dependencies]` entry no + /// manifest inherits — is diagnosed, since the file reads like wiring. #[tokio::test] async fn an_ineffective_socket_registry_is_diagnosed() { let reg = format!("registry = \"socket-patch-{UUID_A}\""); @@ -2509,20 +2506,15 @@ mod tests { /// A workspace root that declares nothing itself, with one member. fn workspace_with_member(p: &Project, member_deps: &str) { p.write("Cargo.toml", "[workspace]\nmembers = [\"crates/*\"]\n"); - p.write( - "crates/a/Cargo.toml", - format!( - "[package]\nname = \"a\"\nversion = \"0.1.0\"\n\n[dependencies]\n{member_deps}\n" - ), - ); + member(p, "a", member_deps); } - /// REGRESSION (C2): the hosted rewriter pins the crate in every member - /// manifest that declares it, so a member's declaration decides like the - /// root's. A member reverted to crates.io (the root declares nothing) - /// makes cargo re-resolve from crates.io: no ref, diagnosed against the - /// member, and the stale lock entry's patch is recognized (a ledger - /// record for it is dead). + /// The hosted rewriter pins the crate in every member manifest that + /// declares it, so a member's declaration decides like the root's. A + /// member reverted to crates.io (the root declares nothing) makes cargo + /// re-resolve from crates.io: no ref, diagnosed against the member, and + /// the stale lock entry's patch is recognized (a ledger record for it is + /// dead). #[tokio::test] async fn reverted_member_manifest_pin_drops_the_stale_lock_entry() { for decl in [ @@ -2596,18 +2588,8 @@ mod tests { #[tokio::test] async fn a_root_pin_with_an_unpinned_member_declaration_is_a_ref() { let p = Project::new(); - p.write( - "Cargo.toml", - format!( - "[workspace]\nmembers = [\"crates/*\"]\n\n[package]\nname = \"app\"\n\ - version = \"0.1.0\"\n\n[dependencies]\n{}\n", - pinned("serde", "1", UUID_A) - ), - ); - p.write( - "crates/a/Cargo.toml", - "[package]\nname = \"a\"\nversion = \"0.1.0\"\n\n[dependencies]\nserde = \"0.9\"\n", - ); + pinned_root_workspace(&p); + member(&p, "a", "serde = \"0.9\""); p.write( "Cargo.lock", lock(&[("serde", "1.0.1", Some(&index(UUID_A)), Some(CKSUM))]), @@ -2625,19 +2607,8 @@ mod tests { /// `extra` lock entries). fn mixed_workspace(root_dep: &str, member_dep: &str, extra: &[(&str, &str)]) -> Project { let p = Project::new(); - p.write( - "Cargo.toml", - format!( - "[workspace]\nmembers = [\"crates/*\"]\n\n[package]\nname = \"app\"\n\ - version = \"0.1.0\"\n\n[dependencies]\n{root_dep}\n" - ), - ); - p.write( - "crates/a/Cargo.toml", - format!( - "[package]\nname = \"a\"\nversion = \"0.1.0\"\n\n[dependencies]\n{member_dep}\n" - ), - ); + p.write("Cargo.toml", workspace_root(root_dep)); + member(&p, "a", member_dep); let socket = index(UUID_A); let mut pkgs = vec![("serde", "1.0.1", Some(socket.as_str()), Some(CKSUM))]; pkgs.extend( @@ -2650,8 +2621,7 @@ mod tests { p } - /// Review on #2019 (round 2, fixed here where `Shadow` lives): a - /// `[patch.crates-io]` Socket pin that cargo ignores (a crates.io URL + /// A `[patch.crates-io]` Socket pin that cargo ignores (a crates.io URL /// table replaces it) pins no declaration: the member's crates.io /// declaration is unpinned, so the ref is not attested. #[tokio::test] @@ -2680,25 +2650,38 @@ mod tests { ); } - /// The one unattested mark of `out`, asserted to be the hosted serde - /// ref's [`UnattestedKind::CargoUnpinnedDeclaration`] naming `file`. - fn unpinned_mark<'a>(out: &'a Discovery, file: &str) -> &'a Unattested { + /// The one unattested mark of `out`, asserted to be patch A's `kind` + /// mark on `purl` naming `file`. + fn one_mark<'a>( + out: &'a Discovery, + kind: UnattestedKind, + purl: &str, + file: &str, + ) -> &'a Unattested { assert_eq!(out.unattested.len(), 1, "{:#?}", out.unattested); let u = &out.unattested[0]; - assert_eq!(u.kind, UnattestedKind::CargoUnpinnedDeclaration); - assert_eq!( - (u.purl.as_str(), u.uuid.as_str()), - ("pkg:cargo/serde@1.0.1", UUID_A) - ); + assert_eq!(u.kind, kind); + assert_eq!((u.purl.as_str(), u.uuid.as_str()), (purl, UUID_A)); assert_eq!(u.file, std::path::PathBuf::from(file)); u } - /// REGRESSION: the root pins serde to Socket, but a member declares it - /// from crates.io at a requirement the patched 1.0.1 satisfies. Cargo - /// resolves that declaration as a separate crates.io package, so the - /// member builds unpatched bytes: the ref stays (rollback / remove find - /// it, the ledger claim lives) but is not attested, naming the member. + /// [`one_mark`] for the hosted serde ref's + /// [`UnattestedKind::CargoUnpinnedDeclaration`]. + fn unpinned_mark<'a>(out: &'a Discovery, file: &str) -> &'a Unattested { + one_mark( + out, + UnattestedKind::CargoUnpinnedDeclaration, + "pkg:cargo/serde@1.0.1", + file, + ) + } + + /// The root pins serde to Socket, but a member declares it from crates.io + /// at a requirement the patched 1.0.1 satisfies. Cargo resolves that + /// declaration as a separate crates.io package, so the member builds + /// unpatched bytes: the ref stays (rollback / remove find it, the ledger + /// claim lives) but is not attested, naming the member. #[tokio::test] async fn a_root_pin_beside_an_unpinned_member_is_unattested() { for member in [ @@ -2839,8 +2822,8 @@ mod tests { "{}", u.detail ); - // Review on #2019 (round 2): `registry = "crates-io"` is crates.io, - // which a re-scan re-pins (#386). + // `registry = "crates-io"` is crates.io, which a re-scan re-pins + // (#386). let p = mixed_workspace( &pinned("serde", "=1.0.1", UUID_A), "serde = { version = \"1\", registry = \"crates-io\" }", @@ -2895,10 +2878,9 @@ mod tests { u.detail ); } - // Review on #2019 (round 3): every unpinned declaration passes on - // its own, but the rewriter refuses the dependency over the PIN's - // own requirement (`"1"` also matches the crates.io 1.0.200), so a - // re-scan pins nothing. + // Every unpinned declaration passes on its own, but the rewriter + // refuses the dependency over the PIN's own requirement (`"1"` also + // matches the crates.io 1.0.200), so a re-scan pins nothing. let p = mixed_workspace( &pinned("serde", "1", UUID_A), "serde = \"=1.0.1\"", @@ -2980,13 +2962,7 @@ mod tests { pinned("serde", "1", UUID_A) ), ); - p.write( - "crates/a/Cargo.toml", - format!( - "[package]\nname = \"a\"\nversion = \"0.1.0\"\n\n[dependencies]\n\ - {member_dep}\n" - ), - ); + member(&p, "a", member_dep); p.write( "Cargo.lock", lock(&[("serde", "1.0.1", Some(&index(UUID_A)), Some(CKSUM))]), @@ -3109,14 +3085,7 @@ mod tests { /// A workspace whose root pins `serde` and declares `crates/*` members. fn pinned_root_workspace(p: &Project) { - p.write( - "Cargo.toml", - format!( - "[workspace]\nmembers = [\"crates/*\"]\n\n[package]\nname = \"app\"\n\ - version = \"0.1.0\"\n\n[dependencies]\n{}\n", - pinned("serde", "1", UUID_A) - ), - ); + p.write("Cargo.toml", workspace_root(&pinned("serde", "1", UUID_A))); } fn member(p: &Project, dir: &str, deps: &str) { @@ -3126,12 +3095,11 @@ mod tests { ); } - /// REVIEW (lockless members): a member's unpinned declaration at a - /// version the pin does not reach (`serde = "0.9"` beside the root's - /// pinned `serde = "1"`, which the rewriter leaves alone) keeps the - /// lockless pin; it only excludes the versions it accepts. One that - /// accepts the record's version, or that has no requirement at all (a - /// git dependency), leaves the record unrouted. + /// A member's unpinned declaration at a version the pin does not reach + /// (`serde = "0.9"` beside the root's pinned `serde = "1"`, which the + /// rewriter leaves alone) keeps the lockless pin; it only excludes the + /// versions it accepts. One that accepts the record's version, or that has + /// no requirement at all (a git dependency), leaves the record unrouted. #[tokio::test] async fn lockless_pin_survives_a_member_declaring_another_version() { let record = "pkg:cargo/serde@1.0.1"; @@ -3256,9 +3224,8 @@ mod tests { ); } - /// REVIEW (one pin per crate and patch): the root and two members pin the - /// same patch — one lockless pin carrying every requirement, one - /// diagnostic listing each manifest once. + /// The root and two members pin the same patch — one lockless pin carrying + /// every requirement, one diagnostic listing each manifest once. #[tokio::test] async fn one_lockless_pin_and_diagnostic_per_crate_and_patch() { let p = Project::new(); @@ -3290,11 +3257,10 @@ mod tests { ); } - /// REVIEW (perf): members only matter to Socket wiring, so a project - /// with a lock that resolves nothing from a Socket patch server — or no - /// lock and no `socket-patch-` pin anywhere — reads no member manifest - /// as discovery's own (none in [`Discovery::read`], which the full run - /// collects). + /// Members only matter to Socket wiring, so a project with a lock that + /// resolves nothing from a Socket patch server — or no lock and no + /// `socket-patch-` pin anywhere — reads no member manifest as discovery's + /// own (none in [`Discovery::read`], which the full run collects). #[tokio::test] async fn members_are_read_only_when_socket_wiring_could_need_them() { let member_read = |out: &Discovery| { @@ -3326,9 +3292,9 @@ mod tests { let p = Project::new(); workspace_with_member(&p, &pinned("serde", "1", UUID_A)); assert!(member_read(&p.discover().await)); - // Review on #2012 (round 2): a stale crates.io lock beside a member - // pin still reads the member, so the pin is diagnosed (a stale lock - // or a server outside the allowlist) and its uuid recognized. + // A stale crates.io lock beside a member pin still reads the member, + // so the pin is diagnosed (a stale lock or a server outside the + // allowlist) and its uuid recognized. let p = Project::new(); workspace_with_member(&p, &pinned("serde", "1", UUID_A)); p.write( @@ -3496,8 +3462,8 @@ mod tests { // The crates.io declaration is what the entry redirects: not unpinned. assert!(out.unattested.is_empty(), "{:#?}", out.unattested); - // Review on #2019 (Bugbot): an explicit `registry = "crates-io"` is - // crates.io, so a `[patch.""]` pin redirects it too. + // An explicit `registry = "crates-io"` is crates.io, so a + // `[patch.""]` pin redirects it too. let p = Project::new(); p.write( "Cargo.toml", @@ -4261,18 +4227,13 @@ mod tests { p.write( "Cargo.toml", format!( - "[workspace]\nmembers = [\"crates/*\"]\n\n{}\n[patch.crates-io]\n\ + "{}\n[patch.crates-io]\n\ cfg-if-socket-aaaaaaaa = {{ package = \"cfg-if\", path = \"{rel}\" }}\n\ {root_extra}\n", - manifest("cfg-if = \"1\"") - ), - ); - p.write( - "crates/a/Cargo.toml", - format!( - "[package]\nname = \"a\"\nversion = \"0.1.0\"\n\n[dependencies]\n{member_dep}\n" + workspace_root("cfg-if = \"1\"") ), ); + member(&p, "a", member_dep); let tagged = crate::vendor::cargo_tag::tag_version("1.0.4", UUID_A); let mut pkgs = vec![("cfg-if", tagged.as_str(), None, None)]; pkgs.extend( @@ -4284,26 +4245,23 @@ mod tests { p } - /// The one unattested mark of `out`, asserted to be the vendored - /// cfg-if ref's [`UnattestedKind::CargoPatchBypassed`] naming `file`. + /// [`one_mark`] for the vendored cfg-if ref's + /// [`UnattestedKind::CargoPatchBypassed`]. fn bypassed_mark<'a>(out: &'a Discovery, file: &str) -> &'a Unattested { - assert_eq!(out.unattested.len(), 1, "{:#?}", out.unattested); - let u = &out.unattested[0]; - assert_eq!(u.kind, UnattestedKind::CargoPatchBypassed); - assert_eq!( - (u.purl.as_str(), u.uuid.as_str()), - ("pkg:cargo/cfg-if@1.0.4", UUID_A) - ); - assert_eq!(u.file, std::path::PathBuf::from(file)); - u + one_mark( + out, + UnattestedKind::CargoPatchBypassed, + "pkg:cargo/cfg-if@1.0.4", + file, + ) } - /// REGRESSION: `[patch.crates-io]` redirects only crates.io - /// declarations. A member that declares cfg-if from another registry or - /// from git, at a requirement the patched 1.0.4 satisfies, resolves its - /// own unpatched copy, which may be that very version. The vendored ref - /// stays (rollback / remove find it, the ledger claim lives) but is not - /// attested, naming the member. + /// `[patch.crates-io]` redirects only crates.io declarations. A member + /// that declares cfg-if from another registry or from git, at a + /// requirement the patched 1.0.4 satisfies, resolves its own unpatched + /// copy, which may be that very version. The vendored ref stays (rollback + /// / remove find it, the ledger claim lives) but is not attested, naming + /// the member. #[tokio::test] async fn a_member_from_another_source_bypasses_the_vendored_copy() { for (member, origin) in [ @@ -4473,11 +4431,10 @@ mod tests { bypassed_mark(&out, "crates/a/Cargo.toml"); } - /// REGRESSION (member gate): a vendored project's lock has no - /// Socket-sourced package (the copy's entry is sourceless), and a - /// lockless one has no `socket-patch-` pin, so the member walk must - /// not hinge on either: the members are read whenever a `[patch]` - /// wires a Socket copy, lock or no lock. + /// A vendored project's lock has no Socket-sourced package (the copy's + /// entry is sourceless), and a lockless one has no `socket-patch-` pin, so + /// the member walk must not hinge on either: the members are read whenever + /// a `[patch]` wires a Socket copy, lock or no lock. #[tokio::test] async fn members_are_read_for_a_vendored_copy_without_a_socket_lock_entry() { for with_lock in [true, false] { @@ -4498,8 +4455,6 @@ mod tests { } } - // ── review findings ────────────────────────────────────────────── - /// The one vendored cfg-if ref of `out`: attested (no mark) or not. fn assert_vendored_attested(out: &Discovery, attested: bool, what: &str) { assert_refs( @@ -4514,12 +4469,11 @@ mod tests { ); } - /// REVIEW (false attestation): the project config redefines the - /// manifest's crates.io `[patch]` key with a git fork, so cargo ignores - /// the manifest's entry for the copy and builds the fork for crates.io - /// declarations. That shadowed entry used to count as redirecting them, - /// so the copy's other (honored) `[patch.my-corp]` entry was attested - /// beside the fork. + /// The project config redefines the manifest's crates.io `[patch]` key + /// with a git fork, so cargo ignores the manifest's entry for the copy and + /// builds the fork for crates.io declarations: that shadowed entry + /// redirects nothing, so the copy's other (honored) `[patch.my-corp]` + /// entry is not attested beside the fork. #[tokio::test] async fn a_shadowed_manifest_entry_covers_no_declaration() { let rel = vendor_path(UUID_A, "cfg-if-1.0.4"); @@ -4549,9 +4503,8 @@ mod tests { ); } - /// Review on #2024 (round 3): a pin to another patch beside a lock that - /// also resolves the crate from crates.io is not described as "only - /// from other Socket patches". + /// A pin to another patch beside a lock that also resolves the crate from + /// crates.io is not described as "only from other Socket patches". #[tokio::test] async fn another_patch_pin_beside_a_foreign_twin_is_not_called_socket_only() { let p = mixed_workspace( @@ -4572,24 +4525,28 @@ mod tests { ); } - /// REVIEW (Bugbot on #2024): a config item for the same key replaces - /// the manifest's `[patch]` item in cargo whatever the source table and - /// whether or not either wires a path: a path-less config git fork - /// shadows a path-less manifest registry pin, and a `[patch.my-corp]` - /// config item shadows the manifest's `[patch.my-corp]` copy. + /// The [`Shadow`] of a bare manifest under project config `config`. + fn shadow(config: &str) -> super::Shadow { + let manifest: toml_edit::DocumentMut = "[package]\nname = \"x\"\n".parse().unwrap(); + let config: toml_edit::DocumentMut = config.parse().unwrap(); + super::Shadow::new(&manifest, Some(&(".cargo/config.toml", config))) + } + + /// A config item for the same key replaces the manifest's `[patch]` item + /// in cargo whatever the source table and whether or not either wires a + /// path: a path-less config git fork shadows a path-less manifest registry + /// pin, and a `[patch.my-corp]` config item shadows the manifest's + /// `[patch.my-corp]` copy. #[test] fn config_items_shadow_pathless_and_named_source_entries() { - let manifest: toml_edit::DocumentMut = "[package]\nname = \"x\"\n".parse().unwrap(); - let config: toml_edit::DocumentMut = "[patch.crates-io]\n\ + let s = shadow( + "[patch.crates-io]\n\ cfg-if-socket-aaaaaaaa = { package = \"cfg-if\", git = \"https://example.com/f\" }\n\ [patch.my-corp]\n\ cfg-if = { git = \"https://example.com/f\" }\n\ [patch.\"https://My-Corp.example/index/\"]\n\ - cfg-if = { path = \"../fork\" }\n" - .parse() - .unwrap(); - let cfg = (".cargo/config.toml", config); - let s = super::Shadow::new(&manifest, Some(&cfg)); + cfg-if = { path = \"../fork\" }\n", + ); // A path-less registry pin under the same key: shadowed. assert!(s .why( @@ -4619,13 +4576,8 @@ mod tests { None ) .is_some()); - // A canonical-equivalent git table (Bugbot on a7611ef). - let config: toml_edit::DocumentMut = - "[patch.\"https://GitHub.com/Corp/Fork.git/\"]\ncfg-if = { git = \"https://example.com/f\" }\n" - .parse() - .unwrap(); - let cfg2 = (".cargo/config.toml", config); - let s2 = super::Shadow::new(&manifest, Some(&cfg2)); + // A canonical-equivalent git table. + let s2 = shadow("[patch.\"https://GitHub.com/Corp/Fork.git/\"]\ncfg-if = { git = \"https://example.com/f\" }\n"); assert!(s2 .why( "https://github.com/corp/fork", @@ -4651,31 +4603,23 @@ mod tests { None ) .is_none()); - // Review on #2024: the identical path-less Socket registry pin in - // both files is the same wiring, not a shadow. - let config: toml_edit::DocumentMut = - "[patch.crates-io]\nserde = { registry = \"socket-patch-aaaaaaaa\" }\n" - .parse() - .unwrap(); - let cfg3 = (".cargo/config.toml", config); - let s3 = super::Shadow::new(&manifest, Some(&cfg3)); + // The identical path-less Socket registry pin in both files is the + // same wiring, not a shadow. + let s3 = shadow("[patch.crates-io]\nserde = { registry = \"socket-patch-aaaaaaaa\" }\n"); assert!(s3 .why("crates-io", "serde", None, Some("socket-patch-aaaaaaaa")) .is_none()); assert!(s3 .why("crates-io", "serde", None, Some("socket-patch-bbbbbbbb")) .is_some()); - // Review on #2024: a `[patch.""]` config table patches the - // same source as the manifest's `[patch.]` for a registry - // defined on that index. - let config: toml_edit::DocumentMut = "[registries.my-corp]\n\ + // A `[patch.""]` config table patches the same source as the + // manifest's `[patch.]` for a registry defined on that index. + let s4 = shadow( + "[registries.my-corp]\n\ index = \"https://my-corp.example/index\"\n\ [patch.\"https://my-corp.example/index\"]\n\ - cfg-if = { git = \"https://example.com/fork\" }\n" - .parse() - .unwrap(); - let cfg4 = (".cargo/config.toml", config); - let s4 = super::Shadow::new(&manifest, Some(&cfg4)); + cfg-if = { git = \"https://example.com/fork\" }\n", + ); assert!(s4 .why("my-corp", "cfg-if", Some("vendor/x"), None) .is_some()); @@ -4684,13 +4628,13 @@ mod tests { .is_none()); } - /// REVIEW: a `registry-index` declaration is redirected by a + /// A `registry-index` declaration is redirected by a /// `[patch.""]` table for the same index (host case, a /// trailing `/` and the lock's `registry+` spelling aside; `sparse+` is - /// another source), and one naming the crates.io git index is - /// crates.io. The sparse crates.io index stays another source: whether - /// `[patch.crates-io]` reaches it is not what the files show, so it is - /// withheld rather than risked. + /// another source), and + /// one naming the crates.io git index is crates.io. The sparse crates.io + /// index stays another source: whether `[patch.crates-io]` reaches it is + /// not what the files show, so it is withheld rather than risked. #[tokio::test] async fn a_registry_index_patch_table_covers_that_index() { let rel = vendor_path(UUID_A, "cfg-if-1.0.4"); @@ -4734,10 +4678,10 @@ mod tests { } } - /// REVIEW: the remedy follows the evidence — a declaration cannot be - /// fixed by re-vendoring; a crates.io lock entry beside a - /// `[patch.crates-io]` copy is stale (re-lock); another source's entry - /// is some dependency's (`cargo tree -i` finds it). + /// The remedy follows the evidence — a declaration cannot be fixed by + /// re-vendoring; a crates.io lock entry beside a `[patch.crates-io]` copy + /// is stale (re-lock); another source's entry is some dependency's + /// (`cargo tree -i` finds it). #[tokio::test] async fn the_bypass_remedy_follows_its_evidence() { let out = run(&vendored_workspace( @@ -4781,12 +4725,33 @@ mod tests { "{}", u.detail ); + + // A git entry a git `[patch]` table at the copy redirects is stale + // too, but `vendor` (which writes `[patch.crates-io]`) does not + // re-lock it. + let rel = vendor_path(UUID_A, "cfg-if-1.0.4"); + let out = run(&vendored_workspace( + "cfg-if = \"1\"", + &format!( + "\n[patch.\"https://github.com/corp/fork\"]\ncfg-if = {{ path = \"{rel}\" }}\n" + ), + &[("1.0.4", "git+https://github.com/corp/fork#0123456789abcdef")], + )) + .await; + let u = bypassed_mark(&out, "Cargo.lock"); + assert!( + u.detail + .contains("`[patch.\"https://github.com/corp/fork\"]` redirects its source") + && u.detail.contains("cargo update -p cfg-if") + && !u.detail.contains("socket-patch vendor"), + "{}", + u.detail + ); } - /// REVIEW: git URLs compare as cargo's `CanonicalUrl` spells them — a - /// GitHub URL case-insensitively, any host's case aside, one trailing - /// `/` and `.git` dropped — so another path on a non-GitHub host is - /// another repository. + /// Git URLs compare as cargo's `CanonicalUrl` spells them — a GitHub URL + /// case-insensitively, any host's case aside, one trailing `/` and `.git` + /// dropped — so another path on a non-GitHub host is another repository. #[tokio::test] async fn a_git_patch_table_matches_cargos_canonical_url() { let rel = vendor_path(UUID_A, "cfg-if-1.0.4"); @@ -4813,11 +4778,11 @@ mod tests { assert_vendored_attested(&out, attested, &member); } } - /// REGRESSION (Bugbot on #2024): a member pinned to ANOTHER Socket - /// patch builds that patch's copy. When the lock resolves cfg-if from - /// it, its hosted ref stands beside the vendored one and the CLI's - /// conflict gate refuses both; when the lock does not (stale), the - /// vendored copy is marked bypassed rather than attested alone. + /// A member pinned to ANOTHER Socket patch builds that patch's copy. When + /// the lock resolves cfg-if from it, its hosted ref stands beside the + /// vendored one and the CLI's conflict gate refuses both; when the lock + /// does not (stale), the vendored copy is marked bypassed rather than + /// attested alone. #[tokio::test] async fn a_member_pinned_to_another_patch_bypasses_the_vendored_copy() { let p = vendored_workspace(&pinned("cfg-if", "1", UUID_B), "", &[]); diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index 0097d9562..dbff114f8 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -2589,7 +2589,7 @@ impl Discovery { }) { return RecordLiveness::Live; } - if resolved().any(|r| foreign_hosted_uuid(r).as_deref() == Some(uuid)) { + if resolved().any(|r| crate::origin::hosted_shape_uuid(r).as_deref() == Some(uuid)) { return RecordLiveness::Undecided; } if entries.iter().any(|e| resolves_elsewhere(e)) { @@ -2662,48 +2662,21 @@ impl RecordLiveness { } } -/// The patch uuid `url` names when read as a hosted url of its OWN origin -/// ([`crate::origin::hosted_patch_uuid`]'s grammar — the last -/// canonical-uuid path segment — with the url's origin taken as -/// configured): what a lock entry on an origin this run was not given -/// claims to resolve from. Only a url in a Socket patch server's path -/// shape (a `patch`, `patch-registry` or `gopatch` segment) counts: any -/// other url merely ending in the uuid (an artifact store's blob path) -/// resolves from elsewhere. Userinfo is dropped first: Socket never writes -/// one, so `https://patch.socket.dev@evil.example/patch/…` is a look-alike -/// on the host after the `@`, never an accepted origin. -fn foreign_hosted_uuid(url: &str) -> Option { - // Normalized the way `hosted_patch_uuid` reads it, so an escaped, - // percent-encoded or `sparse+` url still yields its origin. - let normalized = crate::origin::normalized_hosted_url(url)?; - let mut parsed = reqwest::Url::parse(&normalized).ok()?; - if !parsed - .path_segments()? - .any(|s| matches!(s, "patch" | "patch-registry" | "gopatch")) - { - return None; - } - let _ = parsed.set_username(""); - let _ = parsed.set_password(None); - let origin = parsed.origin().ascii_serialization(); - crate::origin::hosted_patch_uuid(parsed.as_str(), &[origin]) -} - -/// Whether any of `files` — the root-relative files a REDIRECT ledger -/// recorded editing — spells a url with the hosted shape for `uuid` -/// ([`foreign_hosted_uuid`]) on an origin outside Socket's host and -/// `origins`. Fail closed: one such url is enough, whatever else the files +/// Whether any of `files` — the root-relative files a REDIRECT ledger recorded +/// editing — spells a url with the hosted shape for `uuid` +/// ([`crate::origin::hosted_shape_uuid`]) on an origin outside Socket's host +/// and `origins`. Fail closed: one such url is enough, whatever else the files /// spell (a Socket-host url beside it — a leftover definition, a comment — -/// does not clear it). Each occurrence of the uuid is widened to the url -/// token around it (bounded by whitespace, quotes, markup and `=` / `&`, -/// so a berry `__archiveUrl=` binding value or a `Cargo.lock` `sparse+` -/// source reads whole), then read the way -/// [`crate::origin::hosted_patch_uuid`] reads a url; a scheme-less token -/// (a go.mod `replace` module path, `/gopatch/`) is read as -/// `https://`. Every recorded file counts, registry definitions included: -/// a definition routes nothing on its own, but it names the host the pin -/// routes to. `bun.lockb` is binary and is skipped (its inventory carries -/// the url to step 1). Same path guards as [`hosted_wiring_in_files`]. +/// does not clear it). Each occurrence of the uuid is widened to the url token +/// around it (bounded by whitespace, quotes, markup and `=` / `&`, so a berry +/// `__archiveUrl=` binding value or a `Cargo.lock` `sparse+` source reads +/// whole), then read the way [`crate::origin::hosted_patch_uuid`] reads a url; +/// a scheme-less token (a go.mod `replace` module path, +/// `/gopatch/`) is read as `https://`. Every recorded file counts, +/// registry definitions included: a definition routes nothing on its own, but +/// it names the host the pin routes to. `bun.lockb` is binary and is skipped +/// (its inventory carries the url to step 1). Same path guards as +/// [`hosted_wiring_in_files`]. async fn foreign_hosted_url_in_files( root: &Path, files: &[&str], @@ -2728,8 +2701,8 @@ async fn foreign_hosted_url_in_files( schemed = format!("https://{token}"); &schemed }; - if foreign_hosted_uuid(url).as_deref() == Some(uuid) - && crate::origin::hosted_patch_uuid(url, origins).as_deref() != Some(uuid) + if crate::origin::hosted_patch_uuid(url, origins).as_deref() != Some(uuid) + && crate::origin::hosted_shape_uuid(url).as_deref() == Some(uuid) { return true; } @@ -3464,7 +3437,7 @@ mod tests { /// `hosted_patch_uuid` applies: `\\/` escapes, a percent-encoded url /// and a Cargo `sparse+` / `registry+` prefix. #[test] - fn foreign_hosted_uuid_normalizes_like_hosted_patch_uuid() { + fn hosted_shape_uuid_normalizes_like_hosted_patch_uuid() { const U: &str = "11111111-2222-4333-8444-555555555555"; for url in [ format!("https://mirror.example/patch/{U}/x.tgz"), @@ -3475,15 +3448,18 @@ mod tests { format!("https://patch.socket.dev@mirror.example/patch/{U}/x.tgz"), format!("https://user:pw@patch.socket.dev/patch/{U}/x.tgz"), ] { - assert_eq!(foreign_hosted_uuid(&url).as_deref(), Some(U), "{url}"); + assert_eq!( + crate::origin::hosted_shape_uuid(&url).as_deref(), + Some(U), + "{url}" + ); } - // Review on #2012 (round 2): a url merely ending in the uuid is not - // a patch server's. + // A url merely ending in the uuid is not a patch server's. for url in [ format!("https://artifacts.corp/blobs/{U}/x-1.0.0.tgz"), format!("https://evil.example/{U}/x.tgz"), ] { - assert_eq!(foreign_hosted_uuid(&url), None, "{url}"); + assert_eq!(crate::origin::hosted_shape_uuid(&url), None, "{url}"); } } @@ -3820,8 +3796,8 @@ mod tests { "host not allowlisted" ); // Without the origin, the internal host's url is undecided: never - // attested, and — the regression — not vetoed by the script lock's - // registry instance either. + // attested, and not vetoed by the script lock's registry instance + // either. let mut inventory = None; assert_eq!( discovery @@ -3968,16 +3944,15 @@ mod tests { assert!(!hosted_wiring_in_files(root, &["package-lock.json"], "not-a-uuid").await); } - /// REGRESSION (P6): step 1 classifies a lock entry's resolved url with - /// the one origin rule ([`crate::origin::hosted_patch_uuid`]), - /// never by substring. Only a Socket-host url or a configured origin's - /// url for the record's uuid is LIVE (attested). A url with the hosted - /// shape for the uuid on an origin this run was not given — a patch - /// server an earlier run was pointed at, or a host that merely spells - /// it — is UNDECIDED: never attested, but its wiring is retained. A url - /// that only contains the uuid (a file name, a query) and a Socket url - /// for ANOTHER patch (whose path carries the uuid as its grant token) - /// resolve the package from somewhere else: DEAD. + /// Step 1 classifies a lock entry's resolved url with the one origin rule + /// ([`crate::origin::hosted_patch_uuid`]), never by substring. Only a + /// Socket-host url or a configured origin's url for the record's uuid is + /// LIVE (attested). A url with the hosted shape for the uuid on an origin + /// this run was not given — a patch server an earlier run was pointed at, + /// or a host that merely spells it — is UNDECIDED: never attested, but its + /// wiring is retained. A url that only contains the uuid (a file name, a + /// query) and a Socket url for ANOTHER patch (whose path carries the uuid + /// as its grant token) resolve the package from somewhere else: DEAD. #[tokio::test] async fn liveness_matches_the_resolved_url_by_origin_not_substring() { use RecordLiveness::{Dead, Live, Undecided}; @@ -4141,9 +4116,8 @@ mod tests { &with_origin, Live, ), - // Review on #2012 (round 3): a Socket-host spelling elsewhere in - // the files (a comment, a leftover definition) does not clear a - // foreign pin. + // A Socket-host spelling elsewhere in the files (a comment, a + // leftover definition) does not clear a foreign pin. ( "yarn.lock", "pkg:npm/x@1.0.0",