Skip to content
Open
17 changes: 16 additions & 1 deletion crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -661,7 +661,9 @@ are patched by `socket-patch apply` like every other ecosystem:
--check` is a read-only audit of the committed redirect.
The `go.work` in effect is resolved as go resolves it: `GOWORK` naming an absolute file, none under
`GOWORK=off`, else (unset, empty or `auto`) the nearest `go.work` at or above the project; a relative
`GOWORK` is invalid to go, so nothing is written or attested. A `-modfile` in `GOFLAGS` (#1799)
`GOWORK` is invalid to go, so nothing is written or attested. A `go.work` outside the repository
root is never read and refuses the same way (see "Reads bounded to the repository root").
A `-modfile` in `GOFLAGS` (#1799)
that names any file other than the project's `go.mod` (a relative one is taken from the project
directory) makes go build from that file and ignore `go.mod`'s replaces, and in workspace mode go
refuses `-modfile` outright: `apply`, `vendor` and `scan --mode hosted` write nothing
Expand Down Expand Up @@ -902,6 +904,19 @@ socket-patch creates `.socket/` and everything under it itself and never writes

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).

### Reads bounded to the repository root

**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.

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.

- **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.
- **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.
- **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.
- **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.
- **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.
- **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.

## Vendor command contract

`vendor` converts the hosted patches pinned in the project's lockfiles into committable
Expand Down
8 changes: 8 additions & 0 deletions crates/socket-patch-cli/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,14 @@ impl GlobalArgs {
self.global || self.global_prefix.is_some()
}

/// The read scope this run's mode fixes for every bounded read: a
/// global run (`--global` / `--global-prefix`) reads anywhere on the
/// system; a project run never reads a project file outside the
/// repository root (see `socket_patch_core::utils::repo_root`).
pub(crate) fn read_scope(&self) -> socket_patch_core::utils::repo_root::ReadScope {
socket_patch_core::utils::repo_root::ReadScope::of_global(self.is_global())
}

/// Whether `--ecosystems` selects `eco` (every ecosystem when unset or
/// empty). The names are validated at parse time, so this is an exact
/// match.
Expand Down
124 changes: 121 additions & 3 deletions crates/socket-patch-cli/src/commands/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -471,13 +471,17 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 {
// The same PnP gate `apply` runs: a PnP tree hides every npm copy, so
// without it each npm patch would read as not installed and pass calm.
if matches!(
detect_npm_pkg_manager(&args.common.cwd),
detect_npm_pkg_manager(args.common.read_scope(), &args.common.cwd),
NpmPkgManager::YarnBerryPnP
) && eco_in_local_scope(&args.common, Ecosystem::Npm)
&& manifest_targets_npm(&tree)
{
return refuse_yarn_pnp(args);
}
// The same cargo gate `apply` runs (see [`cargo_config_outside_root`]).
if let Some(why) = cargo_config_outside_root(args, &tree) {
return refuse_cargo_config_outside_root(args, &why, &[]);
}
let mut in_sync: Vec<String> = Vec::new();
let mut not_installed: Vec<String> = Vec::new();
if !tree.patches.is_empty() {
Expand Down Expand Up @@ -948,6 +952,52 @@ pub(crate) fn result_to_event(result: &ApplyResult, dry_run: bool) -> PatchEvent
/// True when the manifest records at least one npm patch — the only kind a
/// PnP layout can block (a polyglot repo's pypi/gem/go patches live outside
/// `node_modules` and apply fine).
/// The refusal code of [`cargo_config_outside_root`].
const CARGO_CONFIG_OUTSIDE_ROOT: &str = "cargo_config_outside_root";

/// Why a workspace apply cannot patch this project's Cargo crates: a
/// `.cargo/config` / `config.toml` sits above the repository root (stat
/// only; it is never read), and cargo merges it into the build, so it may
/// replace crates.io with another source while apply patches the shared
/// registry cache. `None` for a global run, a run without Cargo patches
/// or a project that is no Cargo project.
fn cargo_config_outside_root(args: &ApplyArgs, manifest: &PatchManifest) -> Option<String> {
let cwd = &args.common.cwd;
if !eco_in_local_scope(&args.common, Ecosystem::Cargo)
|| !manifest
.patches
.keys()
.any(|p| Ecosystem::from_purl(p) == Some(Ecosystem::Cargo))
|| !(cwd.join("Cargo.toml").is_file() || cwd.join("Cargo.lock").is_file())
{
return None;
}
socket_patch_core::vendor::cargo_config::config_above_repo(cwd).map(|why| {
format!(
"{why}: it may replace crates.io with another source, so the registry copy apply \
patches may not be what cargo builds; move the settings into the repository's \
.cargo/config.toml (or run with --global to patch the registry cache anyway)"
)
})
}

/// Print the [`cargo_config_outside_root`] refusal and return the exit
/// code.
fn refuse_cargo_config_outside_root(args: &ApplyArgs, why: &str, skipped: &[PatchEvent]) -> i32 {
if args.common.json {
let mut env = Envelope::new(Command::Apply);
env.dry_run = args.common.dry_run;
for skip in skipped {
env.record(skip.clone());
}
env.mark_error(EnvelopeError::new(CARGO_CONFIG_OUTSIDE_ROOT, why));
println!("{}", env.to_pretty_json());
} else {
eprintln!("Error: {why}");
}
1
}

fn manifest_targets_npm(manifest: &PatchManifest) -> bool {
manifest
.patches
Expand Down Expand Up @@ -1061,7 +1111,7 @@ pub async fn run(args: ApplyArgs) -> i32 {
// filter excluding npm never touches it.
if eco_in_local_scope(&args.common, Ecosystem::Npm)
&& matches!(
detect_npm_pkg_manager(&args.common.cwd),
detect_npm_pkg_manager(args.common.read_scope(), &args.common.cwd),
NpmPkgManager::YarnBerryPnP
)
{
Expand Down Expand Up @@ -1388,7 +1438,17 @@ pub(crate) async fn run_locked(
// informational note; the substantive safety is core's rename-over
// write (`utils::fs::atomic_write_bytes` never touches the store's
// shared inode).
match detect_npm_pkg_manager(&args.common.cwd) {
// A cargo config above the repository root is never read in a
// workspace run; it may move crates.io elsewhere, so the Cargo patches
// refuse instead of patching the registry cache blind.
if let Some(why) = cargo_config_outside_root(&args, &manifest) {
let code = refuse_cargo_config_outside_root(&args, &why, &user_patch_skips);
return ApplyRunReport {
skipped: user_patch_skips,
..ApplyRunReport::run_failure(code, CARGO_CONFIG_OUTSIDE_ROOT, why)
};
}
match detect_npm_pkg_manager(args.common.read_scope(), &args.common.cwd) {
NpmPkgManager::YarnBerryPnP => {
if eco_in_local_scope(&args.common, Ecosystem::Npm) && manifest_targets_npm(&manifest) {
let code = refuse_yarn_pnp_with_skips(&args, &user_patch_skips);
Expand Down Expand Up @@ -1512,6 +1572,7 @@ pub(crate) async fn run_locked(
});
cache_warnings.extend(
socket_patch_core::utils::cargo_build_cache::invalidate_project(
args.common.read_scope(),
&args.common.cwd,
rewritten.map(|r| r.package_key.as_str()),
)
Expand Down Expand Up @@ -2712,6 +2773,7 @@ async fn apply_patches_inner(
// extension, or is refused with nothing written (#1996).
let result = if Ecosystem::from_purl(purl) == Some(Ecosystem::Gem) {
let applied = gem_ext::apply_gem_package_patch(
args.common.read_scope(),
variant_purl,
pkg_path,
&patch.files,
Expand All @@ -2735,6 +2797,7 @@ async fn apply_patches_inner(
applied.result
} else {
apply_package_patch(
args.common.read_scope(),
variant_purl,
pkg_path,
&patch.files,
Expand Down Expand Up @@ -2892,6 +2955,7 @@ async fn apply_patches_inner(
}
None => {
apply_package_patch(
args.common.read_scope(),
purl,
pkg_path,
&patch.files,
Expand Down Expand Up @@ -3423,6 +3487,7 @@ async fn apply_maven_base(m: &MavenBase<'_>) -> MavenApplied {
}
attempted = true;
let mut result = apply_package_patch(
args.common.read_scope(),
variant,
&dir,
&files,
Expand All @@ -3443,6 +3508,7 @@ async fn apply_maven_base(m: &MavenBase<'_>) -> MavenApplied {
if copy_attempted && !absent.is_empty() {
attempted = true;
let result = apply_package_patch(
args.common.read_scope(),
variant,
copy,
&absent,
Expand Down Expand Up @@ -3743,6 +3809,58 @@ mod tests {
AppliedVia as CoreAppliedVia, ApplyResult, VerifyResult, VerifyStatus,
};

/// Review finding 3: a workspace apply of Cargo patches refuses when a
/// `.cargo/config.toml` above the repository root (never read) may
/// replace crates.io; a global run, or one without it, does not.
#[test]
fn cargo_patches_refuse_under_a_cargo_config_above_the_repository() {
#[derive(clap::Parser)]
struct Wrap {
#[command(flatten)]
args: ApplyArgs,
}
let tmp = tempfile::tempdir().unwrap();
let outer = std::fs::canonicalize(tmp.path()).unwrap();
let app = outer.join("repo");
std::fs::create_dir_all(app.join(".git")).unwrap();
std::fs::write(app.join("Cargo.toml"), "[package]\nname = \"app\"\n").unwrap();
let record: socket_patch_core::manifest::schema::PatchRecord =
serde_json::from_value(serde_json::json!({
"uuid": "11111111-1111-4111-8111-111111111111",
"exportedAt": "",
"files": {},
"vulnerabilities": {},
"description": "",
"license": "",
"tier": "free",
}))
.unwrap();
let mut manifest = PatchManifest::default();
manifest
.patches
.insert("pkg:cargo/serde@1.0.0".to_string(), record);
let parse = |extra: &[&str]| {
let cwd = app.display().to_string();
let mut argv = vec!["t", "--cwd", cwd.as_str()];
argv.extend_from_slice(extra);
<Wrap as clap::Parser>::try_parse_from(argv).unwrap().args
};
assert_eq!(cargo_config_outside_root(&parse(&[]), &manifest), None);
std::fs::create_dir_all(outer.join(".cargo")).unwrap();
std::fs::write(
outer.join(".cargo/config.toml"),
"[source.crates-io]\nreplace-with = \"v\"\n[source.v]\ndirectory = \"vendor\"\n",
)
.unwrap();
let why = cargo_config_outside_root(&parse(&[]), &manifest).unwrap();
assert!(why.contains(".cargo/config.toml"), "{why}");
assert!(why.contains("outside the repository"), "{why}");
assert_eq!(
cargo_config_outside_root(&parse(&["--global"]), &manifest),
None
);
}

/// A new-file collision's empty expected hash reads `(new file)` in the
/// verbose summary; a real hash is shown as is.
#[test]
Expand Down
15 changes: 13 additions & 2 deletions crates/socket-patch-cli/src/commands/rollback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3069,6 +3069,7 @@ pub(crate) async fn rollback_patches_inner(
for path in &found {
for copy in
socket_patch_core::crawlers::npm_crawler::find_store_peer_variant_copies(
common.read_scope(),
path,
)
.await
Expand Down Expand Up @@ -3277,6 +3278,7 @@ pub(crate) async fn rollback_patches_inner(
Some(r) => r,
None => {
let mut result = rollback_package_patch(
common.read_scope(),
purl,
pkg_path,
&patch.files,
Expand Down Expand Up @@ -3318,6 +3320,7 @@ pub(crate) async fn rollback_patches_inner(
// isolated-store entry, #1084) is restored here, or fails the
// run and keeps the record, before the record is dropped.
match socket_patch_core::patch::rollback::rollback_store_copies_holding_patch(
common.read_scope(),
purl,
pkg_path,
files,
Expand Down Expand Up @@ -3400,6 +3403,7 @@ pub(crate) async fn rollback_patches_inner(
if !common.dry_run {
warnings.extend(
socket_patch_core::utils::cargo_build_cache::invalidate_project(
common.read_scope(),
&common.cwd,
results
.iter()
Expand Down Expand Up @@ -3817,8 +3821,15 @@ async fn rollback_maven_target(
}
}
}
let mut result =
rollback_package_patch(&target.purl, &target.dir, files, blobs_path, common.dry_run).await;
let mut result = rollback_package_patch(
common.read_scope(),
&target.purl,
&target.dir,
files,
blobs_path,
common.dry_run,
)
.await;
if !result.success || common.dry_run {
return result;
}
Expand Down
2 changes: 1 addition & 1 deletion crates/socket-patch-cli/src/commands/scan/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -614,7 +614,7 @@ pub(crate) async fn nested_project_skips(
// keeps the package the root's, whatever copy the crawl kept.
// An unreadable tree keeps every candidate the root's (the backend
// then reports it), rather than dropping a package the root installs.
let crawler = socket_patch_core::crawlers::NpmCrawler::new();
let crawler = socket_patch_core::crawlers::NpmCrawler::for_options(options);
let wanted: Vec<String> = candidates.keys().cloned().collect();
let Ok(nm_roots) = crawler.get_node_modules_paths(options).await else {
return Default::default();
Expand Down
Loading
Loading