Skip to content

Commit d129c70

Browse files
Never read or rewrite project files outside the git repository root (#2025)
* Claim only this project's vendor paths when rewriting wiring `parse_vendor_path` found the `.socket/vendor/` anchor anywhere in a string, so a lock entry naming a sibling checkout's store (`file:../.socket/vendor/npm/<uuid>/x.tgz`) read as ours to every vendored writer, reverter and GC, while discovery's `vendor_ref` already required a root-anchored path. With the same patch vendored in both checkouts, `vendor --revert` restored our recorded original over the other project's wiring. The ownership parse is now root-anchored and shared with discovery (`vendor_rest_from`): only `.`/`..` may precede the anchor and they must land exactly on the project root (`parse_vendor_path_from` for a file below the root). Yarn berry locators cut their `name@` first (`parse_vendor_locator`); pnpm <= 8 keeps its machine-absolute specifier (`parse_vendor_path_or_machine_absolute`), which pnpm itself writes. Readers that only ask "is this a vendored spec" (the lock inventory, composer/pnpm registry listings, the hosted-wiring probe, the reference scan behind repair and the orphan sweep) use `recognize_vendor_path_anywhere`, which nothing rewrites by. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Never read project files outside the repository root One shared bound (`utils::repo_root::ReadBoundary`): the git checkout enclosing the project, else the project itself; `..` and symlinked levels are resolved before the check. `bounded_project_path` refuses a climbing or absolute project-relative path as `NotFound` (a reader sees a missing file), and `is_refused_read` lets a writer refuse instead. - go.work: an ancestor's or an absolute `GOWORK` outside the root is only stat'ed; the workspace is Invalid, so writers refuse and discovery withholds. `use` members and `replace` directories outside the root read as missing (the selection stays Unknown). - ProjectView reads (discovery, lock inventory, requirements walks, yarn file:/portal:/link: copies) and the hosted restore's staged reads are bounded; the requirements planner and the lock flavors' out-of-root pin check refuse `pypi_requirements_outside_root`, the restore refuses with the reason. - NuGet parent configs above the root are not read; vendoring refuses and hosted mode skips the dep with `redirect_nuget_inherited_config_outside_root` (CLI_CONTRACT.md). Fixtures that model files beside the project inside a checkout now create the `.git` they assumed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Bound upward workspace and settings lookups to the repository root Inside a git checkout, the walks that search a project's ancestors for a workspace root or a settings file never read above the repository root (`repo_root::upward_bound`). When a file the walk would read sits above it (stat only, never in the home directory or above), the tool may honor it, so writers refuse with the existing workspace codes and readers withhold: npm/yarn/Bun/pnpm/vlt governing roots and stray member locks, the governing pnpm-workspace.yaml (and its gitBranchLockfile and restore settings), uv workspace roots and uv settings, the cargo workspace root, `.cargo/config` chain and `[patch]` paths, `.yarnrc.yml` linker/store/env files, Berry registry settings, yarn classic rc files and linked members, and Maven/Gradle/sbt build roots. Hosted cargo refuses with the new `redirect_cargo_config_outside_root`. Without a checkout every lookup behaves as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Make the repository-root bound a workspace-only mode Owner clarification: the bound applies to workspace runs only; a global run (`--global` / `-g`, `--global-prefix`) must read anywhere on the system. `ReadScope { Workspace, Global }` is chosen once from the CLI flags (`GlobalArgs::read_scope`, `CrawlerOptions::read_scope`) and passed to every bounded read; `ReadBoundary` is now `Workspace { root } | Unbounded`, and `ReadBoundary::of`, `upward_bound`, `upward_readable` and `bounded_project_path` take the scope explicitly instead of inferring it. Global-reachable paths take the run's scope: the cargo crawler's source replacement and `config_layer_paths`, agent-mode cargo build-cache invalidation from apply/rollback, the npm crawler's relocated yarn store, apply's PnP gate, `YarnEnv`, `go_workspace::resolve_in` and Gradle `build_roots`. Hosted, vendored, VEX discovery/verify, the lock inventory and the upstream restore never run globally and pass `Workspace`. CLI_CONTRACT.md documents the distinction. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Thread the read scope into the npm install-tree lookups The last lookups without the run's mode now get it, so a global run is unbounded and a workspace run stays inside the repository root: - `NpmCrawler` stores its scope (`for_options`, `with_scope`; `new()` is a workspace crawler). `find_by_purls` and local `node_modules` discovery pass it to the relocated yarn-store probe; the CLI's npm crawls and resolvers build the crawler from their `CrawlerOptions`. - The agent-mode engines (`apply_package_patch`, `rollback_package_patch`, `rollback_store_copies_holding_patch`, `gem_ext::apply_gem_package_patch`) take the scope from the CLI and thread it into the store fan-out (`find_store_peer_variant_copies`) and the shared-store write guard (`shared_store_of*`). Test: a yarn project in a git checkout configured by a `.yarnrc.yml` above it; the global scope sees the relocated store everywhere, the workspace scope nowhere. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Fix the review findings on the repository-root bound - Resolve paths physically (canonicalize, else component by component), so `link/../x` climbs from the link's target as the OS does. - Check every project-file read on its real path, not only climbing or absolute names: an in-tree name through an outward symlink is refused. - Agent-mode cargo: `apply` / `apply --check` refuse Cargo patches when a `.cargo/config(.toml)` above the repository may replace crates.io (`cargo_config_outside_root`); build-cache invalidation names that file, whose target dir was not read. - `with_store_peer_variant_copies` takes the run's scope, so a global `apply --check` sees the copies a global apply writes. - go.work / go native vendor / NuGet parent configs use upward-lookup semantics: without a checkout, as before; drop the test `.git` dirs that only worked around it. - An ancestor `pnpm-workspace.yaml` at or above home never governs. - Drive-absolute pnpm <= 8 specifiers are machine-absolute on every OS. - `NpmCrawler` keeps one scope source (its own, from its options). - Look up the ancestor uv config once; compute the go native vendor and vex node-resolution bounds once per check / package. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Refuse out-of-root symlinked hosted targets; keep fixtures in the repo The repository-root bound made a hosted candidate lock symlinked out of the root (uv.lock, package-lock.json, vlt-lock.json -> ../shared/) read as missing on disk, so `scan --mode hosted` exited 0 having silently skipped a lock the package manager still honors. A disk read the bound refuses on a symlinked candidate now lands in `symlinked_reads`, and the guard checks those on every view, so the run refuses with redirect_symlinked_file_unsupported as before. Three e2e fixtures relied on reading a file outside any repository: - yarn-classic vendor symlinked lock: keep the link target inside the project, as vendor_failure_paths already does; - yarn-classic `file:../ext/lpdir` copy and the pip `-r ../shared/base.txt` include: make the temp dir the repository, so the sibling stays outside the project but inside the repository (the monorepo layout these tests model). Out-of-repository includes are covered by pypi_requirements::tests::includes_outside_the_repository_are_never_read. Co-Authored-By: Claude <noreply@anthropic.com> * Refuse hosted candidates reached through an outward directory link Address Bugbot on 8250521: the hosted arm recorded a bound-refused candidate only when the file itself was a link, so a candidate whose real path left the repository through a directory link on its path (`sub -> ../outside/sub`, then `sub/package-lock.json`) still read as missing and hosted skipped a lock the package manager follows. Every bound-refused candidate read is now recorded (candidate names never climb, so only a link can take one out of the root), and the guard names the directory-link case in its own message under the same code. Co-Authored-By: Claude <noreply@anthropic.com> * Revert "Refuse hosted candidates reached through an outward directory link" This reverts commit 230cba3. Refusing every bound-refused candidate read also refuses install-state reads such as node_modules/.modules.yaml when node_modules links out of the repository, which broke the vlt heal tests (in_process_redirect vlt::scan_redirect_vlt_heal_undeterminable_*, vlt::scan_redirect_vlt_heal_invalidation_failure_warns). The directory- link case Bugbot raised needs the refusal limited to rewrite targets (locks and manifests), not install state; left for a follow-up round. Co-Authored-By: Claude <noreply@anthropic.com> * Refuse hosted rewrite targets reached through an outward dir link Re-applies 230cba3 (reverted in f28eaef because it also refused install state such as `node_modules/.modules.yaml` behind a linked `node_modules`, breaking the vlt heal tests), with the refusal limited to rewrite targets: a bound-refused candidate read counts as a linked read when the file itself is a link, or when it is not under a `node_modules/` segment. A lock or manifest whose real path leaves the repository through a directory link (`sub -> ../outside/sub`, then `sub/package-lock.json`) is refused by the guard instead of read as absent while the package manager still follows the link. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Crawl no Cargo sources under a config above the repository root A project crawl stops the cargo config chain at the git root, so a .cargo/config.toml above it that replaces crates.io with a directory source went unread and the crawler fell through to the registry cache. vex could then verify a patched shared registry copy and emit not_affected while cargo builds the unpatched vendor directory, and rollback could restore a copy apply refuses to touch. Fail closed in source_roots instead, matching apply's cargo_config_outside_root gate. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 32e1630 commit d129c70

70 files changed

Lines changed: 4118 additions & 597 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎crates/socket-patch-cli/CLI_CONTRACT.md‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -661,7 +661,9 @@ are patched by `socket-patch apply` like every other ecosystem:
661661
--check` is a read-only audit of the committed redirect.
662662
The `go.work` in effect is resolved as go resolves it: `GOWORK` naming an absolute file, none under
663663
`GOWORK=off`, else (unset, empty or `auto`) the nearest `go.work` at or above the project; a relative
664-
`GOWORK` is invalid to go, so nothing is written or attested. A `-modfile` in `GOFLAGS` (#1799)
664+
`GOWORK` is invalid to go, so nothing is written or attested. A `go.work` outside the repository
665+
root is never read and refuses the same way (see "Reads bounded to the repository root").
666+
A `-modfile` in `GOFLAGS` (#1799)
665667
that names any file other than the project's `go.mod` (a relative one is taken from the project
666668
directory) makes go build from that file and ignore `go.mod`'s replaces, and in workspace mode go
667669
refuses `-modfile` outright: `apply`, `vendor` and `scan --mode hosted` write nothing
@@ -903,6 +905,19 @@ socket-patch creates `.socket/` and everything under it itself and never writes
903905

904906
Not covered: the agent-mode blob cache and manifest are checked from `.socket` down, so a `.socket` that is itself a link still redirects those writes (deciding that at lock time is deferred).
905907

908+
### Reads bounded to the repository root
909+
910+
**Workspace runs only.** The bound applies to project-scoped runs (the default `--cwd` project). A global run (`--global` / `-g`, or `--global-prefix`, on any command) reads anywhere on the system, so it can scan global `node_modules`, `site-packages`, `~/.m2`, the cargo and Go caches, and their settings wherever they live: nothing in this section applies to it. The mode is fixed once per run from those flags and passed to every bounded read (core `utils::repo_root::ReadScope`: `Workspace` or `Global`; `ReadBoundary::Workspace { root }` or `ReadBoundary::Unbounded`). It is never inferred from the filesystem. Hosted and vendored mode refuse global runs anyway, and VEX discovery and verification do not run in them.
911+
912+
VEX discovery, the lock inventory, hosted mode and vendored mode never read a PROJECT file outside the repository root: the git checkout enclosing the project (the nearest `.git`, by the same rules as the repository lookup), or the project directory itself when there is none. One check (core `utils::repo_root::ReadBoundary`) decides it on the REAL path: every project-file read is checked, an in-tree name included, and a level that resolves outside through `..` or a symlink (a `..` after a link climbs from the link's target, as the OS resolves it) counts as outside. A refused read behaves like a missing file to a reader; a writer refuses with the reason (`… is outside the repository root …; socket-patch does not read project files outside it`). Machine-level tool configuration (the user `~/.npmrc` / `.yarnrc.yml` / `NuGet.Config`, the `GOENV` file, `$CARGO_HOME`, module and package caches) is not a project file and is read as before.
913+
914+
- **Go** (an upward lookup: without a checkout, as before): a `go.work` outside the root (an ancestor's, or one an absolute `GOWORK` names) is only noticed, never read: the workspace is invalid, so vendored and hosted writes refuse with the Go workspace refusal and discovery withholds every Go ref. Inside the root, a `use` member or a `replace` directory outside it reads as missing, so the selection it would decide is unknown.
915+
- **Requirements includes**: a `-r` include outside the root is never read. Discovery and the lock inventory see it as missing; vendored mode refuses `pypi_requirements_outside_root`, since the pins pip installs from it are unknowable.
916+
- **NuGet parent configs** (an upward lookup: without a checkout, as before): a `nuget.config` in a directory above the root is never read. Vendoring refuses (`… also merges <path>, which is outside the repository root …`) and hosted mode skips each NuGet dep with `redirect_nuget_inherited_config_outside_root`, writing nothing for it.
917+
- **Relative and absolute references read through the project view** (yarn `file:` / `portal:` / `link:` copies, include chains, any `../` path a lockfile names) and the hosted upstream restore's staged reads: outside the root they read as missing, and the restore refuses.
918+
- **Upward lookups** (a workspace root or settings file the package manager searches the project's ancestors for): inside a git checkout none reads above the repository root. When one of the files the lookup would read exists above it (a stat, never a read, and never in the home directory or above), the tool may honor it, so writers refuse and readers withhold: a lockless npm-family member or a member's own npm/Bun/vlt lock under a `package.json`, `pnpm-workspace.yaml` or `vlt.json` above (`redirect_workspace_lockfile_elsewhere` in hosted mode, `vendor_lockfile_missing` in vendored mode, unattested in discovery); the nearest `pnpm-workspace.yaml` above (but never one in the home directory or above it) counts as governing (`redirect_pnpm_settings_elsewhere`, `vendor_pnpm_settings_elsewhere`, and a `gitBranchLockfile` that may be on); a uv workspace `pyproject.toml` above (`redirect_workspace_lockfile_elsewhere`, `pypi_uv_workspace_unsupported`) or uv settings above (`pypi_uv_ancestor_settings`; hosted `redirect_uv_project_unsupported` / `redirect_uv_script_unsupported` / `redirect_uv_lock_unsupported`); a `Cargo.toml` above (`cargo_manifest_not_workspace_root`) or a `.cargo/config(.toml)` above (`user_authored_patch_entry` in vendored mode; hosted mode refuses the run with `redirect_cargo_config_outside_root`; agent-mode `apply` and `apply --check` refuse a run with Cargo patches with `cargo_config_outside_root`, naming the file, since it may replace crates.io while apply would patch the registry cache, and the cargo build-cache invalidation warns `cargo_build_cache_stale` that a `build.target-dir` / `build.build-dir` set there was not read); a `.yarnrc.yml` above (its `nodeLinker` reads as unknown, so PnP; no `pnpmStoreFolder` store is admitted; the Berry upstream restore refuses the registry lookup); a yarn classic `.yarnrc` / `.npmrc` above, or a workspace member linked from outside the repository (`redirect_yarn_classic_offline_mirror`, naming the file or member); a `pom.xml`, Gradle settings or `build.sbt` above (`not_build_root`). Gradle settings above are not analysed by the crawler, an `injectEnvironmentFiles` dotenv file outside the repository (other than beside the home rc) injects nothing, a cargo `[patch]` path outside it reads as unknown (`user_authored_patch_entry`), and agent mode's build-cache invalidation stays inside it. Without a git checkout every upward lookup walks on as before.
919+
- **Vendored ownership**: writers, reverters and the GC claim a lockfile reference only when it is root-anchored into THIS project's `.socket/vendor/` (`./.socket/vendor/…`, or `../` from a file below the root back to exactly the root). A sibling checkout's `../.socket/vendor/<eco>/<uuid>/…`, even for the same patch uuid, is never rewritten, reverted or deleted; the revert leaves it with `vendor_lock_entry_drifted` and keeps the artifact. The one exception is pnpm <= 8, which records override specifiers as machine-absolute paths itself: an absolute `…/.socket/vendor/npm/<uuid>/<leaf>` naming the entry's own uuid and leaf is still the entry's.
920+
906921
## Vendor command contract
907922

908923
`vendor` converts the hosted patches pinned in the project's lockfiles into committable

‎crates/socket-patch-cli/src/args.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,14 @@ impl GlobalArgs {
447447
self.global || self.global_prefix.is_some()
448448
}
449449

450+
/// The read scope this run's mode fixes for every bounded read: a
451+
/// global run (`--global` / `--global-prefix`) reads anywhere on the
452+
/// system; a project run never reads a project file outside the
453+
/// repository root (see `socket_patch_core::utils::repo_root`).
454+
pub(crate) fn read_scope(&self) -> socket_patch_core::utils::repo_root::ReadScope {
455+
socket_patch_core::utils::repo_root::ReadScope::of_global(self.is_global())
456+
}
457+
450458
/// Whether `--ecosystems` selects `eco` (every ecosystem when unset or
451459
/// empty). The names are validated at parse time, so this is an exact
452460
/// match.

‎crates/socket-patch-cli/src/commands/apply.rs‎

Lines changed: 121 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -471,13 +471,17 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 {
471471
// The same PnP gate `apply` runs: a PnP tree hides every npm copy, so
472472
// without it each npm patch would read as not installed and pass calm.
473473
if matches!(
474-
detect_npm_pkg_manager(&args.common.cwd),
474+
detect_npm_pkg_manager(args.common.read_scope(), &args.common.cwd),
475475
NpmPkgManager::YarnBerryPnP
476476
) && eco_in_local_scope(&args.common, Ecosystem::Npm)
477477
&& manifest_targets_npm(&tree)
478478
{
479479
return refuse_yarn_pnp(args);
480480
}
481+
// The same cargo gate `apply` runs (see [`cargo_config_outside_root`]).
482+
if let Some(why) = cargo_config_outside_root(args, &tree) {
483+
return refuse_cargo_config_outside_root(args, &why, &[]);
484+
}
481485
let mut in_sync: Vec<String> = Vec::new();
482486
let mut not_installed: Vec<String> = Vec::new();
483487
if !tree.patches.is_empty() {
@@ -948,6 +952,52 @@ pub(crate) fn result_to_event(result: &ApplyResult, dry_run: bool) -> PatchEvent
948952
/// True when the manifest records at least one npm patch — the only kind a
949953
/// PnP layout can block (a polyglot repo's pypi/gem/go patches live outside
950954
/// `node_modules` and apply fine).
955+
/// The refusal code of [`cargo_config_outside_root`].
956+
const CARGO_CONFIG_OUTSIDE_ROOT: &str = "cargo_config_outside_root";
957+
958+
/// Why a workspace apply cannot patch this project's Cargo crates: a
959+
/// `.cargo/config` / `config.toml` sits above the repository root (stat
960+
/// only; it is never read), and cargo merges it into the build, so it may
961+
/// replace crates.io with another source while apply patches the shared
962+
/// registry cache. `None` for a global run, a run without Cargo patches
963+
/// or a project that is no Cargo project.
964+
fn cargo_config_outside_root(args: &ApplyArgs, manifest: &PatchManifest) -> Option<String> {
965+
let cwd = &args.common.cwd;
966+
if !eco_in_local_scope(&args.common, Ecosystem::Cargo)
967+
|| !manifest
968+
.patches
969+
.keys()
970+
.any(|p| Ecosystem::from_purl(p) == Some(Ecosystem::Cargo))
971+
|| !(cwd.join("Cargo.toml").is_file() || cwd.join("Cargo.lock").is_file())
972+
{
973+
return None;
974+
}
975+
socket_patch_core::vendor::cargo_config::config_above_repo(cwd).map(|why| {
976+
format!(
977+
"{why}: it may replace crates.io with another source, so the registry copy apply \
978+
patches may not be what cargo builds; move the settings into the repository's \
979+
.cargo/config.toml (or run with --global to patch the registry cache anyway)"
980+
)
981+
})
982+
}
983+
984+
/// Print the [`cargo_config_outside_root`] refusal and return the exit
985+
/// code.
986+
fn refuse_cargo_config_outside_root(args: &ApplyArgs, why: &str, skipped: &[PatchEvent]) -> i32 {
987+
if args.common.json {
988+
let mut env = Envelope::new(Command::Apply);
989+
env.dry_run = args.common.dry_run;
990+
for skip in skipped {
991+
env.record(skip.clone());
992+
}
993+
env.mark_error(EnvelopeError::new(CARGO_CONFIG_OUTSIDE_ROOT, why));
994+
println!("{}", env.to_pretty_json());
995+
} else {
996+
eprintln!("Error: {why}");
997+
}
998+
1
999+
}
1000+
9511001
fn manifest_targets_npm(manifest: &PatchManifest) -> bool {
9521002
manifest
9531003
.patches
@@ -1061,7 +1111,7 @@ pub async fn run(args: ApplyArgs) -> i32 {
10611111
// filter excluding npm never touches it.
10621112
if eco_in_local_scope(&args.common, Ecosystem::Npm)
10631113
&& matches!(
1064-
detect_npm_pkg_manager(&args.common.cwd),
1114+
detect_npm_pkg_manager(args.common.read_scope(), &args.common.cwd),
10651115
NpmPkgManager::YarnBerryPnP
10661116
)
10671117
{
@@ -1388,7 +1438,17 @@ pub(crate) async fn run_locked(
13881438
// informational note; the substantive safety is core's rename-over
13891439
// write (`utils::fs::atomic_write_bytes` never touches the store's
13901440
// shared inode).
1391-
match detect_npm_pkg_manager(&args.common.cwd) {
1441+
// A cargo config above the repository root is never read in a
1442+
// workspace run; it may move crates.io elsewhere, so the Cargo patches
1443+
// refuse instead of patching the registry cache blind.
1444+
if let Some(why) = cargo_config_outside_root(&args, &manifest) {
1445+
let code = refuse_cargo_config_outside_root(&args, &why, &user_patch_skips);
1446+
return ApplyRunReport {
1447+
skipped: user_patch_skips,
1448+
..ApplyRunReport::run_failure(code, CARGO_CONFIG_OUTSIDE_ROOT, why)
1449+
};
1450+
}
1451+
match detect_npm_pkg_manager(args.common.read_scope(), &args.common.cwd) {
13921452
NpmPkgManager::YarnBerryPnP => {
13931453
if eco_in_local_scope(&args.common, Ecosystem::Npm) && manifest_targets_npm(&manifest) {
13941454
let code = refuse_yarn_pnp_with_skips(&args, &user_patch_skips);
@@ -1512,6 +1572,7 @@ pub(crate) async fn run_locked(
15121572
});
15131573
cache_warnings.extend(
15141574
socket_patch_core::utils::cargo_build_cache::invalidate_project(
1575+
args.common.read_scope(),
15151576
&args.common.cwd,
15161577
rewritten.map(|r| r.package_key.as_str()),
15171578
)
@@ -2712,6 +2773,7 @@ async fn apply_patches_inner(
27122773
// extension, or is refused with nothing written (#1996).
27132774
let result = if Ecosystem::from_purl(purl) == Some(Ecosystem::Gem) {
27142775
let applied = gem_ext::apply_gem_package_patch(
2776+
args.common.read_scope(),
27152777
variant_purl,
27162778
pkg_path,
27172779
&patch.files,
@@ -2735,6 +2797,7 @@ async fn apply_patches_inner(
27352797
applied.result
27362798
} else {
27372799
apply_package_patch(
2800+
args.common.read_scope(),
27382801
variant_purl,
27392802
pkg_path,
27402803
&patch.files,
@@ -2892,6 +2955,7 @@ async fn apply_patches_inner(
28922955
}
28932956
None => {
28942957
apply_package_patch(
2958+
args.common.read_scope(),
28952959
purl,
28962960
pkg_path,
28972961
&patch.files,
@@ -3423,6 +3487,7 @@ async fn apply_maven_base(m: &MavenBase<'_>) -> MavenApplied {
34233487
}
34243488
attempted = true;
34253489
let mut result = apply_package_patch(
3490+
args.common.read_scope(),
34263491
variant,
34273492
&dir,
34283493
&files,
@@ -3443,6 +3508,7 @@ async fn apply_maven_base(m: &MavenBase<'_>) -> MavenApplied {
34433508
if copy_attempted && !absent.is_empty() {
34443509
attempted = true;
34453510
let result = apply_package_patch(
3511+
args.common.read_scope(),
34463512
variant,
34473513
copy,
34483514
&absent,
@@ -3743,6 +3809,58 @@ mod tests {
37433809
AppliedVia as CoreAppliedVia, ApplyResult, VerifyResult, VerifyStatus,
37443810
};
37453811

3812+
/// Review finding 3: a workspace apply of Cargo patches refuses when a
3813+
/// `.cargo/config.toml` above the repository root (never read) may
3814+
/// replace crates.io; a global run, or one without it, does not.
3815+
#[test]
3816+
fn cargo_patches_refuse_under_a_cargo_config_above_the_repository() {
3817+
#[derive(clap::Parser)]
3818+
struct Wrap {
3819+
#[command(flatten)]
3820+
args: ApplyArgs,
3821+
}
3822+
let tmp = tempfile::tempdir().unwrap();
3823+
let outer = std::fs::canonicalize(tmp.path()).unwrap();
3824+
let app = outer.join("repo");
3825+
std::fs::create_dir_all(app.join(".git")).unwrap();
3826+
std::fs::write(app.join("Cargo.toml"), "[package]\nname = \"app\"\n").unwrap();
3827+
let record: socket_patch_core::manifest::schema::PatchRecord =
3828+
serde_json::from_value(serde_json::json!({
3829+
"uuid": "11111111-1111-4111-8111-111111111111",
3830+
"exportedAt": "",
3831+
"files": {},
3832+
"vulnerabilities": {},
3833+
"description": "",
3834+
"license": "",
3835+
"tier": "free",
3836+
}))
3837+
.unwrap();
3838+
let mut manifest = PatchManifest::default();
3839+
manifest
3840+
.patches
3841+
.insert("pkg:cargo/serde@1.0.0".to_string(), record);
3842+
let parse = |extra: &[&str]| {
3843+
let cwd = app.display().to_string();
3844+
let mut argv = vec!["t", "--cwd", cwd.as_str()];
3845+
argv.extend_from_slice(extra);
3846+
<Wrap as clap::Parser>::try_parse_from(argv).unwrap().args
3847+
};
3848+
assert_eq!(cargo_config_outside_root(&parse(&[]), &manifest), None);
3849+
std::fs::create_dir_all(outer.join(".cargo")).unwrap();
3850+
std::fs::write(
3851+
outer.join(".cargo/config.toml"),
3852+
"[source.crates-io]\nreplace-with = \"v\"\n[source.v]\ndirectory = \"vendor\"\n",
3853+
)
3854+
.unwrap();
3855+
let why = cargo_config_outside_root(&parse(&[]), &manifest).unwrap();
3856+
assert!(why.contains(".cargo/config.toml"), "{why}");
3857+
assert!(why.contains("outside the repository"), "{why}");
3858+
assert_eq!(
3859+
cargo_config_outside_root(&parse(&["--global"]), &manifest),
3860+
None
3861+
);
3862+
}
3863+
37463864
/// A new-file collision's empty expected hash reads `(new file)` in the
37473865
/// verbose summary; a real hash is shown as is.
37483866
#[test]

‎crates/socket-patch-cli/src/commands/rollback.rs‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3069,6 +3069,7 @@ pub(crate) async fn rollback_patches_inner(
30693069
for path in &found {
30703070
for copy in
30713071
socket_patch_core::crawlers::npm_crawler::find_store_peer_variant_copies(
3072+
common.read_scope(),
30723073
path,
30733074
)
30743075
.await
@@ -3277,6 +3278,7 @@ pub(crate) async fn rollback_patches_inner(
32773278
Some(r) => r,
32783279
None => {
32793280
let mut result = rollback_package_patch(
3281+
common.read_scope(),
32803282
purl,
32813283
pkg_path,
32823284
&patch.files,
@@ -3318,6 +3320,7 @@ pub(crate) async fn rollback_patches_inner(
33183320
// isolated-store entry, #1084) is restored here, or fails the
33193321
// run and keeps the record, before the record is dropped.
33203322
match socket_patch_core::patch::rollback::rollback_store_copies_holding_patch(
3323+
common.read_scope(),
33213324
purl,
33223325
pkg_path,
33233326
files,
@@ -3400,6 +3403,7 @@ pub(crate) async fn rollback_patches_inner(
34003403
if !common.dry_run {
34013404
warnings.extend(
34023405
socket_patch_core::utils::cargo_build_cache::invalidate_project(
3406+
common.read_scope(),
34033407
&common.cwd,
34043408
results
34053409
.iter()
@@ -3817,8 +3821,15 @@ async fn rollback_maven_target(
38173821
}
38183822
}
38193823
}
3820-
let mut result =
3821-
rollback_package_patch(&target.purl, &target.dir, files, blobs_path, common.dry_run).await;
3824+
let mut result = rollback_package_patch(
3825+
common.read_scope(),
3826+
&target.purl,
3827+
&target.dir,
3828+
files,
3829+
blobs_path,
3830+
common.dry_run,
3831+
)
3832+
.await;
38223833
if !result.success || common.dry_run {
38233834
return result;
38243835
}

‎crates/socket-patch-cli/src/commands/scan/discovery.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -614,7 +614,7 @@ pub(crate) async fn nested_project_skips(
614614
// keeps the package the root's, whatever copy the crawl kept.
615615
// An unreadable tree keeps every candidate the root's (the backend
616616
// then reports it), rather than dropping a package the root installs.
617-
let crawler = socket_patch_core::crawlers::NpmCrawler::new();
617+
let crawler = socket_patch_core::crawlers::NpmCrawler::for_options(options);
618618
let wanted: Vec<String> = candidates.keys().cloned().collect();
619619
let Ok(nm_roots) = crawler.get_node_modules_paths(options).await else {
620620
return Default::default();

0 commit comments

Comments
 (0)