Skip to content
8 changes: 6 additions & 2 deletions crates/socket-patch-cli/CLI_CONTRACT.md

Large diffs are not rendered by default.

12 changes: 10 additions & 2 deletions crates/socket-patch-cli/src/ecosystem_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ pub async fn cargo_copies_still_patched<'a>(
/// The copies of the Cargo `purls` [`find_all_packages_for_rollback`]
/// locates (the roots rollback restores), plus, with `shadowed_registry`,
/// the shared `$CARGO_HOME/registry/src` copies of a local Cargo project
/// with a `cargo vendor` dir, whose crawl searches only that dir — where
/// whose cargo config replaces crates.io with a `cargo vendor` directory,
/// whose crawl searches only that dir — where
/// an apply from before `cargo vendor` may have left the patch. The prune
/// only reads them (its keep decision); rollback restores them only for a
/// vendored crate whose record it is about to drop (#336).
Expand All @@ -103,7 +104,14 @@ pub async fn find_cargo_copies(
let local = !options.global && options.global_prefix.is_none();
let cargo_project =
options.cwd.join("Cargo.toml").is_file() || options.cwd.join("Cargo.lock").is_file();
if shadowed_registry && local && cargo_project && options.cwd.join("vendor").is_dir() {
if shadowed_registry
&& local
&& cargo_project
&& CargoCrawler::new()
.directory_source(&options.cwd)
.await
.is_some()
{
let registry = CrawlerOptions {
cwd: options.cwd.clone(),
global: true,
Expand Down
14 changes: 14 additions & 0 deletions crates/socket-patch-cli/tests/e2e_cargo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,14 @@ async fn scan_discovers_vendor_crates() {
// a crate source once the root is identified as a Cargo project. A vendored
// project always carries a lockfile, so stage one as the project marker.
std::fs::write(dir.path().join("Cargo.lock"), "version = 3\n").unwrap();
// `cargo vendor`'s source replacement: only a wired tree is where
// cargo builds from (#338).
std::fs::create_dir_all(dir.path().join(".cargo")).unwrap();
std::fs::write(
dir.path().join(".cargo/config.toml"),
"[source.crates-io]\nreplace-with = \"v\"\n[source.v]\ndirectory = \"vendor\"\n",
)
.unwrap();

// Set up vendor directory
let vendor_dir = dir.path().join("vendor");
Expand Down Expand Up @@ -494,6 +502,12 @@ async fn sync_keeps_shared_cache_entry_behind_a_cargo_vendor_dir() {
.unwrap();
let registry = root.join(".cargo/registry/src/index.crates.io-test");
let itoa_old = fake_registry_crate(&registry, "itoa", "1.0.11", patched);
// The source replacement that makes `vendor/` cargo's source (#338).
std::fs::write(
root.join(".cargo/config.toml"),
"[source.crates-io]\nreplace-with = \"v\"\n[source.v]\ndirectory = \"vendor\"\n",
)
.unwrap();

let socket = root.join(".socket");
let itoa = "pkg:cargo/itoa@1.0.11";
Expand Down
96 changes: 96 additions & 0 deletions crates/socket-patch-cli/tests/e2e_safety_cargo_build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,102 @@ fn apply_then_cargo_check_succeeds() {
manifestless_agent_patch_is_not_attested(&consumer, &cargo_home);
}

/// Move the consumer's directory source to `dir` (relative to `root`),
/// wired by `root/.cargo/config.toml`, as `cargo vendor <dir>` lays it
/// out when run there.
fn relocate_vendor(consumer: &Path, root: &Path, dir: &str) {
std::fs::create_dir_all(root.join(dir).parent().unwrap()).unwrap();
std::fs::rename(consumer.join("vendor"), root.join(dir)).unwrap();
std::fs::remove_dir_all(consumer.join(".cargo")).unwrap();
std::fs::create_dir_all(root.join(".cargo")).unwrap();
std::fs::write(
root.join(".cargo/config.toml"),
format!(
"[source.crates-io]\nreplace-with = \"vendored-test\"\n\n\
[source.vendored-test]\ndirectory = \"{dir}\"\n"
),
)
.unwrap();
}

/// Apply in `cwd`, then prove the copy at `vendored` (the directory
/// source cargo builds from) is patched and `cargo check --frozen
/// --offline` accepts it.
fn apply_patches_directory_source(cwd: &Path, vendored: &Path, cargo_home: &Path) {
let (stdout, _stderr) = assert_run_ok(
cwd,
&[
"apply",
"--json",
"--offline",
"--cwd",
cwd.to_str().unwrap(),
],
"socket-patch apply",
);
let env = parse_json_envelope(&stdout);
assert_eq!(env["status"], "success", "{stdout}");
assert_eq!(
std::fs::read_to_string(vendored.join("src/lib.rs")).unwrap(),
PATCHED_LIB_RS,
"the directory source cargo builds from must be patched: {stdout}"
);
let out = cargo_check(cwd, cargo_home);
assert!(
out.status.success(),
"cargo check after apply:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
}

/// #338 shape 1: `cargo vendor third_party/rust` — a directory source
/// with a custom name. Apply must patch that copy, not look for
/// `<cwd>/vendor` and fall back to the registry cache.
#[test]
#[ignore]
fn apply_patches_a_custom_named_vendor_dir() {
if !cargo_e2e_matrix::cargo_available("e2e_safety_cargo_build") {
return;
}
let root = tempfile::tempdir().unwrap();
let consumer = stage_consumer(root.path());
relocate_vendor(&consumer, &consumer, "third_party/rust");
let cargo_home = root.path().join(".cargo-home");
generate_lockfile(&consumer, &cargo_home);
assert!(cargo_check(&consumer, &cargo_home).status.success());
stage_socket_manifest(&consumer);

let vendored = consumer.join("third_party/rust/safety-fixture");
apply_patches_directory_source(&consumer, &vendored, &cargo_home);
}

/// #338 shape 2: apply run from a workspace member, with the vendor
/// directory and its config at the workspace root.
#[test]
#[ignore]
fn apply_from_a_member_patches_the_workspace_vendor_dir() {
if !cargo_e2e_matrix::cargo_available("e2e_safety_cargo_build") {
return;
}
let root = tempfile::tempdir().unwrap();
let ws = root.path().join("ws");
std::fs::create_dir_all(&ws).unwrap();
std::fs::write(
ws.join("Cargo.toml"),
"[workspace]\nmembers = [\"consumer\"]\nresolver = \"2\"\n",
)
.unwrap();
let consumer = stage_consumer(&ws);
relocate_vendor(&consumer, &ws, "vendor");
let cargo_home = root.path().join(".cargo-home");
generate_lockfile(&ws, &cargo_home);
assert!(cargo_check(&consumer, &cargo_home).status.success());
stage_socket_manifest(&consumer);

apply_patches_directory_source(&consumer, &ws.join("vendor/safety-fixture"), &cargo_home);
}

/// Step 5 of the module doc: the agent-mode patch attests only through the
/// manifest; with it deleted nothing in the lockfile / config wires a
/// Socket patch (the `[source.crates-io] replace-with` directory source is
Expand Down
10 changes: 10 additions & 0 deletions crates/socket-patch-cli/tests/e2e_vendor_cargo_build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1005,7 +1005,17 @@ fn agent_to_vendored_rollback_restores_shared_cache(cargo_vendor_dir: bool) {
"vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
if cargo_vendor_dir {
// A wired (but empty) directory source: the project crawl then
// searches only `vendor/` (#338).
std::fs::create_dir_all(proj.join("vendor")).unwrap();
std::fs::create_dir_all(proj.join(".cargo")).unwrap();
let config = proj.join(".cargo/config.toml");
let mut text = std::fs::read_to_string(&config).unwrap_or_default();
text.push_str(
"\n[source.crates-io]\nreplace-with = \"vendored-sources\"\n\n\
[source.vendored-sources]\ndirectory = \"vendor\"\n",
);
std::fs::write(&config, text).unwrap();
}

let (code, stdout, stderr) = run_socket(
Expand Down
9 changes: 8 additions & 1 deletion crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -686,13 +686,20 @@ fn rollback_dispatch_branch_cargo() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write_root_package_json(root);
// Cargo crawler uses the vendor layout when `vendor/` exists.
// Cargo crawler uses the vendor layout when a config wires `vendor/`.
std::fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"t\"\nversion = \"0.0.0\"\n",
)
.unwrap();
let purl = "pkg:cargo/__rollback_dispatch__@1.0.0";
// `cargo vendor`'s source replacement makes `vendor/` cargo's source (#338).
std::fs::create_dir_all(root.join(".cargo")).unwrap();
std::fs::write(
root.join(".cargo/config.toml"),
"[source.crates-io]\nreplace-with = \"v\"\n[source.v]\ndirectory = \"vendor\"\n",
)
.unwrap();
let crate_dir = root.join("vendor").join("__rollback_dispatch__");
std::fs::create_dir_all(crate_dir.join("src")).unwrap();
std::fs::write(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,13 @@ version = "1.0.0"

// Cargo crawler needs a Cargo.toml in cwd to engage.
std::fs::write(tmp.path().join("Cargo.toml"), "[workspace]\n").unwrap();
// `cargo vendor`'s source replacement makes `vendor/` cargo's source (#338).
std::fs::create_dir_all(tmp.path().join(".cargo")).unwrap();
std::fs::write(
tmp.path().join(".cargo/config.toml"),
"[source.crates-io]\nreplace-with = \"v\"\n[source.v]\ndirectory = \"vendor\"\n",
)
.unwrap();

assert_ne!(original.to_vec(), patched.to_vec());
assert_eq!(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,13 @@ async fn gc_scan_crawls_the_unselected_ecosystems() {
let tmp = tempfile::tempdir().unwrap();
stage_project(tmp.path());
let krate = tmp.path().join("vendor").join(INSTALLED_CRATE);
// `cargo vendor`'s source replacement makes `vendor/` cargo's source (#338).
std::fs::create_dir_all(tmp.path().join(".cargo")).unwrap();
std::fs::write(
tmp.path().join(".cargo/config.toml"),
"[source.crates-io]\nreplace-with = \"v\"\n[source.v]\ndirectory = \"vendor\"\n",
)
.unwrap();
std::fs::create_dir_all(&krate).unwrap();
std::fs::write(
krate.join("Cargo.toml"),
Expand Down
108 changes: 102 additions & 6 deletions crates/socket-patch-core/src/crawlers/cargo_crawler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ use std::path::{Path, PathBuf};
use super::listing::list_dir_sync;
use super::types::{CrawledPackage, CrawlerOptions};
use crate::formats::cargo::manifest::package_name_version;
use crate::formats::cargo::source_replacement::{
self, config_layer_paths, ConfigLayer, CratesIoSource,
};
use crate::formats::cargo::CargoLock;
use crate::patch::path_safety;
use crate::utils::fs::{is_dir, is_dir_sync, run_blocking};
Expand Down Expand Up @@ -46,6 +49,18 @@ impl CargoCrawler {
Ok(source_roots(options).await.0)
}

/// The directory a local Cargo project in `cwd` builds its crates.io
/// dependencies from, when a cargo config replaces crates.io with a
/// directory source (`cargo vendor`): the crawl then searches only that
/// directory, never the shared registry cache. The directory need not
/// exist.
pub async fn directory_source(&self, cwd: &Path) -> Option<PathBuf> {
match crates_io_source(cwd).await {
CratesIoSource::Directory(dir) => Some(dir),
CratesIoSource::Registry | CratesIoSource::Other(_) => None,
}
}

/// Crawl all discovered crate source directories and return every
/// package found.
///
Expand Down Expand Up @@ -267,14 +282,78 @@ async fn source_roots(options: &CrawlerOptions) -> (Vec<PathBuf>, bool) {
return (Vec::new(), false);
}

// Cargo project: prefer a vendored source tree if present, else
// fall back to the global registry cache.
let vendor_dir = options.cwd.join("vendor");
if is_dir(&vendor_dir).await {
return (vec![vendor_dir], false);
// Cargo project: crawl what cargo builds from. A config that
// replaces crates.io with a directory source (`cargo vendor`) names
// that directory, wherever it is; without one, cargo reads the
// registry cache, even when an unwired `vendor/` sits beside the
// manifest (#338). A replacement directory that is missing yields
// nothing: cargo can't build from the cache either.
match crates_io_source(&options.cwd).await {
CratesIoSource::Directory(dir) => {
if is_dir(&dir).await {
(vec![dir], false)
} else {
(Vec::new(), false)
}
}
CratesIoSource::Registry | CratesIoSource::Other(_) => {
(CargoCrawler::get_registry_src_paths().await, true)
}
}
}

/// `path` with each `..` removing the component before it.
fn fold_parent_dirs(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for c in path.components() {
match c {
std::path::Component::ParentDir => {
out.pop();
}
std::path::Component::CurDir => {}
other => out.push(other),
}
}
out
}

(CargoCrawler::get_registry_src_paths().await, true)
/// Where cargo takes crates.io packages from for a build in `cwd`: the
/// source replacement of the cargo configs it reads there (every
/// ancestor's `.cargo/config*`, then `$CARGO_HOME`'s). An unreadable
/// config is skipped.
async fn crates_io_source(cwd: &Path) -> CratesIoSource {
// The logical path, not the canonical one: the crawled roots are
// reported under it, as the `<cwd>`-joined roots always were. The
// config walk uses it with `..` folded, so `a/b/../proj` reads
// `a/proj`'s ancestors, not `a/b`'s; a directory under the folded
// cwd is reported back under the cwd as given, which path-scoped
// commands strip as a prefix.
let given = std::path::absolute(cwd).unwrap_or_else(|_| cwd.to_path_buf());
let folded = fold_parent_dirs(&given);
let cargo_home = CargoCrawler::cargo_home();
run_blocking(move || {
let files = config_layer_paths(&folded, cargo_home.as_deref(), |p| p.is_file());
let texts: Vec<(PathBuf, String)> = files
.into_iter()
.filter_map(|(file, base)| {
crate::utils::fs::read_regular_to_string_sync(&file)
.ok()
.map(|t| (base, t))
})
.collect();
let layers: Vec<ConfigLayer<'_>> = texts
.iter()
.map(|(base, text)| ConfigLayer { base, text })
.collect();
match source_replacement::crates_io_source(&layers) {
CratesIoSource::Directory(dir) => match dir.strip_prefix(&folded) {
Ok(rel) => CratesIoSource::Directory(given.join(rel)),
Err(_) => CratesIoSource::Directory(dir),
},
other => other,
}
})
.await
}

/// The `(name, version)` of every registry-sourced `[[package]]` in
Expand Down Expand Up @@ -390,6 +469,18 @@ impl Default for CargoCrawler {

#[cfg(test)]
mod tests {
/// `<root>/.cargo/config.toml` replacing crates.io with `<root>/vendor`,
/// as `cargo vendor` asks: only a wired tree is cargo's source (#338).
fn wire_vendor(root: &Path) {
std::fs::create_dir_all(root.join(".cargo")).unwrap();
std::fs::write(
root.join(".cargo").join("config.toml"),
"[source.crates-io]\nreplace-with = \"vendored-sources\"\n\n\
[source.vendored-sources]\ndirectory = \"vendor\"\n",
)
.unwrap();
}

use super::*;

#[test]
Expand Down Expand Up @@ -569,6 +660,7 @@ mod tests {
)
.await
.unwrap();
wire_vendor(dir.path());

let crawler = CargoCrawler::new();
let options = CrawlerOptions {
Expand Down Expand Up @@ -622,6 +714,7 @@ mod tests {
tokio::fs::write(dir.path().join("Cargo.lock"), "version = 3\n")
.await
.unwrap();
wire_vendor(dir.path());

let crawler = CargoCrawler::new();
let options = CrawlerOptions {
Expand Down Expand Up @@ -1144,6 +1237,7 @@ version = "fake"
write(&vendored.path().join("Cargo.lock"), &locked);
stage(&vendored.path().join("vendor"), "unlocked", "1.0.0");
mkdir(&vendored.path().join("vendor").join(".hidden"));
wire_vendor(vendored.path());
assert_eq!(
purls(&crawler.crawl_all(&local(vendored.path())).await),
["pkg:cargo/unlocked@1.0.0"]
Expand Down Expand Up @@ -1256,6 +1350,7 @@ version = "fake"
let mut perms = PermGuard::default();
let mut rng = Rng::new(7);
write(&tmp.path().join("Cargo.toml"), "[workspace]\n");
wire_vendor(tmp.path());
tree(
&mut rng,
&tmp.path().join("vendor"),
Expand Down Expand Up @@ -1332,6 +1427,7 @@ version = "fake"
tokio::fs::create_dir_all(&left).await.unwrap();
let fifo = left.join("Cargo.toml");
make_fifo(&fifo);
wire_vendor(dir.path());
// A readable crate beside the FIFO proves the crawl continues.
let serde_dir = vendor.join("serde");
tokio::fs::create_dir_all(&serde_dir).await.unwrap();
Expand Down
Loading
Loading