diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index e2bc9b3d9..be4e251a9 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1491,7 +1491,7 @@ command's integration tests check its output. | Tag | Action(s) | Context | |---------------------------|------------------|---------| | `already_patched` | `skipped` | apply: every file's hash already matches `afterHash`. | -| `package_not_installed` | `skipped` | apply: manifest entry has no matching installed package. When the project's lockfiles resolve the purl (a platform-gated optional dependency, a devDependency under `--omit=dev`), the detail says it is lockfile-only and the entry never fails the run, even when no other patch matched; only an all-miss run with an unmatched purl that no lock resolves exits 1 (`partialFailure`). **Cargo is never lockfile-only (v5.0, #616)**: `cargo fetch` unpacks every `Cargo.lock` entry (target-gated ones included), so a locked crate missing from `$CARGO_HOME/registry/src` was simply not fetched yet (a cold CI cache, a pruned `registry/src`) and the next `cargo build` would compile it unpatched; it counts as an unresolved purl (an all-miss run exits 1, as in 4.x) and its detail, the human error and the human warning say to run `cargo fetch` first. | +| `package_not_installed` | `skipped` | apply: manifest entry has no matching installed package. When the project's lockfiles resolve the purl (a platform-gated optional dependency, a devDependency under `--omit=dev`), the detail says it is lockfile-only and the entry never fails the run, even when no other patch matched; only an all-miss run with an unmatched purl that no lock resolves exits 1 (`partialFailure`). **Cargo is never lockfile-only (v5.0, #616)**: `cargo fetch` unpacks every `Cargo.lock` entry (target-gated ones included), so a locked crate missing from `$CARGO_HOME/registry/src` was simply not fetched yet (a cold CI cache, a pruned `registry/src`) and the next `cargo build` would compile it unpatched; it counts as an unresolved purl (an all-miss run exits 1, as in 4.x) and its detail, the human error and the human warning say to run `cargo fetch` first. A crate `Cargo.lock` takes from a path, git, `[patch]` or `[replace]` source is never looked up in the registry cache (#506): cargo does not build that copy, so the same detail also names that case. | | `apply_failed` | `failed` | apply: hash mismatch, write error, archive read error. | | `no_local_source` | `skipped`/`failed` | Agent patch application cannot obtain the required local or downloaded patch source. Vendored mode consumes complete server artifacts and no longer stages patch blobs. | | `offline_missing_sources` / `sources_download_failed` | apply run-level `warnings[]` | apply (additive): the patch sources were unavailable — `--offline` with no local source, or the download left a patch with no source — so nothing was attempted. The envelope keeps its pinned shape (`partialFailure`, empty `events[]`, zero summary, no top-level `error`); the warning is its machine-readable reason (the human path prints the staging `Error:` line on stderr instead, even under `--silent`). | diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index 1f915eb2f..6986642e1 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -3343,7 +3343,9 @@ fn lock_resolution_is_calm(purl: &str) -> bool { } /// The `package_not_installed` detail of an unmatched purl the lock does -/// not calmly account for: Cargo's carries the `cargo fetch` remedy. +/// not calmly account for: Cargo's carries the `cargo fetch` remedy, and +/// says why a crate the lock takes from a path, git or `[patch]` source +/// is never found (#506). fn not_installed_detail(purl: &str) -> &'static str { if Ecosystem::from_purl(purl) == Some(Ecosystem::Cargo) { CARGO_NOT_FETCHED_DETAIL @@ -3355,7 +3357,8 @@ fn not_installed_detail(purl: &str) -> &'static str { /// [`not_installed_detail`] for a Cargo crate. const CARGO_NOT_FETCHED_DETAIL: &str = "No unpacked crate source matches this PURL; run `cargo fetch` first so the crate is in \ - the Cargo registry cache, then re-run apply"; + the Cargo registry cache, then re-run apply. A crate Cargo.lock takes from a path, git or \ + [patch] source is never patched: cargo does not build its registry copy"; /// The `unmatched` purls with no lock evidence (sorted input, sorted /// output): the ones that can still fail an all-miss run. @@ -3431,7 +3434,8 @@ fn cargo_fetch_hint(unmatched: &[String]) -> Option<&'static str> { .any(|p| Ecosystem::from_purl(p) == Some(Ecosystem::Cargo)) .then_some( "Cargo crates are patched in the registry cache: run `cargo fetch` first so every \ - locked crate is unpacked there, then re-run apply.", + locked crate is unpacked there, then re-run apply. A crate Cargo.lock takes from a \ + path, git or [patch] source is never patched there: cargo does not build that copy.", ) } diff --git a/crates/socket-patch-cli/src/ecosystem_dispatch.rs b/crates/socket-patch-cli/src/ecosystem_dispatch.rs index 82595ae59..d2761444c 100644 --- a/crates/socket-patch-cli/src/ecosystem_dispatch.rs +++ b/crates/socket-patch-cli/src/ecosystem_dispatch.rs @@ -621,7 +621,7 @@ pub async fn find_all_packages_for_purls( // first-wins via their own `merge_first_wins` wiring in // `dispatch_find`. An npm purl with no copy can still be reached // through a workspace member's alias link (#1494). - dispatch_find_with_npm_links( + let mut found = dispatch_find_with_npm_links( partitioned, options, silent, @@ -629,7 +629,31 @@ pub async fn find_all_packages_for_purls( None, NpmLinks::ForUnresolved, ) - .await + .await; + retain_locked_cargo_copies(&mut found, options).await; + found +} + +/// Drop the Cargo copies a local project's build never consumes: a +/// registry-cache copy of a crate its `Cargo.lock` takes from a path, git +/// or `[patch]` source instead (`CargoCrawler::locked_registry_crates`). +/// The cache is shared machine-wide, so such a copy is often there, and +/// patching or attesting it would vouch for code cargo does not build +/// (#506). Only the commands that patch or attest call this: rollback and +/// `scan --prune` still find the copy, since an earlier apply may have +/// patched it (#1278). +async fn retain_locked_cargo_copies( + copies: &mut HashMap>, + options: &CrawlerOptions, +) { + let is_cargo = |purl: &str| Ecosystem::from_purl(purl) == Some(Ecosystem::Cargo); + if !copies.keys().any(|purl| is_cargo(purl)) { + return; + } + let Some(locked) = CargoCrawler::new().locked_registry_crates(options).await else { + return; + }; + copies.retain(|purl, _| !is_cargo(purl) || locked.contains_purl(purl)); } /// Multi-copy variant of `find_packages_for_rollback` (qualified-aware @@ -1075,6 +1099,9 @@ pub async fn find_manifest_package_copies_reusing( NpmLinks::ForUnresolved, ) .await; + // Only the registry copies of crates the lock takes from a registry + // are ones the build consumes, as for `apply` (#506). + retain_locked_cargo_copies(&mut copies, &crawler_options).await; // `apply` also writes every store variant of each npm copy (a pnpm peer // suffix, a Deno `_N` copy index, a vlt peer extra), so "every copy" // includes them (#603). diff --git a/crates/socket-patch-cli/tests/apply/cargo_lock_source.rs b/crates/socket-patch-cli/tests/apply/cargo_lock_source.rs new file mode 100644 index 000000000..5d1c7854c --- /dev/null +++ b/crates/socket-patch-cli/tests/apply/cargo_lock_source.rs @@ -0,0 +1,144 @@ +//! `apply` in a Cargo project whose `Cargo.lock` takes a crate from a path, +//! git or `[patch]` source (#506). +//! +//! `$CARGO_HOME/registry/src` is shared machine-wide, so it can hold a +//! crates.io copy of `cfg-if-1.0.4` that this project's build never reads: +//! a `[patch.crates-io]` path override (in `Cargo.toml` or +//! `.cargo/config.toml`) or a git dependency replaces it. Patching that +//! copy in place reports success while cargo builds the user's unpatched +//! source, so agent mode must leave it alone. A registry-sourced lock entry +//! (the control) is still patched. + +use std::path::Path; + +use serde_json::{json, Value}; + +use crate::cargo_lock_source_fixture::{ + stage_registry_copy, write_project, Shape, AFTER, BEFORE, PURL, +}; +use crate::common; + +use common::{git_sha256, parse_json_envelope, run_with_env}; + +/// `.socket/manifest.json` with one agent-mode record for [`PURL`], and the +/// after blob staged so apply runs offline. +fn write_manifest(root: &Path) { + let socket = root.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + std::fs::write(socket.join("blobs").join(git_sha256(AFTER)), AFTER).unwrap(); + let manifest = json!({ "patches": { PURL: { + "uuid": "50650650-0000-4000-8000-000000000506", + "exportedAt": "2024-01-01T00:00:00Z", + "files": { "package/src/lib.rs": { + "beforeHash": git_sha256(BEFORE), + "afterHash": git_sha256(AFTER), + }}, + "vulnerabilities": {}, + "description": "cargo lock source fixture", + "license": "MIT", + "tier": "free" + }}}); + std::fs::write( + socket.join("manifest.json"), + serde_json::to_vec_pretty(&manifest).unwrap(), + ) + .unwrap(); +} + +/// Stage `shape`, run `apply --offline --json`, and return the exit code, +/// the envelope and the registry copy's `src/lib.rs` afterwards. +fn apply(shape: Shape, extra: &[&str]) -> (i32, Value, Vec) { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path().join("app"); + std::fs::create_dir_all(&project).unwrap(); + write_project(&project, shape); + write_manifest(&project); + let cargo_home = tmp.path().join("cargo-home"); + let lib = stage_registry_copy(&cargo_home, BEFORE); + + let mut argv = vec!["apply", "--offline", "--json"]; + argv.extend_from_slice(extra); + let (code, stdout, stderr) = run_with_env( + &project, + &argv, + &[ + ("SOCKET_TELEMETRY_DISABLED", "1"), + ("CARGO_HOME", cargo_home.to_str().unwrap()), + ], + ); + let v = parse_json_envelope(&stdout); + assert!(!v.is_null(), "{shape:?}: no envelope\n{stdout}\n{stderr}"); + (code, v, std::fs::read(&lib).unwrap()) +} + +/// Control: a crate the lock takes from crates.io is patched in the +/// registry cache, so the fixture really reaches the agent apply path. +#[test] +fn registry_sourced_crate_is_patched_in_the_registry_cache() { + let (code, v, lib) = apply(Shape::Registry, &[]); + assert_eq!(code, 0, "{v}"); + assert_eq!(lib, AFTER, "the registry copy is the build's: {v}"); +} + +/// #506: a crate the lock resolves to a path, git or `[patch]` source is +/// never built from the registry cache, so apply must not patch that copy +/// or report it applied. +#[test] +fn crate_overridden_away_from_the_registry_is_not_patched() { + for shape in [ + Shape::ManifestPatch, + Shape::ConfigPatch, + Shape::GitDependency, + Shape::PathDependency, + Shape::Replace, + ] { + let (code, v, lib) = apply(shape, &[]); + assert_eq!( + lib, BEFORE, + "{shape:?}: the unused registry copy must stay untouched: {v}" + ); + let ev = event(&v, shape); + assert_eq!(ev["action"], "skipped", "{shape:?}: {v}"); + match shape { + // The user's own `[patch]` or `[replace]` entry: the consent + // gate's calm skip. + Shape::ManifestPatch | Shape::ConfigPatch | Shape::Replace => { + assert_eq!(code, 0, "{shape:?}: {v}"); + assert_eq!(ev["errorCode"], "user_patch_conflict", "{shape:?}: {ev}"); + } + // Nothing else protects a git or path dependency: the crate is not + // installed where agent mode patches, and the run says why. + _ => { + assert_eq!(code, 1, "{shape:?}: must not report success: {v}"); + assert_eq!(ev["errorCode"], "package_not_installed", "{shape:?}: {ev}"); + assert!( + ev.to_string().contains("path, git or [patch] source"), + "{shape:?}: the detail names the override case: {ev}" + ); + } + } + } +} + +/// #506: `--allow-patch-conflicts` lets apply try a crate the user's own +/// `[patch]` entry replaces, but the registry copy is still not the one +/// cargo builds, so it stays untouched and the run does not succeed. +#[test] +fn allowed_patch_conflict_still_leaves_the_unused_registry_copy() { + for shape in [Shape::ManifestPatch, Shape::ConfigPatch, Shape::Replace] { + let (code, v, lib) = apply(shape, &["--allow-patch-conflicts"]); + assert_eq!(lib, BEFORE, "{shape:?}: {v}"); + assert_eq!(code, 1, "{shape:?}: must not report success: {v}"); + let ev = event(&v, shape); + assert_eq!(ev["errorCode"], "package_not_installed", "{shape:?}: {ev}"); + } +} + +fn event(v: &Value, shape: Shape) -> &Value { + v["events"] + .as_array() + .expect("events array") + .iter() + .find(|e| e["purl"] == PURL) + .unwrap_or_else(|| panic!("{shape:?}: no event for {PURL}: {v}")) +} diff --git a/crates/socket-patch-cli/tests/apply/main.rs b/crates/socket-patch-cli/tests/apply/main.rs index 2abb0ab98..5ff9267f6 100644 --- a/crates/socket-patch-cli/tests/apply/main.rs +++ b/crates/socket-patch-cli/tests/apply/main.rs @@ -4,6 +4,8 @@ //! //! One test binary per command: each module was its own binary. +#[path = "../common/cargo_lock_source.rs"] +mod cargo_lock_source_fixture; #[path = "../common/mod.rs"] mod common; #[path = "../common/rollback_json.rs"] @@ -20,6 +22,7 @@ mod apply_network; mod apply_render; mod apply_silent_staging_errors; mod bun_global_store; +mod cargo_lock_source; mod check_verifies_installed_tree; mod cli_gem_variant_mismatch_policy; mod e2e_safety_advisories; diff --git a/crates/socket-patch-cli/tests/common/cargo_lock_source.rs b/crates/socket-patch-cli/tests/common/cargo_lock_source.rs new file mode 100644 index 000000000..0b9000106 --- /dev/null +++ b/crates/socket-patch-cli/tests/common/cargo_lock_source.rs @@ -0,0 +1,115 @@ +//! A Cargo project whose `Cargo.lock` takes `cfg-if 1.0.4` from crates.io, +//! a `[patch.crates-io]` path override or a git dependency, beside a +//! crates.io copy of the crate in `$CARGO_HOME/registry/src` (#506). Shared +//! by the `apply` and `vex` suites; pull it in with +//! `#[path = "common/cargo_lock_source.rs"] mod cargo_lock_source;`. + +#![allow(dead_code)] + +use std::path::{Path, PathBuf}; + +pub const PURL: &str = "pkg:cargo/cfg-if@1.0.4"; +pub const BEFORE: &[u8] = b"pub fn upstream() -> u32 { 1 }\n"; +pub const AFTER: &[u8] = b"pub fn upstream() -> u32 { 1 }\npub fn socket_patched() {}\n"; +pub const CRATES_IO: &str = "registry+https://github.com/rust-lang/crates.io-index"; +pub const GIT_SOURCE: &str = "git+file:///srv/git/cfg-if#0123456789abcdef0123456789abcdef01234567"; + +/// Where cargo builds `cfg-if` from in the project. +#[derive(Clone, Copy, Debug)] +pub enum Shape { + /// `cfg-if = "=1.0.4"` from crates.io: the registry copy is the build's. + Registry, + /// `[patch.crates-io] cfg-if = { path = "local/cfg-if" }` in `Cargo.toml`. + ManifestPatch, + /// The same `[patch.crates-io]` in `.cargo/config.toml`. + ConfigPatch, + /// `cfg-if = { git = "file:///…" }`, no `[patch]` at all. + GitDependency, + /// `cfg-if = { path = "local/cfg-if" }`, no `[patch]` at all. + PathDependency, + /// `[replace] "cfg-if:1.0.4" = { git = "file:///…" }`: the lock keeps + /// the crates.io entry and names the git copy in its `replace` key. + Replace, +} + +/// A crates.io copy of `cfg-if-1.0.4` in `/registry/src`, +/// holding `lib`. Returns the copy's `src/lib.rs`. +pub fn stage_registry_copy(cargo_home: &Path, lib: &[u8]) -> PathBuf { + let dir = cargo_home.join("registry/src/index.crates.io-1949cf8c6b5b557f/cfg-if-1.0.4"); + std::fs::create_dir_all(dir.join("src")).unwrap(); + std::fs::write( + dir.join("Cargo.toml"), + "[package]\nname = \"cfg-if\"\nversion = \"1.0.4\"\nedition = \"2018\"\n", + ) + .unwrap(); + std::fs::write(dir.join("src/lib.rs"), lib).unwrap(); + dir.join("src/lib.rs") +} + +/// A binary crate depending on `cfg-if 1.0.4` the way `shape` says, with +/// the `Cargo.lock` cargo writes for it. +pub fn write_project(root: &Path, shape: Shape) { + let dep = match shape { + Shape::GitDependency => "cfg-if = { git = \"file:///srv/git/cfg-if\" }", + Shape::PathDependency => "cfg-if = { path = \"local/cfg-if\" }", + _ => "cfg-if = \"=1.0.4\"", + }; + let mut manifest = format!( + "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\ + [dependencies]\n{dep}\n" + ); + let patch = "[patch.crates-io]\ncfg-if = { path = \"local/cfg-if\" }\n"; + match shape { + Shape::ManifestPatch => manifest.push_str(&format!("\n{patch}")), + Shape::ConfigPatch => { + std::fs::create_dir_all(root.join(".cargo")).unwrap(); + std::fs::write(root.join(".cargo/config.toml"), patch).unwrap(); + } + Shape::Replace => manifest + .push_str("\n[replace]\n\"cfg-if:1.0.4\" = { git = \"file:///srv/git/cfg-if\" }\n"), + Shape::Registry | Shape::GitDependency | Shape::PathDependency => {} + } + if matches!( + shape, + Shape::ManifestPatch | Shape::ConfigPatch | Shape::PathDependency + ) { + let fork = root.join("local/cfg-if"); + std::fs::create_dir_all(fork.join("src")).unwrap(); + std::fs::write( + fork.join("Cargo.toml"), + "[package]\nname = \"cfg-if\"\nversion = \"1.0.4\"\nedition = \"2018\"\n", + ) + .unwrap(); + std::fs::write(fork.join("src/lib.rs"), "pub fn user_fork() -> u32 { 7 }\n").unwrap(); + } + std::fs::write(root.join("Cargo.toml"), manifest).unwrap(); + + let source = match shape { + Shape::Registry => format!( + "source = \"{CRATES_IO}\"\nchecksum = \ + \"9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801\"\n" + ), + Shape::ManifestPatch | Shape::ConfigPatch | Shape::PathDependency => String::new(), + Shape::GitDependency => format!("source = \"{GIT_SOURCE}\"\n"), + // What cargo writes for a `[replace]`d crate: the original entry + // keeps its registry `source` and names the replacement, which is + // locked as a package of its own. + Shape::Replace => format!( + "source = \"{CRATES_IO}\"\nreplace = \"cfg-if 1.0.4 ({GIT_SOURCE})\"\n\n\ + [[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\nsource = \"{GIT_SOURCE}\"\n" + ), + }; + std::fs::write( + root.join("Cargo.lock"), + format!( + "# This file is automatically @generated by Cargo.\n\ + # It is not intended for manual editing.\n\ + version = 4\n\n\ + [[package]]\nname = \"app\"\nversion = \"0.1.0\"\ndependencies = [\n \"cfg-if\",\n]\n\n\ + [[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\n{source}" + ), + ) + .unwrap(); + std::fs::create_dir_all(root.join("src")).unwrap(); + std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap(); +} diff --git a/crates/socket-patch-cli/tests/e2e_vex.rs b/crates/socket-patch-cli/tests/e2e_vex.rs index 26e39dd70..6c35ad785 100644 --- a/crates/socket-patch-cli/tests/e2e_vex.rs +++ b/crates/socket-patch-cli/tests/e2e_vex.rs @@ -14,6 +14,8 @@ //! 4. verify-mode against patched files laid on disk //! 5. verify-mode where one patch file is missing → omitted + warning +#[path = "common/cargo_lock_source.rs"] +mod cargo_lock_source; #[path = "common/mod.rs"] mod common; @@ -1870,3 +1872,90 @@ fn find_vexctl_on_path() -> Option { } None } + +// ────────────────────────────────────────────────────────────────────── +// Cargo: verify the copy the build consumes (#506) +// ────────────────────────────────────────────────────────────────────── + +/// Run verify-mode `vex` over an agent record for `cfg-if 1.0.4` whose +/// crates.io copy in the registry cache IS patched, in a project that +/// takes the crate the way `shape` says. Returns (success, stdout, stderr). +fn vex_over_patched_registry_copy(shape: cargo_lock_source::Shape) -> (bool, String, String) { + use cargo_lock_source::{stage_registry_copy, write_project, AFTER, BEFORE, PURL}; + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path().join("app"); + std::fs::create_dir_all(&project).unwrap(); + write_project(&project, shape); + let cargo_home = tmp.path().join("cargo-home"); + stage_registry_copy(&cargo_home, AFTER); + + let mut manifest = PatchManifest::new(); + manifest.patches.insert( + PURL.to_string(), + make_record( + "50650650-0000-4000-8000-000000000506", + "package/src/lib.rs", + compute_git_sha256_from_bytes(BEFORE).as_str(), + compute_git_sha256_from_bytes(AFTER).as_str(), + "GHSA-cfg-if-506", + &["CVE-2024-506"], + ), + ); + write_manifest(&project, &manifest); + + let out = cli() + .args([ + "vex", + "--cwd", + project.to_str().unwrap(), + "--product", + "pkg:cargo/app@0.1.0", + ]) + .env("CARGO_HOME", &cargo_home) + .output() + .expect("invoke vex"); + ( + out.status.success(), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +/// Control: a crate the lock takes from crates.io is attested from its +/// patched registry copy. +#[test] +fn verify_mode_attests_registry_sourced_cargo_crate() { + let (ok, stdout, stderr) = vex_over_patched_registry_copy(cargo_lock_source::Shape::Registry); + assert!(ok, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!( + stdout.contains("GHSA-cfg-if-506") && stdout.contains(cargo_lock_source::PURL), + "the registry copy is the one the build consumes:\n{stdout}" + ); +} + +/// #506: when `Cargo.lock` resolves the crate to a `[patch.crates-io]` +/// path (in `Cargo.toml` or `.cargo/config.toml`), a git or a path source, +/// or a `[replace]` entry (a registry entry whose `replace` names the copy), cargo +/// never builds the registry copy, so its patched bytes attest nothing. +#[test] +fn verify_mode_does_not_attest_cargo_crate_overridden_away_from_registry() { + use cargo_lock_source::Shape; + for shape in [ + Shape::ManifestPatch, + Shape::ConfigPatch, + Shape::GitDependency, + Shape::PathDependency, + Shape::Replace, + ] { + let (ok, stdout, stderr) = vex_over_patched_registry_copy(shape); + assert!( + !stdout.contains("GHSA-cfg-if-506"), + "{shape:?}: must not attest a crate the build takes from elsewhere:\n\ + {stdout}\nstderr:\n{stderr}" + ); + assert!( + !ok, + "{shape:?}: nothing left to attest is a non-zero exit:\n{stderr}" + ); + } +} diff --git a/crates/socket-patch-core/src/crawlers/cargo_crawler.rs b/crates/socket-patch-core/src/crawlers/cargo_crawler.rs index 84e5d6592..a26b5185a 100644 --- a/crates/socket-patch-core/src/crawlers/cargo_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/cargo_crawler.rs @@ -63,6 +63,30 @@ impl CargoCrawler { } } + /// The crates a local Cargo project's build takes from the shared + /// registry cache: one per registry-sourced `Cargo.lock` entry + /// ([`lock_scope`]), the crates [`Self::crawl_all`] looks up there. + /// `None` when the cache is not scoped to a lock (a global run, a + /// `cargo vendor` directory source, no readable `Cargo.lock`). + /// + /// [`Self::find_by_purls`] matches a registry dir by name and version + /// alone, so it also finds a copy another project left behind of a + /// crate this lock takes from a path, git or `[patch]` source. Cargo + /// never builds that copy: callers that patch or attest the build's + /// copy keep only these crates (#506). + pub async fn locked_registry_crates( + &self, + options: &CrawlerOptions, + ) -> Option { + let project_registry = source_roots(options).await.1; + Some(LockedRegistryCrates( + registry_lock_scope(options, project_registry) + .await? + .into_iter() + .collect(), + )) + } + /// Crawl all discovered crate source directories and return every /// package found. /// @@ -84,11 +108,7 @@ impl CargoCrawler { if src_paths.is_empty() { return Vec::new(); } - let scope = if project_registry { - lock_scope(&options.cwd).await - } else { - None - }; + let scope = registry_lock_scope(options, project_registry).await; run_blocking(move || { let mut packages = Vec::new(); @@ -261,6 +281,21 @@ impl CargoCrawler { } } +/// The registry-sourced `(name, version)`s of a project's `Cargo.lock` +/// ([`CargoCrawler::locked_registry_crates`]). +#[derive(Debug, Clone, Default)] +pub struct LockedRegistryCrates(HashSet<(String, String)>); + +impl LockedRegistryCrates { + /// Whether the lock takes `purl`'s `name@version` from a registry. + /// A purl that is not a Cargo one never is. + pub fn contains_purl(&self, purl: &str) -> bool { + crate::utils::purl::parse_cargo_purl(purl).is_some_and(|(name, version)| { + self.0.contains(&(name.into_owned(), version.into_owned())) + }) + } +} + /// [`CargoCrawler::get_crate_source_paths`], and whether those roots are /// the shared registry cache of a local Cargo project (the roots /// [`lock_scope`] narrows). @@ -374,8 +409,25 @@ async fn crates_io_source(cwd: &Path) -> CratesIoSource { run_blocking(move || resolve_for(&cwd, cargo_home.as_deref())).await } +/// The registry crates `options`' build takes from the shared registry +/// cache when its roots are that cache for a local project +/// (`project_registry`, from [`source_roots`]): the one scope +/// [`CargoCrawler::crawl_all`] walks and +/// [`CargoCrawler::locked_registry_crates`] reports. +async fn registry_lock_scope( + options: &CrawlerOptions, + project_registry: bool, +) -> Option> { + if project_registry { + lock_scope(&options.cwd).await + } else { + None + } +} + /// The `(name, version)` of every registry-sourced `[[package]]` in -/// `/Cargo.lock` (`LockedPackage::is_from_registry`), in lock order. +/// `/Cargo.lock` (`LockedPackage::is_from_registry`) that no +/// `[replace]` swaps out, in lock order. /// `None` when there is no readable lock or it is not TOML: the crawl then /// keeps walking the whole cache. async fn lock_scope(cwd: &Path) -> Option> { @@ -386,7 +438,9 @@ async fn lock_scope(cwd: &Path) -> Option> { Some( lock.packages() .iter() - .filter(|p| p.is_from_registry()) + // A `[replace]`d entry keeps its registry `source`, but cargo + // builds the replacement it names, never this registry copy. + .filter(|p| p.is_from_registry() && p.replace.is_none()) .map(|p| (p.name.clone(), p.version.clone())) .collect(), ) @@ -1182,6 +1236,48 @@ version = "fake" assert_eq!(roots, vec![other, crates_io]); } + /// #506: a `[replace]`d entry keeps its registry `source` in the + /// lock, but cargo builds the replacement its `replace` key names, + /// so the registry copy is out of scope for the crawl and for + /// `locked_registry_crates`. + #[tokio::test] + #[serial_test::serial] + async fn replaced_registry_entry_is_out_of_scope() { + let home = tempfile::tempdir().unwrap(); + let (crates_io, _) = cache(home.path()); + stage(&crates_io, "cfg-if", "1.0.4"); + let _g = CargoHome::set(home.path()); + + let project = tempfile::tempdir().unwrap(); + write( + &project.path().join("Cargo.toml"), + "[package]\nname = \"member\"\n", + ); + let git = "git+https://example.com/cfg-if#abc"; + write( + &project.path().join("Cargo.lock"), + &format!( + "{}\n[[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\n\ + source = \"{CRATES_IO}\"\nreplace = \"cfg-if 1.0.4 ({git})\"\n\n\ + [[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\nsource = \"{git}\"\n", + lock(&[ + ("member", "0.1.0", None), + ("serde", "1.0.200", Some(CRATES_IO)) + ]), + ), + ); + + let options = local(project.path()); + let pkgs = CargoCrawler::new().crawl_all(&options).await; + assert_eq!(purls(&pkgs), ["pkg:cargo/serde@1.0.200"]); + let locked = CargoCrawler::new() + .locked_registry_crates(&options) + .await + .expect("a locked project's registry scope"); + assert!(locked.contains_purl("pkg:cargo/serde@1.0.200")); + assert!(!locked.contains_purl("pkg:cargo/cfg-if@1.0.4")); + } + /// A cached dir whose manifest names another crate or version, or /// a lock coordinate that would leave the index dir, is not reported. #[tokio::test] diff --git a/crates/socket-patch-core/src/formats/cargo/mod.rs b/crates/socket-patch-core/src/formats/cargo/mod.rs index 4671b49d7..081b7f230 100644 --- a/crates/socket-patch-core/src/formats/cargo/mod.rs +++ b/crates/socket-patch-core/src/formats/cargo/mod.rs @@ -65,6 +65,10 @@ pub(crate) struct LockedPackage { /// The `dependencies` references as spelled (`"name"`, `"name /// version"`, `"name version (source)"`; see [`parse_ref`]). pub(crate) dependencies: Vec, + /// The `replace` package id (`"name version (source)"`) cargo writes on + /// an entry a `[replace]` override swaps out: the entry keeps its own + /// `source`, but cargo builds the named replacement instead. + pub(crate) replace: Option, } impl LockedPackage { @@ -141,6 +145,7 @@ fn read_packages(root: &Table, spanned: bool) -> Vec<(LockedPackage, Option Vec<(LockedPackage, Option