diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 3756e7abf..3b948a19e 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -102,7 +102,8 @@ Every subcommand accepts the same set of "global" flags via a single shared `Glo | `--vendor-url` | — | `SOCKET_VENDOR_URL` | (active API/proxy base) | string | Base host for the vendoring-service package-reference request | | `--patch-server-url` | — | `SOCKET_PATCH_SERVER_URL` | (server-returned) | string | Override the host of the prebuilt-archive download URL (local-dev / testing) | | `--offline` | — | `SOCKET_OFFLINE` | `false` | bool | **Strict airgap on every command** — never contact the network | -| `--strict` | — | `SOCKET_STRICT` | `false` | bool | Treat a beforeHash mismatch as a hard error in the in-place apply paths (see the mismatch-policy note below) | +| `--strict` | — | `SOCKET_STRICT` | `false` | bool | Keep rejecting file-content mismatches even with `--allow-user-patches`; mismatches already fail by default without consent. `apply --force` overrides this file check, but never grants consent for declared project patches. | +| `--allow-user-patches` | — | — | `false` | bool | Explicit, invocation-only consent to attempt Socket patches alongside project patches/replacements, or overwrite modified installed files. No environment or repository-policy binding. Backend safety refusals still apply. See [Existing project patches](#existing-project-patches). | | `--global` | `-g` | `SOCKET_GLOBAL` | `false` | bool | Operate on globally-installed packages | | `--global-prefix` | — | `SOCKET_GLOBAL_PREFIX` | (auto) | path | Override global packages root | | `--json` | `-j` | `SOCKET_JSON` | `false` | bool | Machine-readable output | @@ -121,7 +122,91 @@ Every subcommand accepts the same set of "global" flags via a single shared `Glo `--offline` means the same thing on every command (v3.0): never contact the network, fail loudly when a required local source is missing. On `repair`, `--offline` and `--download-only` are mutually exclusive (exit 2). `scan` and `get` need remote data for their core function (patch discovery / patch fetch), so `--offline` refuses them up front — exit 1 with an error naming the offline gate (JSON: `status: "error"`), before any crawl, client build, or network contact. This covers `scan --mode vendored` too: offline vendored staging is `vendor --offline`'s job. -The `--strict` mismatch policy applies to the in-place apply paths (apply/get/scan --mode agent/hook/go redirect). DEFAULT (v3.4): a file whose on-disk content matches neither the patch's beforeHash nor its afterHash is overwritten with the FULL verified patched content (blob writes are hash-gated to exactly afterHash; the missing blob is downloaded on demand) and surfaced as a `content_mismatch_overwritten` stderr warning + Skipped event (agent-mode `get` / `scan --json`: a `warnings[]` entry, see "Agent-mode mismatch overwrites"). A file the patch adds (empty beforeHash) that already exists with other content is the same case. `--strict` turns that case into a hard error. Rollback of an added file deletes it; the content it replaced is not kept. `--force` overrides `--strict` and additionally skips missing files. Vendoring is unaffected: it commits the patch server's verified artifact and never reads the installed bytes. +The in-place apply paths (`apply`, agent-mode `get` / `scan`, and their nested +apply calls) reject files matching neither `beforeHash` nor `afterHash` by +default. This also protects a pre-existing file where the Socket patch adds a +new file (empty `beforeHash`). The exception is content a Socket patch wrote: +a file whose hash names a stored `.socket/blobs/` blob (as after an update to a +newer patch for the same release) is overwritten with the +`content_mismatch_overwritten` warning, except under `--strict`. Only a stored +blob proves this: when it is gone (pruned by `gc` / `scan --prune`, or +`.socket/blobs/` is not shared with the checkout), the earlier output reads as +unknown drift and `apply --force` is the explicit way to replace it. +`--allow-user-patches` permits the old +warn-and-overwrite behavior: only the full, hash-verified `afterHash` content +can be written, with `content_mismatch_overwritten` diagnostics. `--strict` +keeps rejecting file drift even with that consent. `apply --force` remains +explicit consent for file drift and additionally skips missing files, but a +**declared project patch still requires `--allow-user-patches`**. Overwriting +is not a merge: the replaced local content is not kept for rollback. +Vendoring commits the service artifact without merging installed changes. + +## Existing project patches + +**Release classification: MAJOR.** This intentionally tightens the default +file-mismatch policy and package eligibility. Publish it with the next major +release under the semver policy; version stamps and release notes belong to the +release train. + +Project-owned patches are protected at package level, even if they touch a +different file, are not installed yet, or all Socket `beforeHash` checks pass. +A missing referenced diff does not remove that protection. + +- `scan` (every mode, including previews) excludes matching packages before patch + selection and emits `warnings[].code: user_patch_conflict`, naming the purl + and evidence path. Other packages remain eligible; skips alone exit 0. + The full installed inventory still protects skipped packages from prune-as-missing. +- Mutating `apply`, including the nested apply of agent-mode `get` / `scan`, + leaves each protected manifest entry out of the run with a + `user_patch_conflict` warning and applies every other entry; skips alone exit 0, + so a `postinstall` apply never fails an install over them. The manifest is not + changed, and a skipped entry is not reconciled away as dropped. +- Explicit `get` selection (including UUID, `--save-only`, and `--dry-run`) and + `vendor` refuse selected conflicts with exit 1, `status: error`, + `error.code: user_patch_conflict`, before their patch writes. `vendor` still + reverts entries dropped from the manifest first. + A hosted or vendored writer rechecks under its apply lock before rewiring. +- `--allow-user-patches` approves the attempt for this invocation and emits + `user_patch_override` warnings. It is forwarded to nested apply. `--yes`, + `--force`, a UUID, policy settings, and `SOCKET_ALLOW_USER_PATCHES` do not grant + this consent. Automatic scans never introduce a confirmation prompt. +- Consent does not turn an unsupported composition into a supported one. Native + Bun/npm refusals, Yarn layout restrictions, source ownership, integrity, + path-safety and other backend checks still apply. The CLI never edits, deletes, + rebases, or evaluates user patch files to make a Socket patch fit. +- Read-only `apply --check`, `vendor --check`, `list` and `vex`, and explicit + reversal (`rollback`, `remove`, `vendor --revert`) keep their existing behavior. + `repair` may restore Socket-owned artifacts; any re-vendoring uses the same + consent gate. Existing Socket pins are not automatically unwound when a user + adds a patch: inspect them and explicitly roll back if necessary. + +Detection covers root and declared npm workspace manifests, ancestor workspace +settings, and the following evidence: + +| Mechanism | Evidence | +| --- | --- | +| pnpm | `pnpm.patchedDependencies` in `package.json`; `patchedDependencies` in `pnpm-workspace.yaml` and `pnpm-lock.yaml`; registry lock keys carrying `patch_hash` or `patched` markers, including legacy and multi-document locks | +| Yarn Berry | User `patch:` references in dependency/resolution fields or lock resolutions; builtin-only compatibility patches are exempt | +| `patch-package` (npm / Yarn Classic / other npm layouts) | Default `patches/` filenames, including scoped, nested (`++`), sequenced and `.dev.patch` files; literal `--patch-dir` paths in package scripts | +| Native npm / Bun | Root `patchedDependencies`; npm lock `patched` records; Bun text-lock declarations (binary Bun installs also have the root declaration) | +| Composer Patches | Root `extra.patches`, external `patches.json` / configured `patches-file`, `patches.lock.json`, and dependency `extra.patches` in `composer.lock` | +| Cargo | `[patch]` (including renamed `package` entries), `[replace]`, and `.cargo/config[.toml]` `paths` overrides; Socket-owned vendored paths are exempt | +| Go | User-owned `replace` directives in `go.mod` / `go.work`; Socket-owned replacements are exempt | + +Exact npm versions and supported npm ranges are matched against the selected +release; name-only and `*` declarations cover all releases, including prereleases. +Unrecognized ranges protect +the named package conservatively. Matching is conservative across copies and +workspace members: one declaration protects every selected copy of that release. +Unreadable/malformed metadata, dynamic patch directories, or a workspace glob the +bounded reader cannot inspect protect the affected ecosystem with an explanatory +conflict diagnostic. Filesystem inspection never executes hooks or fetches patch +URLs. Arbitrary scripts, external build pipelines, unrecorded edits to files the +Socket patch does not touch, and global-install patch declarations cannot be +identified reliably; rejecting unknown file drift remains the fallback. This is +not a proof that two patches compose, even after opt-in. See the +[research and source references](../../docs/ecosystems.md#existing-project-patches). + ## Per-subcommand arguments @@ -1517,7 +1602,7 @@ command's integration tests check its output. | `vendor_artifact_redownload_failed` | `failed` | repair: download unavailable, integrity mismatch, or downloaded bytes/inventory differ from the ledger. Existing files are preserved. | | `vendor_artifact_unrepairable` | `failed` | repair: the ledger identity or patch record cannot be trusted or recovered. | | `vendor_uuid_mismatch` | `skipped` | repair: the manifest's patch uuid moved past the vendored artifact — a re-vendor (`vendor` / `scan --mode vendored`) is pending; repair does not cross patch generations. | -| `content_mismatch_overwritten` | `skipped` (warning) | apply (default policy): a file matched NEITHER beforeHash nor afterHash and was overwritten with the full verified patched content. This includes a file the patch adds (empty beforeHash) that already exists with other content. `--strict` turns this case into a `failed` event instead. Agent-mode `get` / `scan --json` carry it as a `(content_mismatch_overwritten) …` `warnings[]` entry (#1004). | +| `content_mismatch_overwritten` | `skipped` (warning) | apply (explicit `--allow-user-patches` / `--force`): a file matched NEITHER beforeHash nor afterHash and was overwritten with the full verified patched content. This includes a file the patch adds (empty beforeHash) that already exists with other content. `--strict` turns this case into a `failed` event instead. Agent-mode `get` / `scan --json` carry it as a `(content_mismatch_overwritten) …` `warnings[]` entry (#1004). | | `vendor_lock_checksums_unsupported` / `vendor_stale_lock_checksum` | `failed` | vendor (gem): an ambiguous/platform CHECKSUMS entry, or a v1-wired lock whose stale token blocks the hot path (run `vendor --revert` + re-vendor). | | `redirect_unattributable` | `skipped` (hosted event) | scan/get `--mode hosted`: the rewriters would pin the patch, but lockfile discovery over the result reads that pin as contested (another lock or requirements file resolves the same version elsewhere, or the pin is not one the package manager consumes), so `vex`, `rollback`, `remove` and `vendor` would refuse it. The candidate is left out of the rewrite, so nothing is written for it; the detail carries discovery's findings. Exit code unchanged. | | `redirect_copy_unpatched` | `warnings[]` (warning) | scan/get `--mode hosted` (pnpm, yarn): the pin was written, but its own lock also installs an unpatched copy of the same `name@version`, which no rewrite reaches: a pnpm `file:` directory or tarball dependency (#935), or a yarn `file:` tarball / directory, URL tarball or berry `portal:` / `link:` dependency declared under another name (`lp2@file:…`, #939, #1236; not repeated for a copy a yarn classic skip warning already names). The detail names the lock entry. Neither the run's own `--vex` nor a later `vex` attests the package (`vex` warns `patched_ref_unattributable`), but discovery still manages the pin, so `rollback` / `remove` undo it. | @@ -1583,10 +1668,14 @@ command's integration tests check its output. | `vendor_prebuilt_stub_invalid` | `failed` | RubyGems: the server stub lacks required attributes or is otherwise invalid; no local stub fallback is permitted. | | `vendor_*` / `pypi_*` / `gemfile_*` / `lock_*` / `locked_version_mismatch` / `user_authored_*` / `native_extensions_unsupported` / `platform_gem_unsupported` | `failed`/`skipped` | vendor: per-ecosystem refusal + drift vocabulary; see the Vendor command contract section. New tags are additive (MINOR). | +`user_patch_conflict` is also a scan and apply run warning for packages excluded by the +project-patch consent gate; `user_patch_override` records explicit consent. + ### Top-level `EnvelopeError` codes | Code | Subcommands | Meaning | |-----------------------|----------------------------------|---------| +| `user_patch_conflict` | get, vendor; hosted/vendored write rechecks | A selected package has project-owned patch/replacement evidence and no `--allow-user-patches` consent. Evidence paths and a remedy are included; exit 1. (`scan` and `apply` report it as a warning and skip the package.) | | `manifest_not_found` | remove, repair, rollback, vex, apply (only when the manifest vanishes between apply's existence probe and its locked read) (not `list` since v5.0: a missing manifest is an empty list) | `.socket/manifest.json` doesn't exist. For `vex` (and `scan --vex`) it fires only when, in addition, NOTHING else names a patch — no vendor-ledger entry, no lockfile reference (hosted or vendored) — and the message says so (exit 2 standalone; `apply`/`vendor --vex` treat it as their calm no-op). v3.5: `repair` proceeds anyway (vendored phase only) when a vendor ledger or vendor-path lockfile references exist, and exits 0 with a `redirect_only_project` skip (not this error) when the project's only patch state is hosted pins in its lockfiles (v5.0; or a pre-v5 `redirect-state.json`). `list` likewise no longer fires this on a hosted-only project: v5.0 lists every hosted pin the lockfiles wire (exit 0, labeled `details.mode: "hosted"` + `details.lockfiles: []` — no `details.ledger`, since hosted mode keeps none; when the manifest exists too, both are shown, purl-sorted with the manifest entry first on a tie). A pin carries its uuid and empty details unless a pre-v5 redirect ledger records the same purl and uuid (read for migration only: its record supplies the vulnerabilities / tier / description); a pre-v5 ledger record whose pin is in no lockfile is not listed. v5.0: `list` reads the vendor ledger the same way — a vendored-only project (every `scan`/`get --mode vendored` project) lists its ledger entries' embedded records labeled `Mode: vendored (recorded in .socket/vendor/state.json)` in human mode — the twin of the hosted `Mode: hosted (wired in )` line — (`details.mode: "vendored"` + `details.ledger: ".socket/vendor/state.json"` in JSON), exit 0. A standalone-`vendor` entry's fallback `record` lists the same way once no manifest entry covers it (by ledger key or base purl) — the copy manifest-less `vex` attests from, so `list` never reports `manifest_not_found` for a tree whose VEX document attests a patch; while the manifest covers it, only the manifest entry is listed. All sources always come from the SAME project (see the `--manifest-path` flag row), so `--manifest-path` into another project reads that project's state, never the local one. The error still fires when NONE of the three sources has a patch, and a present-but-broken manifest still reports `manifest_invalid`/`manifest_unreadable` regardless (corruption is never masked). A malformed pre-v5 redirect ledger degrades to "nothing to consult" with a stderr warning, muted by `--silent` (the pins still list); `list --json` carries it in the run-level `warnings[]` as `redirect_ledger_corrupt` instead of on stderr. v5.0: `rollback` likewise proceeds manifest-less when the vendor ledger or the lockfiles' hosted pins hold work (its error is the legacy `{status: "error", error: {code: "manifest_not_found", message: "Manifest not found"}, path}` shape); only the truly-empty project — no manifest, no vendor ledger, no hosted pin (a lone pre-v5 redirect ledger is deleted, exit 0) — keeps the exit-1 error, and a project whose lockfiles still reference `.socket/vendor/` artifacts with NO vendor ledger gets a distinct error naming `socket-patch repair`. `remove` (v5.0) proceeds manifest-less whenever a vendor ledger file exists or the lockfiles pin a hosted patch (an existence probe and the read-only hosted-pin discovery before the lock; the vendor ledger loads under it): ANY vendor-ledger entry matching the identifier — detached or not — is removed through the ledger path (`--preserve-state` and drift-keeps behave exactly as on the manifest path), a hosted-only match restores its upstream registry entry, and when that state exists but holds nothing for the identifier the error is `not_found` (exit 1), not this code — `manifest_not_found` fires from `remove` only when all three sources are empty. Manifest entries are removed in sorted purl order. | | `ambiguous_target` | remove, get, rollback | The identifier is a package name whose last-segment rule selects several packages across the manifest, the vendor ledger and the hosted pins (`core` → `@angular/core` and `@babel/core`). Nothing is changed (exit 1); the message names each package. Use the full name or a purl. See **Target grammar**. | | `manifest_invalid` | list, remove, apply (incl. `--check`), repair, vendor (incl. `--check`), vex, rollback, get, scan (agent mode) | Manifest exists but is unparseable (malformed JSON or a schema violation). **v5.0, MAJOR (#931)**: one mapping on every command — `apply` used to say `apply_failed`, `repair` `repair_failed`, `vendor` the undocumented `invalid_manifest`, and `apply --check` / `vendor --check` / `vex` / `get` / `rollback` `manifest_unreadable`. Exit codes unchanged (`vex`: 2, every other command 1). Agent-mode `scan` fails with it (exit 1); the other scan modes, which don't read the manifest, add a warning with the same code and go on. | diff --git a/crates/socket-patch-cli/src/args.rs b/crates/socket-patch-cli/src/args.rs index d78d0e582..b28a082dd 100644 --- a/crates/socket-patch-cli/src/args.rs +++ b/crates/socket-patch-cli/src/args.rs @@ -180,12 +180,10 @@ pub struct GlobalArgs { )] pub offline: bool, - /// Treat a beforeHash mismatch as a hard error. By DEFAULT a file whose - /// on-disk content matches neither the patch's beforeHash nor its - /// afterHash is overwritten with the full verified patched content and - /// surfaced as a stderr warning (`content_mismatch_overwritten`); this - /// flag restores the fail-closed behavior. On commands that have - /// `--force`, `--force` overrides it. + /// Reject modified file content even with --allow-user-patches. Without + /// explicit consent, mismatches already fail by default. On commands that + /// have --force, --force overrides this file-content check (but never the + /// separate check for declared project patches). #[arg( help_heading = GLOBAL_OPTIONS, long, @@ -195,6 +193,13 @@ pub struct GlobalArgs { )] pub strict: bool, + /// Explicitly permit attempting patches on packages with project patches or + /// local modifications. Review and test the combined result; backend safety + /// refusals still apply. --yes does not approve patch composition; --force + /// approves only file-content drift. + #[arg(help_heading = GLOBAL_OPTIONS, long)] + pub allow_user_patches: bool, + /// Operate on globally-installed packages. #[arg( help_heading = GLOBAL_OPTIONS, @@ -780,6 +785,7 @@ impl Default for GlobalArgs { patch_server_url: None, offline: false, strict: false, + allow_user_patches: false, global: false, global_prefix: None, json: false, diff --git a/crates/socket-patch-cli/src/commands/agent_download.rs b/crates/socket-patch-cli/src/commands/agent_download.rs index e23ef9658..e7ae38761 100644 --- a/crates/socket-patch-cli/src/commands/agent_download.rs +++ b/crates/socket-patch-cli/src/commands/agent_download.rs @@ -367,6 +367,11 @@ pub struct DownloadParams { /// `--strict` forwarded to the nested apply (a beforeHash mismatch /// fails instead of warn-and-overwrite). pub strict: bool, + pub allow_user_patches: bool, + /// The caller already reported every installed package the user-patch + /// gate holds (`scan`), so the nested apply need not repeat the + /// manifest entries it skips for them. `get` checks only its selection. + pub user_patch_skips_reported: bool, /// `--ecosystems` forwarded to the nested apply, so it never touches /// other ecosystems' packages the user filtered out. pub ecosystems: Option>, @@ -1401,6 +1406,7 @@ pub(crate) fn nested_apply_args_from_params( global: params.global, global_prefix: params.global_prefix.clone(), strict: params.strict, + allow_user_patches: params.allow_user_patches, // Scope the nested apply like the caller was scoped: `None` would // apply the WHOLE manifest, mutating other ecosystems' packages the // user filtered out. @@ -1427,6 +1433,7 @@ pub(crate) fn nested_apply_args_from_params( pub(crate) async fn run_nested_apply( common: GlobalArgs, json: bool, + report_user_patch_skips: bool, client: &ApiClient, lock: LockGuard, ) -> ApplyRunReport { @@ -1436,7 +1443,10 @@ pub(crate) async fn run_nested_apply( force: false, check: false, vex: Default::default(), - nested: Some(super::apply::NestedApply { caller_json: json }), + nested: Some(super::apply::NestedApply { + caller_json: json, + report_user_patch_skips, + }), }; let report = super::apply::run_locked(apply_args, manifest_path, client, lock).await; // An error, so exempt from --silent ("errors only": a failing exit must @@ -1733,6 +1743,7 @@ pub async fn download_and_apply_patches_into( run_nested_apply( nested_apply_args_from_params(params, run, &manifest_path), params.json, + !params.user_patch_skips_reported, run.api_client, lock, ) diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index d7e8ed0aa..78697c0fe 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -120,15 +120,19 @@ fn mismatch_overwrite_warnings(results: &[ApplyResult], dry_run: bool) -> Vec MismatchPolicy { +/// Unknown local changes fail closed unless explicitly approved; content an +/// earlier Socket patch wrote is not a local change (patch updates). --force +/// is existing explicit consent for file drift; declared patches have a +/// separate gate that requires --allow-user-patches even with --force. +fn mismatch_policy(force: bool, strict: bool, allow_user_patches: bool) -> MismatchPolicy { if force { MismatchPolicy::Force } else if strict { MismatchPolicy::Strict - } else { + } else if allow_user_patches { MismatchPolicy::Warn + } else { + MismatchPolicy::SocketContent } } @@ -178,6 +182,10 @@ pub struct NestedApply { /// (one envelope per command), but the caller's stdout is, so the /// nested run's human error lines stay off stderr too. pub caller_json: bool, + /// Report the manifest entries the user-patch gate skips: the caller + /// (`get`) checked only its own selection. `scan` already reported + /// every protected installed package, so it passes `false`. + pub report_user_patch_skips: bool, } impl ApplyArgs { @@ -1039,7 +1047,7 @@ pub(crate) struct ApplyRunReport { /// when `code != 0`. pub applied: Vec, /// Non-fatal per-file warnings the caller's envelope must carry: one - /// `content_mismatch_overwritten` per file the default mismatch policy + /// `content_mismatch_overwritten` per file the explicitly approved mismatch policy /// overwrote (#1004). A nested apply never prints JSON and is silent /// for a JSON caller, so this is their only channel. Filled whenever /// the apply loop ran, whatever the exit code. @@ -1138,6 +1146,15 @@ pub(crate) async fn run_locked( } }; + // Packages the project patched itself sit this run out (warned); every + // other record still applies. The read-only `--check` verifies them all. + let mut manifest = manifest; + let (user_patch_warnings, user_patched) = if args.check { + Default::default() + } else { + super::user_patches::skip_protected(&args.common, &mut manifest.patches).await + }; + // Package-manager layout detection. yarn-berry PnP keeps packages // inside `.yarn/cache/*.zip` and resolves them via `.pnp.cjs` — // the npm crawler can't reach them and rewriting zips is a @@ -1208,7 +1225,7 @@ pub(crate) async fn run_locked( NpmPkgManager::Npm | NpmPkgManager::YarnClassic | NpmPkgManager::Unknown => {} } - match apply_patches_inner(&args, manifest, client).await { + match apply_patches_inner(&args, manifest, &user_patched, client).await { Ok(ApplyOutcome { success, results, @@ -1230,6 +1247,24 @@ pub(crate) async fn run_locked( // rightly ignores it). It rides the run-warning channel so it // is never silent. let mut run_warnings = run_warnings; + // A nested apply's caller (get, scan) already reported the + // overrides it approved; repeating them would double each line. + // Skips are new to `get`, which checked only its selection; a + // JSON caller gets them through the report. + let user_patch_warnings: Vec = user_patch_warnings + .into_iter() + .filter(|(code, _)| match &args.nested { + None => true, + Some(n) => n.report_user_patch_skips && code == super::user_patches::CONFLICT, + }) + .map(|(code, detail)| RunWarning { code, detail }) + .collect(); + run_warnings.extend(user_patch_warnings.iter().cloned()); + let nested_skip_warnings = if args.nested.is_some() { + user_patch_warnings + } else { + Vec::new() + }; run_warnings.extend(results.iter().filter_map(|r| { let note = r.error.as_deref().filter(|_| r.success)?; note.contains(socket_patch_core::patch::apply::OWNERSHIP_NOT_RESTORED_MARKER) @@ -1497,6 +1532,7 @@ pub(crate) async fn run_locked( // JSON events above are the standalone apply's copy). let mut warnings = mismatch_overwrite_warnings(&results, args.common.dry_run); warnings.extend(cache_warnings); + warnings.extend(nested_skip_warnings); // A requested-but-failed VEX flips an otherwise-successful // apply to a non-zero exit (fail-the-command contract). if success && !vex_failed { @@ -1947,6 +1983,7 @@ impl FallbackHomeSkip { async fn apply_patches_inner( args: &ApplyArgs, mut manifest: PatchManifest, + user_patched: &HashSet, client: &ApiClient, ) -> Result { // Resolve patch sources (read `.socket/` directly, or stage an overlay @@ -1996,7 +2033,13 @@ async fn apply_patches_inner( // found" early returns — so orphans are reconciled even when the manifest // now lists zero in-scope go patches (the all-removed case). No-op unless // local go is in scope. - reconcile_local_go(&args.common, &target_manifest_purls).await; + // Records skipped for project patches are still recorded, not orphans. + let recorded: HashSet = target_manifest_purls + .iter() + .chain(user_patched) + .cloned() + .collect(); + reconcile_local_go(&args.common, &recorded).await; if partitioned.is_empty() { // Nothing in scope: the manifest lists no patches (or every patch was @@ -2146,7 +2189,11 @@ async fn apply_patches_inner( // Apply patches let sources = staged.as_patch_sources(); - let policy = mismatch_policy(args.force, args.common.strict); + let policy = mismatch_policy( + args.force, + args.common.strict, + args.common.allow_user_patches, + ); let mut has_errors = false; // Group release-variant PURLs by base. PyPI (`?artifact_id=`), @@ -3502,7 +3549,7 @@ mod tests { /// `HashMap`-iteration-ordered pick let a variant describing a /// different, NOT-installed distribution randomly match and get /// attempted (nondeterministic spurious failures, or wrong-variant - /// content overwrites under the default mismatch policy). 64 rounds + /// content overwrites under the explicitly approved mismatch policy). 64 rounds /// with fresh maps so the randomized per-instance iteration order is /// actually exercised. #[tokio::test] diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index e84e63eed..24c1dd962 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -1300,6 +1300,20 @@ pub async fn run(args: GetArgs) -> i32 { ) .await; let selected = vec![search_result_from_response(&patch)]; + let user_patch_warnings = + match super::user_patches::check(&args.common, [&patch.purl]).await { + Ok(warnings) => { + super::user_patches::warn(&args.common, &warnings); + warnings + } + Err(error) => { + return super::user_patches::refuse( + &args.common, + JsonCommand::Get, + error, + ) + } + }; // Acting against the repo's socket.yml says so // (`policy_bypassed`). let mut uuid_warnings = @@ -1312,6 +1326,7 @@ pub async fn run(args: GetArgs) -> i32 { // Already on stderr from the client: JSON only, after the // print above. uuid_warnings.extend(org_warnings.iter().cloned()); + uuid_warnings.extend(user_patch_warnings); // Mode dispatch. All three reuse THIS fetched patch and // this possibly-proxy-fallback client rather than // re-fetching with a fresh one, which would re-hit the @@ -1728,6 +1743,13 @@ pub async fn run(args: GetArgs) -> i32 { } else { selected }; + match super::user_patches::check(&args.common, selected.iter().map(|p| &p.purl)).await { + Ok(warnings) => { + super::user_patches::warn(&args.common, &warnings); + narrow_warnings.extend(warnings); + } + Err(error) => return super::user_patches::refuse(&args.common, JsonCommand::Get, error), + } if agent_preview { return agent_dry_run(&args, &selected, &narrow_skips, &narrow_warnings).await; } @@ -2085,6 +2107,7 @@ async fn save_and_apply_patch( run_nested_apply( nested_apply_args(&args.common, &manifest_path, quiet), args.common.json, + true, client, lock, ) @@ -2165,6 +2188,8 @@ fn get_download_params(args: &GetArgs, save_only: bool, persist_blobs: bool) -> silent: args.common.silent, all_releases: args.all_releases, strict: args.common.strict, + allow_user_patches: args.common.allow_user_patches, + user_patch_skips_reported: false, ecosystems: args.common.ecosystems.clone(), persist_blobs, patch_server_url: args.common.patch_server_url.clone(), @@ -3845,6 +3870,8 @@ mod tests { silent: true, all_releases: false, strict: false, + allow_user_patches: false, + user_patch_skips_reported: false, ecosystems: None, persist_blobs: false, patch_server_url: None, @@ -4542,6 +4569,8 @@ mod tests { silent: true, all_releases: false, strict: false, + allow_user_patches: false, + user_patch_skips_reported: false, ecosystems: None, // The vendor-detached posture this fn exists for. persist_blobs: false, @@ -5619,6 +5648,7 @@ mod tests { let common = GlobalArgs { verbose: true, strict: true, + allow_user_patches: false, json: true, dry_run: true, ..GlobalArgs::default() diff --git a/crates/socket-patch-cli/src/commands/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs index 1c0be1078..daf3d3454 100644 --- a/crates/socket-patch-cli/src/commands/mod.rs +++ b/crates/socket-patch-cli/src/commands/mod.rs @@ -635,3 +635,4 @@ mod tests { assert_eq!(mode, Ok(scan::ScanMode::Vendored)); } } +pub(crate) mod user_patches; diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 5d6ee98d6..a3e47ca5c 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -1041,6 +1041,26 @@ async fn run_redirect_selected_untracked( None }; + // Re-read project patch declarations under the write lock. A patch added + // during API selection must not be lost in a takeover or lock rewrite. + if !common.allow_user_patches { + if let Err(error) = + crate::commands::user_patches::check(common, candidates.iter().map(&sel_purl)).await + { + // The shared refusal path keeps this run's envelope and lets + // telemetry go out before the JSON error. + return refuse( + common, + scan_result.take(), + &engine::Refusal { + code: error.code, + message: error.message, + }, + &mut telemetry.json_error, + ); + } + } + // v5 hosted mode keeps no ledger: the lockfiles are the only record of // a redirect (vex, list, vendor and rollback read the hosted pins from // them). This run's patch records (fetched below) feed only the diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index a5eadaa04..16c173ee6 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -888,6 +888,8 @@ fn download_params(args: &ScanArgs, save_only: bool, json: bool, silent: bool) - silent, all_releases: args.all_releases, strict: args.common.strict, + allow_user_patches: args.common.allow_user_patches, + user_patch_skips_reported: true, ecosystems: args.common.ecosystems.clone(), persist_blobs: args.mode != Some(ScanMode::Vendored), patch_server_url: args.common.patch_server_url.clone(), @@ -1216,13 +1218,21 @@ fn is_info_note(code: &str) -> bool { } /// Print the run-level warnings to stderr: advisories as `Note:` (not -/// under `--silent`), everything else as `Warning:`. +/// under `--silent`), everything else as `Warning:`. User-patch skips are +/// silenced too, matching `user_patches::check`; the envelope keeps them. fn print_layout_refusals(refusals: &[(String, String)], silent: bool) { for (code, detail) in refusals { if is_info_note(code) { if !silent { eprintln!("Note: {detail}"); } + } else if matches!( + code.as_str(), + super::user_patches::CONFLICT | super::user_patches::ALLOWED + ) { + if !silent { + eprintln!("Warning: {detail}"); + } } else { eprintln!("Warning: {detail}"); } @@ -2212,7 +2222,30 @@ async fn run_scan( .locate_nested_copies(&nm_roots, &filtered_crawled) .await; } - let filtered_crawled = policy.admit_crawled_copies(filtered_crawled, &supplement_purls); + let mut filtered_crawled = policy.admit_crawled_copies(filtered_crawled, &supplement_purls); + // Keep the full crawl above for GC: a protected package is still installed. + // Protect before querying/selecting, in every mode and in dry-run previews. + let user_patches = super::user_patches::inspect(&args.common).await; + let mut user_patch_withheld = 0usize; + filtered_crawled.retain(|package| { + let sources = user_patches.conflicts(&package.purl); + if sources.is_empty() { + return true; + } + if !args.common.allow_user_patches { + user_patch_withheld += 1; + } + layout_refusals.push(( + if args.common.allow_user_patches { + super::user_patches::ALLOWED + } else { + super::user_patches::CONFLICT + } + .into(), + super::user_patches::detail(&package.purl, &sources, args.common.allow_user_patches), + )); + args.common.allow_user_patches + }); // Gradle discovery notes (m2 gating, the user home) ride the run-level // warnings; the lock set only annotates `packages[]` below. @@ -2320,8 +2353,13 @@ async fn run_scan( emit_scan(&env); return code; } else if !args.common.silent { - // A project the policy skipped as a whole is not an empty one. - if !policy.root_excluded() { + // A project the policy skipped as a whole is not an empty one, + // and neither is one whose every package the consent gate held. + if user_patch_withheld > 0 { + println!( + "{user_patch_withheld} installed package(s) are protected by project patches (see the warnings above) and were not scanned. Pass --allow-user-patches to scan them anyway." + ); + } else if !policy.root_excluded() { println!( "{}", render::no_packages_message( diff --git a/crates/socket-patch-cli/src/commands/user_patches.rs b/crates/socket-patch-cli/src/commands/user_patches.rs new file mode 100644 index 000000000..95baf918a --- /dev/null +++ b/crates/socket-patch-cli/src/commands/user_patches.rs @@ -0,0 +1,158 @@ +//! The CLI consent gate. --yes, --force, UUID selection and socket.yml do +//! not grant permission to combine a Socket patch with a project's own patch. + +use socket_patch_core::patch::user_patches::UserPatchIndex; + +use crate::args::GlobalArgs; +use crate::json_envelope::{Command, Envelope, EnvelopeError}; + +pub(crate) const CONFLICT: &str = "user_patch_conflict"; +pub(crate) const ALLOWED: &str = "user_patch_override"; + +/// Ecosystems the detector reads evidence for; any other purl can't conflict. +const DETECTED: &[&str] = &["pkg:npm/", "pkg:composer/", "pkg:cargo/", "pkg:golang/"]; + +/// Whether any of `purls` could match project evidence, so a run with no +/// such purl (an empty or pypi-only manifest) skips discovery entirely. +fn any_detectable<'a>(common: &GlobalArgs, mut purls: impl Iterator) -> bool { + purls.any(|purl| { + common.purl_ecosystem_selected(purl) + && DETECTED.iter().any(|prefix| { + purl.get(..prefix.len()) + .is_some_and(|p| p.eq_ignore_ascii_case(prefix)) + }) + }) +} + +pub(crate) async fn inspect(common: &GlobalArgs) -> UserPatchIndex { + if common.is_global() { + // A local checkout does not govern a separately selected global tree. + UserPatchIndex::default() + } else { + UserPatchIndex::discover(&common.cwd).await + } +} + +pub(crate) fn detail(purl: &str, sources: &[String], allowed: bool) -> String { + let sources = sources.join(", "); + if allowed { + format!("{purl}: --allow-user-patches permits attempting a Socket patch alongside project patches in {sources}; review and test the combined result. Backend safety checks still apply") + } else { + format!("{purl}: protected by project patch/replacement metadata in {sources}; leaving this package untouched. Merge the fixes manually, or explicitly accept the conflict risk with --allow-user-patches (ordinary --yes/--force do not approve this)") + } +} + +/// Explicit-target commands fail before mutation rather than silently succeeding +/// without the requested patch. Approved overrides come back as warnings for +/// the caller to print once (see [`warn`]) and attach to its own envelope. +/// Nothing here downloads or applies the user's patch. +pub(crate) async fn check( + common: &GlobalArgs, + purls: impl IntoIterator>, +) -> Result, EnvelopeError> { + let (warnings, conflicts) = partition(common, purls).await; + if conflicts.is_empty() { + Ok(warnings) + } else { + let mut messages: Vec = conflicts.into_iter().map(|(_, m)| m).collect(); + messages.sort(); + messages.dedup(); + Err(EnvelopeError::new(CONFLICT, messages.join("\n"))) + } +} + +/// [`check`]'s two outcomes kept apart: approved-override warnings, and the +/// `(purl, message)` of each refused conflict. +pub(crate) async fn partition( + common: &GlobalArgs, + purls: impl IntoIterator>, +) -> (Vec<(String, String)>, Vec<(String, String)>) { + let purls: Vec = purls.into_iter().map(|p| p.as_ref().to_string()).collect(); + if !any_detectable(common, purls.iter().map(String::as_str)) { + return (Vec::new(), Vec::new()); + } + let index = inspect(common).await; + let mut warnings = Vec::new(); + let mut conflicts = Vec::new(); + for purl in &purls { + let purl = purl.as_str(); + if !common.purl_ecosystem_selected(purl) { + continue; + } + let sources = index.conflicts(purl); + if sources.is_empty() { + continue; + } + let message = detail(purl, &sources, common.allow_user_patches); + if common.allow_user_patches { + warnings.push((ALLOWED.into(), message)); + } else { + conflicts.push((purl.to_string(), message)); + } + } + (warnings, conflicts) +} + +/// The human copy of [`check`]'s approved overrides; JSON carries them in +/// the envelope and `--silent` mutes them. +pub(crate) fn warn(common: &GlobalArgs, warnings: &[(String, String)]) { + if !common.silent && !common.json { + for (_, detail) in warnings { + eprintln!("Warning: {detail}"); + } + } +} + +/// The manifest-wide gate for `apply`: rather than refuse a whole run over +/// one protected entry (a postinstall apply would fail every install), drop +/// the protected records from this run's in-memory copy and report each as +/// a `user_patch_conflict` warning. Approved overrides stay in and come back +/// as `user_patch_override` warnings. Only the caller's copy changes; the +/// manifest on disk is never rewritten here. Also returns the skipped purls: +/// they are still recorded, so nothing may reconcile them away as dropped. +pub(crate) async fn skip_protected( + common: &GlobalArgs, + patches: &mut std::collections::HashMap< + String, + socket_patch_core::manifest::schema::PatchRecord, + >, +) -> (Vec<(String, String)>, std::collections::HashSet) { + let mut warnings = Vec::new(); + let mut skipped = std::collections::HashSet::new(); + if !any_detectable(common, patches.keys().map(String::as_str)) { + return (warnings, skipped); + } + let index = inspect(common).await; + patches.retain(|purl, _| { + if !common.purl_ecosystem_selected(purl) { + return true; + } + let sources = index.conflicts(purl); + if sources.is_empty() { + return true; + } + let message = detail(purl, &sources, common.allow_user_patches); + if common.allow_user_patches { + warnings.push((ALLOWED.into(), message)); + true + } else { + warnings.push((CONFLICT.into(), message)); + skipped.insert(purl.clone()); + false + } + }); + warnings.sort(); + (warnings, skipped) +} + +pub(crate) fn refuse(common: &GlobalArgs, command: Command, error: EnvelopeError) -> i32 { + if common.json { + let mut env = Envelope::new(command); + env.dry_run = common.dry_run; + env.mark_error(error); + crate::ui::print_json(&env.to_value()); + } else { + eprintln!("Error: {}", error.message); + } + 1 +} diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 3516e7c49..b7ed604f6 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -1728,6 +1728,14 @@ impl EjectSnapshot { /// the project to upstream, not to hosted). async fn run_eject(args: &VendorArgs, pins: Vec) -> i32 { let common = &args.common; + let user_patch_warnings = + match super::user_patches::check(common, pins.iter().map(|p| &p.purl)).await { + Ok(warnings) => { + super::user_patches::warn(common, &warnings); + warnings + } + Err(error) => return super::user_patches::refuse(common, Command::Vendor, error), + }; let (client, use_public_proxy) = get_api_client_with_overrides(common.api_client_overrides()).await; let telemetry = TelemetryAuth::for_client(&client); @@ -1783,6 +1791,11 @@ async fn run_eject(args: &VendorArgs, pins: Vec) -> i32 { // before anything is touched, so every package stays hosted. if !fetch_failures.is_empty() { let mut env = Envelope::new(Command::Vendor); + env.warnings.extend( + user_patch_warnings + .into_iter() + .map(|(code, detail)| crate::json_envelope::RunWarning { code, detail }), + ); env.dry_run = common.dry_run; if common.json { env.warnings @@ -1844,6 +1857,11 @@ async fn run_eject(args: &VendorArgs, pins: Vec) -> i32 { } if !refused.is_empty() { let mut env = Envelope::new(Command::Vendor); + env.warnings.extend( + user_patch_warnings + .into_iter() + .map(|(code, detail)| RunWarning { code, detail }), + ); env.dry_run = common.dry_run; if common.json { env.warnings @@ -1873,6 +1891,11 @@ async fn run_eject(args: &VendorArgs, pins: Vec) -> i32 { // preview the vendor step would be a write. if common.dry_run { let mut env = Envelope::new(Command::Vendor); + env.warnings.extend( + user_patch_warnings + .into_iter() + .map(|(code, detail)| RunWarning { code, detail }), + ); env.dry_run = true; if common.json { env.warnings @@ -1926,6 +1949,11 @@ async fn run_eject(args: &VendorArgs, pins: Vec) -> i32 { return emit_eject_refusal(common, code, &message); } }; + if !common.allow_user_patches { + if let Err(error) = super::user_patches::check(common, pins.iter().map(|p| &p.purl)).await { + return super::user_patches::refuse(common, Command::Vendor, error); + } + } let touched: Vec = pins .iter() .flat_map(|p| p.files.iter().cloned()) @@ -1943,6 +1971,11 @@ async fn run_eject(args: &VendorArgs, pins: Vec) -> i32 { } }; let mut env = Envelope::new(Command::Vendor); + env.warnings.extend( + user_patch_warnings + .into_iter() + .map(|(code, detail)| RunWarning { code, detail }), + ); if common.json { env.warnings .extend(crate::commands::vex_sources::api_auth_fallback_warning( @@ -2198,6 +2231,44 @@ async fn run_vendor( // even when zero in-scope patches remain. Its post-reconcile ledger // feeds the staging harvest below and then the engine. let (mut has_errors, ledger) = reconcile_dropped(&manifest, common, env).await; + // After the reconcile: reverting entries dropped from the manifest + // touches no project patch, so a protected record must not block it. + let (warnings, conflicts) = + super::user_patches::partition(common, manifest.patches.keys()).await; + super::user_patches::warn(common, &warnings); + env.warnings.extend( + warnings + .into_iter() + .map(|(code, detail)| crate::json_envelope::RunWarning { code, detail }), + ); + if !conflicts.is_empty() { + for (_, message) in &conflicts { + if !common.json { + eprintln!("Error: {message}"); + } + } + if env.events.is_empty() { + let mut messages: Vec = conflicts.into_iter().map(|(_, m)| m).collect(); + messages.dedup(); + env.mark_error(crate::json_envelope::EnvelopeError::new( + super::user_patches::CONFLICT, + messages.join("\n"), + )); + } else { + // The reconcile already wrote: a top-level error would claim + // nothing happened, so the refusals are per-package failures. + for (purl, message) in conflicts { + env.record( + crate::json_envelope::PatchEvent::new( + crate::json_envelope::PatchAction::Failed, + purl.as_str(), + ) + .with_error(super::user_patches::CONFLICT, message), + ); + } + } + return 1; + } let socket_dir = crate::args::socket_dir_of(manifest_path, &common.cwd); if manifest.patches.is_empty() && !common.json && !common.silent { diff --git a/crates/socket-patch-cli/src/commands/vendored_backend/mod.rs b/crates/socket-patch-cli/src/commands/vendored_backend/mod.rs index 0830ec6b1..5132f8ea4 100644 --- a/crates/socket-patch-cli/src/commands/vendored_backend/mod.rs +++ b/crates/socket-patch-cli/src/commands/vendored_backend/mod.rs @@ -93,6 +93,17 @@ impl<'a> VendoredBackend<'a> { /// `scan_run_fits_windows_main_thread_stack`). pub(crate) async fn apply(&self, req: ApplyRequest<'_>, env: &mut Envelope) -> bool { let common: &GlobalArgs = &self.common; + if !common.allow_user_patches { + if let Err(error) = + super::user_patches::check(common, req.manifest.patches.keys()).await + { + if !common.json { + eprintln!("Error: {}", error.message); + } + env.mark_error(error); + return true; + } + } let blobs = req.socket_dir.join("blobs"); let sources = socket_patch_core::patch::apply::PatchSources { blobs_path: &blobs, diff --git a/crates/socket-patch-cli/tests/apply/apply_network.rs b/crates/socket-patch-cli/tests/apply/apply_network.rs index 379af2b57..1027b076f 100644 --- a/crates/socket-patch-cli/tests/apply/apply_network.rs +++ b/crates/socket-patch-cli/tests/apply/apply_network.rs @@ -436,7 +436,7 @@ async fn apply_with_force_overrides_hash_mismatch() { } #[tokio::test] -async fn apply_hash_mismatch_default_warns_and_applies_strict_fails() { +async fn apply_hash_mismatch_opt_in_warns_and_applies_strict_fails() { let after = b"after\n"; let after_hash = git_sha256(after); let expected_before = b"expected-before\n"; @@ -461,11 +461,11 @@ async fn apply_hash_mismatch_default_warns_and_applies_strict_fails() { tmp }; - // DEFAULT: the mismatch is overwritten with the full verified patched + // OPT-IN: the mismatch is overwritten with the full verified patched // content (the blob is hash-gated to afterHash) and surfaced as a warning event — exit 0. let tmp = fixture(); let out = hermetic::command(&binary()) - .args(["apply", "--json", "--offline"]) + .args(["apply", "--allow-user-patches", "--json", "--offline"]) .current_dir(tmp.path()) .output() .expect("run socket-patch"); @@ -474,7 +474,7 @@ async fn apply_hash_mismatch_default_warns_and_applies_strict_fails() { assert_eq!( out.status.code().unwrap_or(-1), 0, - "default mismatch is a warning, not an error: {v:#}" + "opted-in mismatch is a warning, not an error: {v:#}" ); assert_eq!(v["status"], "success", "{v:#}"); let events = v["events"].as_array().expect("events array"); @@ -497,7 +497,7 @@ async fn apply_hash_mismatch_default_warns_and_applies_strict_fails() { // The human run logs the warning to stderr. let tmp = fixture(); let out = hermetic::command(&binary()) - .args(["apply", "--offline", "--yes"]) + .args(["apply", "--allow-user-patches", "--offline", "--yes"]) .current_dir(tmp.path()) .output() .expect("run socket-patch"); @@ -511,7 +511,13 @@ async fn apply_hash_mismatch_default_warns_and_applies_strict_fails() { // --strict: fail closed — exit 1, failed event, file untouched. let tmp = fixture(); let out = hermetic::command(&binary()) - .args(["apply", "--json", "--offline", "--strict"]) + .args([ + "apply", + "--allow-user-patches", + "--json", + "--offline", + "--strict", + ]) .current_dir(tmp.path()) .output() .expect("run socket-patch"); @@ -686,7 +692,7 @@ async fn apply_uses_locally_cached_blob_without_fetching() { // Mismatch: the full blob is downloaded and applied. // --------------------------------------------------------------------------- -/// The default mismatch policy applies the FULL afterHash blob to a +/// The explicitly approved mismatch policy applies the FULL afterHash blob to a /// mismatched file. A leftover legacy package archive is not a source, so /// the stage step still downloads the blob. #[tokio::test] @@ -749,7 +755,7 @@ async fn apply_mismatch_redownloads_full_blob_and_applies() { .unwrap(); } - let (code, stdout, stderr) = run_apply(tmp.path(), &mock.uri(), &[]); + let (code, stdout, stderr) = run_apply(tmp.path(), &mock.uri(), &["--allow-user-patches"]); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); assert_eq!(code, 0, "stdout={v:#}\nstderr={stderr}"); let events = v["events"].as_array().expect("events array"); @@ -944,7 +950,7 @@ async fn apply_online_ignores_legacy_package_archive_when_downloads_fail() { /// Two physical copies of one PURL (a root copy plus a nested duplicate): /// the root copy already carries the afterHash bytes, the nested one was /// locally modified (matches NEITHER hash). The stage step fetches the full -/// afterHash blob, and the default warn-and-overwrite policy must apply it +/// afterHash blob, and the explicitly approved warn-and-overwrite policy must apply it /// to EVERY copy, not just the first (clean, root) one. #[tokio::test] async fn mismatch_overwrites_every_copy_of_a_duplicated_package() { @@ -982,7 +988,7 @@ async fn mismatch_overwrites_every_copy_of_a_duplicated_package() { let socket = tmp.path().join(".socket"); write_manifest_with_patch(&socket, purl, uuid, &before_hash, &after_hash); - let (code, stdout, stderr) = run_apply(tmp.path(), &mock.uri(), &[]); + let (code, stdout, stderr) = run_apply(tmp.path(), &mock.uri(), &["--allow-user-patches"]); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); assert_eq!( code, 0, diff --git a/crates/socket-patch-cli/tests/apply/cli_gem_variant_mismatch_policy.rs b/crates/socket-patch-cli/tests/apply/cli_gem_variant_mismatch_policy.rs index d47c732b3..c9e4b170c 100644 --- a/crates/socket-patch-cli/tests/apply/cli_gem_variant_mismatch_policy.rs +++ b/crates/socket-patch-cli/tests/apply/cli_gem_variant_mismatch_policy.rs @@ -1,17 +1,17 @@ //! Mismatch-policy contract for release-variant (gem) packages. //! -//! For npm, a locally-modified file is handled by the documented default +//! For npm, a locally-modified file is handled by the explicitly approved //! mismatch policy: warn + apply the full verified patched content, //! `--strict` refuses, `--force` also skips missing files (see -//! `apply_network.rs::apply_hash_mismatch_default_warns_and_applies_strict_fails`). +//! `apply_network.rs::apply_hash_mismatch_opt_in_warns_and_applies_strict_fails`). //! Release-variant ecosystems (gem/pypi/maven) route through the variant //! loop instead, whose installed-distribution gate must still let the -//! default policy reach an UNQUALIFIED SINGLETON base (one bare manifest +//! opt-in policy reach an UNQUALIFIED SINGLETON base (one bare manifest //! record for the `package@version`, the common case) with a //! locally-modified file: warn-overwrite, not "no matching variant found". //! //! Behaviors pinned (all offline, real binary, synthetic gem trees): -//! * unqualified singleton + locally-modified file: default apply warns +//! * unqualified singleton + locally-modified file: opted-in apply warns //! (`content_mismatch_overwritten`) AND applies the full afterHash //! bytes; `--strict` refuses (file untouched, exit 1); `--force` //! keeps applying as before. @@ -147,14 +147,14 @@ fn singleton_fixture(cwd: &Path) -> (PathBuf, Vec) { /// the full verified patched content and surfaced as a warning — the same /// npm contract, not "no matching variant found" with the file untouched. #[test] -fn singleton_mismatch_default_warns_and_applies() { +fn singleton_mismatch_opt_in_warns_and_applies() { let tmp = tempfile::tempdir().expect("tempdir"); let (file, patched) = singleton_fixture(tmp.path()); - let (code, _stdout, stderr) = run_apply(tmp.path(), &[]); + let (code, _stdout, stderr) = run_apply(tmp.path(), &["--allow-user-patches"]); assert_eq!( code, 0, - "default mismatch on a singleton variant is a warning, not an error; stderr={stderr}" + "opted-in mismatch on a singleton variant is a warning, not an error; stderr={stderr}" ); assert_eq!( std::fs::read(&file).expect("read patched file"), @@ -178,7 +178,7 @@ fn singleton_mismatch_default_warns_and_applies() { // warning event — the same shape apply_network pins for npm. let tmp = tempfile::tempdir().expect("tempdir"); let (file, patched) = singleton_fixture(tmp.path()); - let (code, stdout, _stderr) = run_apply(tmp.path(), &["--json"]); + let (code, stdout, _stderr) = run_apply(tmp.path(), &["--json", "--allow-user-patches"]); assert_eq!(code, 0, "json run must also succeed; stdout={stdout}"); assert_eq!(std::fs::read(&file).expect("read patched file"), patched); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON envelope"); diff --git a/crates/socket-patch-cli/tests/cli_global_args.rs b/crates/socket-patch-cli/tests/cli_global_args.rs index c506e5f4e..a84cdbe07 100644 --- a/crates/socket-patch-cli/tests/cli_global_args.rs +++ b/crates/socket-patch-cli/tests/cli_global_args.rs @@ -101,6 +101,9 @@ fn global_flag_cases() -> Vec<(&'static str, Option<&'static str>, fn(&GlobalArg }), ("--offline", None, |c| assert!(c.offline)), ("--strict", None, |c| assert!(c.strict)), + ("--allow-user-patches", None, |c| { + assert!(c.allow_user_patches) + }), ("--global", None, |c| assert!(c.global)), ("--global-prefix", Some("/opt/global"), |c| { assert_eq!(c.global_prefix, Some(PathBuf::from("/opt/global"))) @@ -236,17 +239,18 @@ fn global_flag_cases_cover_every_global_field() { no_npm_allow_remote_config: _, no_vlt_install_cleanup: _, strict: _, + allow_user_patches: _, vendor_source: _, vendor_url: _, patch_server_url: _, maven_config: _, } = common; - // 26 fields ↔ 26 long-flag cases. Bump both this count and add a case when + // 27 fields ↔ 27 long-flag cases. Bump both this count and add a case when // the destructure above forces you to add a field. assert_eq!( global_flag_cases().len(), - 26, + 27, "every GlobalArgs field needs a long-flag case in global_flag_cases()", ); diff --git a/crates/socket-patch-cli/tests/cli_parse_get.rs b/crates/socket-patch-cli/tests/cli_parse_get.rs index f0c770ae2..0f52dfa21 100644 --- a/crates/socket-patch-cli/tests/cli_parse_get.rs +++ b/crates/socket-patch-cli/tests/cli_parse_get.rs @@ -136,6 +136,7 @@ struct Snap { patch_server_url: Option, offline: bool, strict: bool, + allow_user_patches: bool, global: bool, global_prefix: Option, json: bool, @@ -170,6 +171,7 @@ fn snapshot(a: &GetArgs) -> Snap { patch_server_url: a.common.patch_server_url.clone(), offline: a.common.offline, strict: a.common.strict, + allow_user_patches: a.common.allow_user_patches, global: a.common.global, global_prefix: a.common.global_prefix.clone(), json: a.common.json, @@ -212,6 +214,7 @@ fn expected_defaults(identifier: &str) -> Snap { patch_server_url: None, offline: false, strict: false, + allow_user_patches: false, global: false, global_prefix: None, json: false, diff --git a/crates/socket-patch-cli/tests/cli_parse_repair.rs b/crates/socket-patch-cli/tests/cli_parse_repair.rs index c08c72d87..454cedc33 100644 --- a/crates/socket-patch-cli/tests/cli_parse_repair.rs +++ b/crates/socket-patch-cli/tests/cli_parse_repair.rs @@ -132,6 +132,7 @@ struct Snap { patch_server_url: Option, offline: bool, strict: bool, + allow_user_patches: bool, global: bool, global_prefix: Option, json: bool, @@ -159,6 +160,7 @@ fn snapshot(a: &RepairArgs) -> Snap { patch_server_url: a.common.patch_server_url.clone(), offline: a.common.offline, strict: a.common.strict, + allow_user_patches: a.common.allow_user_patches, global: a.common.global, global_prefix: a.common.global_prefix.clone(), json: a.common.json, @@ -193,6 +195,7 @@ fn expected_defaults() -> Snap { patch_server_url: None, offline: false, strict: false, + allow_user_patches: false, global: false, global_prefix: None, json: false, diff --git a/crates/socket-patch-cli/tests/cli_parse_vex.rs b/crates/socket-patch-cli/tests/cli_parse_vex.rs index 23e5a15ab..7dc3c7451 100644 --- a/crates/socket-patch-cli/tests/cli_parse_vex.rs +++ b/crates/socket-patch-cli/tests/cli_parse_vex.rs @@ -205,6 +205,7 @@ struct Snap { patch_server_url: Option, offline: bool, strict: bool, + allow_user_patches: bool, global: bool, global_prefix: Option, json: bool, @@ -239,6 +240,7 @@ fn snapshot(a: &VexArgs) -> Snap { patch_server_url: a.common.patch_server_url.clone(), offline: a.common.offline, strict: a.common.strict, + allow_user_patches: a.common.allow_user_patches, global: a.common.global, global_prefix: a.common.global_prefix.clone(), json: a.common.json, @@ -280,6 +282,7 @@ fn expected_defaults() -> Snap { patch_server_url: None, offline: false, strict: false, + allow_user_patches: false, global: false, global_prefix: None, json: false, diff --git a/crates/socket-patch-cli/tests/e2e_golang_build.rs b/crates/socket-patch-cli/tests/e2e_golang_build.rs index 92a861bd8..4c6f2384b 100644 --- a/crates/socket-patch-cli/tests/e2e_golang_build.rs +++ b/crates/socket-patch-cli/tests/e2e_golang_build.rs @@ -720,6 +720,24 @@ fn apply_json(dir: &Path, modcache: &Path) -> (i32, String, String) { ) } +/// [`apply_json`] with `--allow-user-patches`, for guards behind that gate. +fn apply_json_consented(dir: &Path, modcache: &Path) -> (i32, String, String) { + run_socket( + dir, + &[ + "apply", + "--offline", + "--allow-user-patches", + "--ecosystems", + "golang", + "--json", + "--cwd", + dir.to_str().unwrap(), + ], + modcache, + ) +} + fn check(dir: &Path, modcache: &Path) -> (i32, String, String) { run_socket( dir, @@ -792,6 +810,8 @@ fn go_work_replace_overrides_the_socket_replace() { assert!(go_out(&consumer, &env).contains("OUT: FORK")); write_patch(&consumer); + // The go.work replace is also a user patch; consent past that gate so + // the run reaches the go.work guard this test is about. // The dry run predicts the refusal. let (code, so, se) = run_socket( &consumer, @@ -799,6 +819,7 @@ fn go_work_replace_overrides_the_socket_replace() { "apply", "--offline", "--dry-run", + "--allow-user-patches", "--ecosystems", "golang", "--json", @@ -812,7 +833,7 @@ fn go_work_replace_overrides_the_socket_replace() { "apply --dry-run must predict the refusal:\n{so}\n{se}" ); assert!(so.contains("go.work replaces"), "{so}"); - let (code, so, se) = apply_json(&consumer, &modcache); + let (code, so, se) = apply_json_consented(&consumer, &modcache); assert_ne!( code, 0, "apply must refuse an overridden replace:\n{so}\n{se}" @@ -827,12 +848,12 @@ fn go_work_replace_overrides_the_socket_replace() { // Applied before the go.work replace existed: the replace is inert. std::fs::remove_file(consumer.join("go.work")).unwrap(); - let (code, so, se) = apply_json(&consumer, &modcache); + let (code, so, se) = apply_json_consented(&consumer, &modcache); assert_eq!(code, 0, "apply without go.work:\n{so}\n{se}"); std::fs::write(consumer.join("go.work"), &go_work).unwrap(); assert!(go_out(&consumer, &env).contains("OUT: FORK")); // A re-run doesn't call the inert replace in sync. - let (code, so, se) = apply_json(&consumer, &modcache); + let (code, so, se) = apply_json_consented(&consumer, &modcache); assert_ne!(code, 0, "re-run apply must refuse:\n{so}\n{se}"); let (code, so, se) = check(&consumer, &modcache); assert_eq!( diff --git a/crates/socket-patch-cli/tests/e2e_golang_hosted_state.rs b/crates/socket-patch-cli/tests/e2e_golang_hosted_state.rs index d41f404e0..fd333e516 100644 --- a/crates/socket-patch-cli/tests/e2e_golang_hosted_state.rs +++ b/crates/socket-patch-cli/tests/e2e_golang_hosted_state.rs @@ -205,7 +205,14 @@ async fn refused_go_rewrite_is_not_confirmed_by_another_lockfile() { let server = MockServer::start().await; mount_hosted_grant(&server).await; - let env = get_hosted(&consumer, &server, &tmp.path().join("modcache")); + // The user's replace is also a user patch; consent past that gate so + // the run reaches the hosted rewriter's own replace refusal. + let env = get_hosted_with( + &consumer, + &server, + &tmp.path().join("modcache"), + &["--allow-user-patches"], + ); assert_eq!(hosted_pinned(&env), 0, "envelope: {env}"); assert!( env["warnings"] diff --git a/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs b/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs index ac1e6732c..966d2bfba 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs @@ -550,6 +550,9 @@ async fn run_shape(shape: Shape) -> Option<()> { "--json", "--yes", "--no-telemetry", + // Consent past the user-patch gate so the shapes reach the + // redirect guards; tests/user_patches.rs covers the gate itself. + "--allow-user-patches", "--cwd", &proj_s, "--api-url", @@ -574,6 +577,7 @@ async fn run_shape(shape: Shape) -> Option<()> { .into_iter() .flatten() .filter_map(|w| w["code"].as_str()) + .filter(|c| *c != "user_patch_override") .collect(); assert_eq!( codes, diff --git a/crates/socket-patch-cli/tests/e2e_vendor_cargo_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_cargo_build.rs index cc46f245a..33915132a 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_cargo_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_cargo_build.rs @@ -2653,6 +2653,7 @@ fn cargo_url_spelled_crates_io_patch_table_is_refused() { "vendor", "--json", "--offline", + "--allow-user-patches", "--cwd", proj.to_str().unwrap(), ], @@ -2734,6 +2735,7 @@ fn cargo_ancestor_config_patch_cannot_silently_shadow_the_vendored_copy() { "vendor", "--json", "--offline", + "--allow-user-patches", "--cwd", proj.to_str().unwrap(), ], diff --git a/crates/socket-patch-cli/tests/get/get_failure_paths.rs b/crates/socket-patch-cli/tests/get/get_failure_paths.rs index 29b8e5b0d..5372dd655 100644 --- a/crates/socket-patch-cli/tests/get/get_failure_paths.rs +++ b/crates/socket-patch-cli/tests/get/get_failure_paths.rs @@ -1628,7 +1628,7 @@ async fn get_uuid_json_nested_apply_failure_names_the_patch() { } /// The agent engine (`scan --mode agent --json`'s path) over an installed -/// file a local edit changed: the default policy overwrites it, and the +/// file a local edit changed: the opted-in policy overwrites it, and the /// envelope must say so — the warning `apply --json` reports as a /// `content_mismatch_overwritten` event — while the patch still counts as /// applied. @@ -1644,6 +1644,7 @@ async fn engine_nested_apply_mismatch_overwrite_reaches_the_json_envelope() { std::fs::write(&installed, LOCAL_EDIT_BYTES).unwrap(); let mut params = engine_params(tmp.path()); params.save_only = false; + params.allow_user_patches = true; let selected = vec![search_result(UUID, PURL)]; let (code, json) = download_and_apply_patches(&selected, ¶ms, &server.uri()).await; @@ -1687,7 +1688,11 @@ async fn get_uuid_json_mismatch_overwrite_is_reported() { write_project(tmp.path()); let installed = tmp.path().join("node_modules").join(NAME).join("index.js"); std::fs::write(&installed, LOCAL_EDIT_BYTES).unwrap(); - let (code, stdout, stderr) = run_get_bin(tmp.path(), &server.uri(), &[UUID, "--json"]); + let (code, stdout, stderr) = run_get_bin( + tmp.path(), + &server.uri(), + &[UUID, "--json", "--allow-user-patches"], + ); assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); let json = parse_json_envelope(&stdout); assert_eq!(json["status"], "success", "json={json}"); @@ -1699,3 +1704,62 @@ async fn get_uuid_json_mismatch_overwrite_is_reported() { assert!(!stderr.contains("did not match"), "stderr={stderr}"); assert_eq!(std::fs::read(&installed).unwrap(), AFTER_BYTES); } + +#[tokio::test] +async fn get_uuid_requires_specific_consent_in_every_mode_before_persisting() { + let server = MockServer::start().await; + mount_real_view(&server, UUID, PURL).await; + for extra in [ + vec![UUID, "--json", "--mode", "agent"], + vec![UUID, "--json", "--mode", "hosted"], + vec![UUID, "--json", "--mode", "vendored"], + vec![UUID, "--json", "--save-only"], + vec![UUID, "--json", "--mode", "agent", "--dry-run"], + ] { + let tmp = tempfile::tempdir().unwrap(); + write_project(tmp.path()); + std::fs::create_dir(tmp.path().join("patches")).unwrap(); + std::fs::write( + tmp.path().join(format!("patches/{NAME}+1.0.0.patch")), + "user diff", + ) + .unwrap(); + let (code, stdout, stderr) = run_get_bin(tmp.path(), &server.uri(), &extra); + let json = parse_json_envelope(&stdout); + assert_eq!(code, 1, "{extra:?}: {stdout} {stderr}"); + assert_eq!(json["error"]["code"], "user_patch_conflict", "{json}"); + assert!(!tmp.path().join(".socket/manifest.json").exists()); + assert_eq!( + std::fs::read(tmp.path().join(format!("node_modules/{NAME}/index.js"))).unwrap(), + BEFORE_BYTES + ); + } +} + +#[tokio::test] +async fn get_uuid_forwards_explicit_consent_to_nested_apply() { + let server = MockServer::start().await; + mount_real_view(&server, UUID, PURL).await; + let tmp = tempfile::tempdir().unwrap(); + write_project(tmp.path()); + std::fs::create_dir(tmp.path().join("patches")).unwrap(); + let patch = tmp.path().join(format!("patches/{NAME}+1.0.0.patch")); + std::fs::write(&patch, "user diff").unwrap(); + let (code, stdout, stderr) = run_get_bin( + tmp.path(), + &server.uri(), + &[UUID, "--json", "--mode", "agent", "--allow-user-patches"], + ); + assert_eq!(code, 0, "{stdout} {stderr}"); + assert_eq!( + std::fs::read(tmp.path().join(format!("node_modules/{NAME}/index.js"))).unwrap(), + AFTER_BYTES + ); + assert_eq!(std::fs::read_to_string(patch).unwrap(), "user diff"); + let json = parse_json_envelope(&stdout); + assert!(json["warnings"] + .as_array() + .unwrap() + .iter() + .any(|w| w["code"] == "user_patch_override")); +} diff --git a/crates/socket-patch-cli/tests/get/get_fixture.rs b/crates/socket-patch-cli/tests/get/get_fixture.rs index b094249b4..d35118cea 100644 --- a/crates/socket-patch-cli/tests/get/get_fixture.rs +++ b/crates/socket-patch-cli/tests/get/get_fixture.rs @@ -293,6 +293,8 @@ pub fn engine_params(root: &Path) -> DownloadParams { json: true, silent: true, strict: false, + allow_user_patches: false, + user_patch_skips_reported: false, ecosystems: None, persist_blobs: true, patch_server_url: None, diff --git a/crates/socket-patch-cli/tests/in_process_cargo_apply.rs b/crates/socket-patch-cli/tests/in_process_cargo_apply.rs index da00bb155..ff3a1defd 100644 --- a/crates/socket-patch-cli/tests/in_process_cargo_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_cargo_apply.rs @@ -347,6 +347,7 @@ async fn cargo_apply_refuses_on_before_hash_mismatch() { // --force) deliberately downgrade a hash mismatch to "ready" // and the file WOULD be overwritten with verified content. strict: true, + allow_user_patches: false, ..socket_patch_cli::args::GlobalArgs::default() }, batch_size: Some(100), diff --git a/crates/socket-patch-cli/tests/in_process_get_update_count.rs b/crates/socket-patch-cli/tests/in_process_get_update_count.rs index 838720388..6c4fb8847 100644 --- a/crates/socket-patch-cli/tests/in_process_get_update_count.rs +++ b/crates/socket-patch-cli/tests/in_process_get_update_count.rs @@ -91,6 +91,8 @@ fn params(root: &Path) -> DownloadParams { json: true, silent: true, strict: false, + allow_user_patches: false, + user_patch_skips_reported: false, ecosystems: None, persist_blobs: true, patch_server_url: None, diff --git a/crates/socket-patch-cli/tests/scan/hosted_failure_paths.rs b/crates/socket-patch-cli/tests/scan/hosted_failure_paths.rs index 12d5588b5..a4b9e779d 100644 --- a/crates/socket-patch-cli/tests/scan/hosted_failure_paths.rs +++ b/crates/socket-patch-cli/tests/scan/hosted_failure_paths.rs @@ -1208,7 +1208,7 @@ async fn unreadable_pnpm_workspace_gets_warning_only_guidance_in_a_live_run() { return; } - let (code, doc) = scan_hosted_json(tmp.path(), &server.uri(), &[], &[]); + let (code, doc) = scan_hosted_json(tmp.path(), &server.uri(), &["--allow-user-patches"], &[]); // Restore before asserting so the tempdir cleans up and the bytes can // be read back. std::fs::set_permissions(&ws, std::fs::Permissions::from_mode(0o644)).unwrap(); diff --git a/crates/socket-patch-cli/tests/scan/scan_failure_paths.rs b/crates/socket-patch-cli/tests/scan/scan_failure_paths.rs index f7e1c9a4a..758bf58dd 100644 --- a/crates/socket-patch-cli/tests/scan/scan_failure_paths.rs +++ b/crates/socket-patch-cli/tests/scan/scan_failure_paths.rs @@ -573,7 +573,11 @@ async fn scan_agent_json_mismatch_overwrite_reaches_the_apply_block() { b"before\n// local edit\n", ); - let (code, v) = run_json(tmp.path(), &mock.uri(), &["--mode", "agent"]); + let (code, v) = run_json( + tmp.path(), + &mock.uri(), + &["--mode", "agent", "--allow-user-patches"], + ); assert_eq!(code, 0, "{v:#}"); assert_eq!(v["summary"]["applied"], 1, "{v}"); let detail = hosted::warning_detail(&v, "content_mismatch_overwritten"); @@ -851,3 +855,79 @@ async fn scan_vendored_ignores_a_degraded_pre_v5_vlt_ledger_edit() { ); } } + +#[tokio::test] +async fn scan_skips_user_patches_in_every_mode_before_querying_or_writing() { + let mock = MockServer::start().await; + for mode in ["agent", "hosted", "vendored"] { + let tmp = minimist_project(b"before\n"); + let declaration = r#"{"dependencies":{"minimist":"1.2.8"},"pnpm":{"patchedDependencies":{"minimist":"missing.patch"}}}"#; + std::fs::write(tmp.path().join("package.json"), declaration).unwrap(); + let (code, json) = run_json(tmp.path(), &mock.uri(), &["--mode", mode, "--yes"]); + assert_eq!(code, 0, "{mode}: {json}"); + assert!( + json["warnings"] + .as_array() + .unwrap() + .iter() + .any(|w| w["code"] == "user_patch_conflict"), + "{json}" + ); + assert!(!tmp.path().join(".socket").exists()); + assert_eq!( + std::fs::read_to_string(tmp.path().join("package.json")).unwrap(), + declaration + ); + assert_eq!( + std::fs::read(tmp.path().join("node_modules/minimist/index.js")).unwrap(), + b"before\n" + ); + } + assert!(mock.received_requests().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn scan_still_patches_unrelated_packages_and_forwards_explicit_consent() { + for allowed in [false, true] { + let mock = MockServer::start().await; + let purl = "pkg:npm/example@1.0.0"; + mount_one_patch_api(&mock, purl, b"before\n").await; + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "example", "1.0.0", b"before\n"); + std::fs::create_dir(tmp.path().join("patches")).unwrap(); + if !allowed { + write_npm_package(tmp.path(), "unrelated", "1.0.0", b"before\n"); + } + let patched_name = if allowed { "example" } else { "unrelated" }; + std::fs::write( + tmp.path() + .join(format!("patches/{patched_name}+1.0.0.patch")), + "user diff", + ) + .unwrap(); + let mut args = vec!["--mode", "agent"]; + if allowed { + args.push("--allow-user-patches"); + } + let (code, json) = run_json(tmp.path(), &mock.uri(), &args); + assert_eq!(code, 0, "{json}"); + if !allowed { + assert_eq!( + std::fs::read(tmp.path().join("node_modules/unrelated/index.js")).unwrap(), + b"before\n" + ); + assert!(mock + .received_requests() + .await + .unwrap() + .iter() + .all(|request| !String::from_utf8_lossy(&request.body) + .contains("pkg:npm/unrelated@"))); + } + assert_eq!( + std::fs::read(tmp.path().join("node_modules/example/index.js")).unwrap(), + b"after\n" + ); + } +} diff --git a/crates/socket-patch-cli/tests/user_patches.rs b/crates/socket-patch-cli/tests/user_patches.rs new file mode 100644 index 000000000..8e6b5ce69 --- /dev/null +++ b/crates/socket-patch-cli/tests/user_patches.rs @@ -0,0 +1,282 @@ +//! Consent is checked at package level, even when the Socket fix touches +//! a different file and beforeHash verification would succeed. +mod common; + +use common::{git_sha256, run_with_env, write_blob, write_minimal_manifest, PatchEntry}; +use std::path::Path; + +const PURL: &str = "pkg:npm/example@1.0.0"; +const UUID: &str = "11111111-1111-4111-8111-111111111111"; +const BEFORE: &[u8] = b"original\n"; +const AFTER: &[u8] = b"Socket fix\n"; + +fn write(root: &Path, path: &str, bytes: impl AsRef<[u8]>) { + let path = root.join(path); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); +} + +fn fixture() -> tempfile::TempDir { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "package.json", "{}"); + write( + tmp.path(), + "node_modules/example/package.json", + r#"{"name":"example","version":"1.0.0"}"#, + ); + write(tmp.path(), "node_modules/example/index.js", BEFORE); + write(tmp.path(), "node_modules/example/user.js", b"user fix\n"); + let socket = tmp.path().join(".socket"); + write_minimal_manifest( + &socket, + PURL, + UUID, + &[PatchEntry { + file_name: "index.js", + before_hash: &git_sha256(BEFORE), + after_hash: &git_sha256(AFTER), + }], + ); + write_blob(&socket, &git_sha256(AFTER), AFTER); + tmp +} + +#[test] +fn apply_skips_and_vendor_refuses_declared_patches_despite_matching_hashes_or_general_confirmation() +{ + for (file, contents) in [ + ( + "package.json", + r#"{"pnpm":{"patchedDependencies":{"example@1.0.0":"patches/fix.patch"}}}"#, + ), + ( + "pnpm-workspace.yaml", + "patchedDependencies:\n example@1.0.0: missing.patch\n", + ), + ( + "package.json", + r#"{"resolutions":{"example":"patch:example@npm%3A1.0.0#./fix.patch"}}"#, + ), + ("patches/example+1.0.0.patch", "diff for a different file"), + ( + "package.json", + r#"{"patchedDependencies":{"example@1.0.0":"patches/fix.patch"}}"#, + ), + ] { + for args in [ + vec!["apply", "--json", "--offline", "--yes"], + vec!["apply", "--json", "--offline", "--force"], + vec!["apply", "--json", "--offline", "--dry-run"], + vec!["vendor", "--json", "--offline", "--force"], + ] { + let tmp = fixture(); + write(tmp.path(), file, contents); + let original = std::fs::read(tmp.path().join(".socket/manifest.json")).unwrap(); + let (code, stdout, stderr) = run_with_env(tmp.path(), &args, &[]); + let value: serde_json::Value = serde_json::from_str(&stdout).expect(&stdout); + // apply leaves the package out with a warning and succeeds (a + // postinstall apply must not fail every install); vendor, an + // explicit vendoring of the whole manifest, refuses. + let message = if args[0] == "apply" { + assert_eq!(code, 0, "{args:?} {file}: {stdout} {stderr}"); + let warning = value["warnings"] + .as_array() + .unwrap() + .iter() + .find(|w| w["code"] == "user_patch_conflict") + .unwrap_or_else(|| panic!("{args:?} {file}: {value}")); + warning["detail"].as_str().unwrap().to_string() + } else { + assert_eq!(code, 1, "{args:?} {file}: {stdout} {stderr}"); + assert_eq!(value["error"]["code"], "user_patch_conflict", "{value}"); + value["error"]["message"].as_str().unwrap().to_string() + }; + assert!(message.contains("--allow-user-patches"), "{message}"); + assert_eq!( + std::fs::read(tmp.path().join("node_modules/example/index.js")).unwrap(), + BEFORE + ); + assert_eq!( + std::fs::read(tmp.path().join(file)).unwrap(), + contents.as_bytes() + ); + assert_eq!( + std::fs::read(tmp.path().join(".socket/manifest.json")).unwrap(), + original + ); + assert!(!tmp.path().join(".socket/vendor/state.json").exists()); + assert!(!tmp.path().join(".socket/apply.lock").exists()); + } + } +} + +#[test] +fn consent_allows_agent_application_and_does_not_modify_the_user_patch() { + let tmp = fixture(); + write(tmp.path(), "patches/example+1.0.0.patch", b"user diff\n"); + let (code, stdout, stderr) = run_with_env( + tmp.path(), + &["apply", "--json", "--offline", "--allow-user-patches"], + &[], + ); + assert_eq!(code, 0, "{stdout} {stderr}"); + let value: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert!(value["warnings"] + .as_array() + .unwrap() + .iter() + .any(|w| w["code"] == "user_patch_override")); + assert_eq!( + std::fs::read(tmp.path().join("node_modules/example/index.js")).unwrap(), + AFTER + ); + assert_eq!( + std::fs::read(tmp.path().join("node_modules/example/user.js")).unwrap(), + b"user fix\n" + ); + assert_eq!( + std::fs::read(tmp.path().join("patches/example+1.0.0.patch")).unwrap(), + b"user diff\n" + ); +} + +#[test] +fn unknown_local_edits_fail_closed_by_default_including_yes_silent_and_dry_run() { + for extra in [ + vec![], + vec!["--yes"], + vec!["--silent"], + vec!["--dry-run"], + vec!["--allow-user-patches", "--strict"], + ] { + let tmp = fixture(); + write(tmp.path(), "node_modules/example/index.js", b"local edit\n"); + let mut args = vec!["apply", "--json", "--offline"]; + args.extend(extra); + let (code, stdout, stderr) = run_with_env(tmp.path(), &args, &[]); + assert_eq!(code, 1, "{args:?}: {stdout} {stderr}"); + assert_eq!( + std::fs::read(tmp.path().join("node_modules/example/index.js")).unwrap(), + b"local edit\n" + ); + } +} + +#[test] +fn unrelated_versions_and_ecosystems_are_not_blocked() { + let tmp = fixture(); + write( + tmp.path(), + "patches/example+2.0.0.patch", + b"different release\n", + ); + write( + tmp.path(), + "composer.json", + r#"{"extra":{"patches":{"a/b":{"fix":"fix.patch"}}}}"#, + ); + let (code, stdout, stderr) = run_with_env( + tmp.path(), + &["apply", "--json", "--offline", "--ecosystems", "npm"], + &[], + ); + assert_eq!(code, 0, "{stdout} {stderr}"); + assert_eq!( + std::fs::read(tmp.path().join("node_modules/example/index.js")).unwrap(), + AFTER + ); +} + +#[test] +fn environment_and_read_only_checks_do_not_grant_or_require_consent() { + let tmp = fixture(); + write(tmp.path(), "patches/example+1.0.0.patch", b"user diff\n"); + let (code, stdout, _) = run_with_env( + tmp.path(), + &["apply", "--json", "--offline"], + &[("SOCKET_ALLOW_USER_PATCHES", "1")], + ); + assert_eq!(code, 0, "{stdout}"); + assert!(stdout.contains("user_patch_conflict")); + assert_eq!( + std::fs::read(tmp.path().join("node_modules/example/index.js")).unwrap(), + BEFORE, + "the environment grants no consent" + ); + let (_, stdout, _) = run_with_env( + tmp.path(), + &["apply", "--json", "--offline", "--check"], + &[], + ); + assert!(!stdout.contains("user_patch_conflict"), "{stdout}"); +} + +#[test] +fn apply_skips_only_the_protected_package() { + let tmp = fixture(); + write(tmp.path(), "patches/example+1.0.0.patch", b"user diff\n"); + write( + tmp.path(), + "node_modules/other/package.json", + r#"{"name":"other","version":"2.0.0"}"#, + ); + write(tmp.path(), "node_modules/other/index.js", BEFORE); + let manifest_path = tmp.path().join(".socket/manifest.json"); + let mut manifest: serde_json::Value = + serde_json::from_slice(&std::fs::read(&manifest_path).unwrap()).unwrap(); + let mut other = manifest["patches"][PURL].clone(); + other["uuid"] = "22222222-2222-4222-8222-222222222222".into(); + manifest["patches"]["pkg:npm/other@2.0.0"] = other; + std::fs::write(&manifest_path, manifest.to_string()).unwrap(); + let original = std::fs::read(&manifest_path).unwrap(); + + let (code, stdout, stderr) = run_with_env(tmp.path(), &["apply", "--json", "--offline"], &[]); + assert_eq!(code, 0, "{stdout} {stderr}"); + let value: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let conflicts: Vec<_> = value["warnings"] + .as_array() + .unwrap() + .iter() + .filter(|w| w["code"] == "user_patch_conflict") + .collect(); + assert_eq!(conflicts.len(), 1, "{value}"); + assert!(conflicts[0]["detail"].as_str().unwrap().contains(PURL)); + assert_eq!( + std::fs::read(tmp.path().join("node_modules/example/index.js")).unwrap(), + BEFORE + ); + assert_eq!( + std::fs::read(tmp.path().join("node_modules/other/index.js")).unwrap(), + AFTER + ); + assert_eq!(std::fs::read(&manifest_path).unwrap(), original); +} + +#[test] +fn default_apply_replaces_an_earlier_socket_patch_but_not_a_local_edit() { + // After an update to a newer patch for the same release, the file holds + // the old patch's output, whose blob Socket stored: not a local change. + let previous: &[u8] = b"older Socket fix\n"; + let tmp = fixture(); + write_blob(&tmp.path().join(".socket"), &git_sha256(previous), previous); + write(tmp.path(), "node_modules/example/index.js", previous); + let (code, stdout, stderr) = run_with_env(tmp.path(), &["apply", "--json", "--offline"], &[]); + assert_eq!(code, 0, "{stdout} {stderr}"); + assert_eq!( + std::fs::read(tmp.path().join("node_modules/example/index.js")).unwrap(), + AFTER + ); + + // --strict still refuses it. + write(tmp.path(), "node_modules/example/index.js", previous); + let (code, stdout, stderr) = run_with_env( + tmp.path(), + &["apply", "--json", "--offline", "--strict"], + &[], + ); + assert_eq!(code, 1, "{stdout} {stderr}"); + assert_eq!( + std::fs::read(tmp.path().join("node_modules/example/index.js")).unwrap(), + previous + ); +} diff --git a/crates/socket-patch-cli/tests/vendor/in_process_vendor_bun.rs b/crates/socket-patch-cli/tests/vendor/in_process_vendor_bun.rs index 81f14e2d0..32d9b2934 100644 --- a/crates/socket-patch-cli/tests/vendor/in_process_vendor_bun.rs +++ b/crates/socket-patch-cli/tests/vendor/in_process_vendor_bun.rs @@ -493,7 +493,8 @@ fn assert_refusal_left_tree_alone(root: &Path, lock_before: &[u8]) { /// Drive `scan --mode vendored --json` on `shape` and pin the refusal /// contract for `code`: exit 1, `partialFailure`, the download-phase -/// record, zero downloads, zero view fetches, engine untouched. +/// record, zero downloads, zero view fetches, engine untouched. Explicit +/// user-patch consent still cannot bypass a malformed/unsupported lock. async fn assert_scan_refuses(shape: LockShape, code: &str) { let mock = MockServer::start().await; mount_patch_api(&mock).await; @@ -505,7 +506,8 @@ async fn assert_scan_refuses(shape: LockShape, code: &str) { Vec::new() }; - let (exit, stdout, stderr) = scan_vendored(tmp.path(), &mock.uri(), &["--json"]); + let (exit, stdout, stderr) = + scan_vendored(tmp.path(), &mock.uri(), &["--json", "--allow-user-patches"]); assert_eq!(exit, 1, "{shape:?}: stdout={stdout}\nstderr={stderr}"); let v = parse_single_json_doc(&stdout); assert_eq!(v["status"], "partialFailure", "{shape:?}: {v}"); @@ -1682,6 +1684,7 @@ async fn fifo_bun_lock_is_refused_without_blocking() { "--vendor-source", "service", "--json", + "--allow-user-patches", ], &uri, ); @@ -1705,6 +1708,7 @@ async fn fifo_bun_lock_is_refused_without_blocking() { "--vendor-source", "service", "--json", + "--allow-user-patches", ], &uri, ); diff --git a/crates/socket-patch-cli/tests/vendor_eject.rs b/crates/socket-patch-cli/tests/vendor_eject.rs index 8218d4ef4..2ee7df82c 100644 --- a/crates/socket-patch-cli/tests/vendor_eject.rs +++ b/crates/socket-patch-cli/tests/vendor_eject.rs @@ -271,6 +271,47 @@ async fn eject_vendors_hosted_pins_and_revert_returns_to_upstream() { assert!(!p.redirect_state().exists()); } +/// The hosted-to-vendored transition also needs explicit patch consent. +#[tokio::test] +async fn eject_requires_user_patch_consent_and_reports_it_in_previews_and_writes() { + let p = hosted_project(true).await; + let patch = p.root().join("patches/left-pad+1.3.0.patch"); + std::fs::create_dir_all(patch.parent().unwrap()).unwrap(); + std::fs::write(&patch, b"user diff\n").unwrap(); + let before = p.lock_bytes(); + + let (code, env) = p.run_json(&["vendor", "--yes"]); + assert_eq!(code, 1, "{env:#}"); + assert_eq!(env["error"]["code"], "user_patch_conflict"); + assert_eq!(p.lock_bytes(), before); + assert!(p.server.received_requests().await.unwrap().is_empty()); + + mock_registry(&p).await; + mock_view(&p).await; + for args in [ + vec!["vendor", "--allow-user-patches", "--dry-run"], + vec!["vendor", "--allow-user-patches"], + ] { + let (code, env) = p.run_json(&args); + assert_eq!(code, 0, "{args:?}: {env:#}"); + assert!( + env["warnings"] + .as_array() + .unwrap() + .iter() + .any(|w| w["code"] == "user_patch_override"), + "{env:#}" + ); + assert_eq!(std::fs::read(&patch).unwrap(), b"user diff\n"); + if args.contains(&"--dry-run") { + assert_eq!(p.lock_bytes(), before); + assert!(!p.artifact().exists()); + } else { + assert!(p.artifact().exists()); + } + } +} + /// Human eject: the first line announces the eject. #[tokio::test] async fn eject_human_output_announces_the_eject() { diff --git a/crates/socket-patch-core/src/patch/apply.rs b/crates/socket-patch-core/src/patch/apply.rs index ac8c1eed7..bfb668c09 100644 --- a/crates/socket-patch-core/src/patch/apply.rs +++ b/crates/socket-patch-core/src/patch/apply.rs @@ -45,7 +45,7 @@ pub struct VerifyResult { /// `Strict` exists. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MismatchPolicy { - /// DEFAULT: a beforeHash mismatch is overwritten with the verified + /// Explicitly permitted: a beforeHash mismatch is overwritten with the verified /// patched content and surfaced as a warning (the promoted /// [`VerifyResult`] keeps `expected_hash`/`current_hash`, which is /// how callers detect and report it). A MISSING pre-existing file is @@ -53,6 +53,12 @@ pub enum MismatchPolicy { Warn, /// A beforeHash mismatch is a hard error (`--strict`). Strict, + /// [`MismatchPolicy::Strict`], except content a Socket patch wrote + /// earlier is overwritten like [`MismatchPolicy::Warn`]: a file whose + /// hash names a stored blob (`.socket/blobs/`) holds another + /// Socket patch's output, as after an update from one patch to a newer + /// one for the same release, so no local change is lost (the CLI default). + SocketContent, /// [`MismatchPolicy::Warn`] PLUS missing pre-existing files are /// skipped instead of failing (`--force`). Force, @@ -840,7 +846,7 @@ pub const NEW_FILE_COLLISION_MESSAGE: &str = /// (CLI_CONTRACT "mismatch policy"). [`verify_file_patch`] reports it as /// `Ready` (its callers only ask "can this be written?"); the apply engine /// turns it into a `HashMismatch` with an empty `expected_hash`, so -/// `--strict` refuses it and the default policy overwrites it with the +/// `Strict` refuses it and explicit mismatch tolerance overwrites it with the /// `content_mismatch_overwritten` warning instead of silently. fn mark_new_file_collision(verify: &mut VerifyResult, file_info: &PatchFileInfo) { if file_info.before_hash.is_empty() @@ -908,8 +914,18 @@ async fn apply_package_patch_at( if verify_result.status != VerifyStatus::Ready && verify_result.status != VerifyStatus::AlreadyPatched { + let policy = match policy { + MismatchPolicy::SocketContent + if verify_result.status == VerifyStatus::HashMismatch + && is_socket_content(sources, verify_result.current_hash.as_deref()) + .await => + { + MismatchPolicy::Warn + } + policy => policy, + }; match (verify_result.status, policy) { - // Mismatch tolerated (default + force): promote to Ready. + // Mismatch explicitly tolerated (warn + force): promote to Ready. // The promoted result KEEPS `expected_hash`/`current_hash` // — the signature callers use to surface the warning. The // blob source is hash-gated to afterHash. @@ -1131,6 +1147,19 @@ async fn apply_package_patch_at( result } +/// Whether `hash` (a file's current content hash) names a blob Socket +/// stored: content some Socket patch wrote, not a local edit. +async fn is_socket_content(sources: &PatchSources<'_>, hash: Option<&str>) -> bool { + let Some(hash) = hash.filter(|h| !h.is_empty() && h.bytes().all(|b| b.is_ascii_hexdigit())) + else { + return false; + }; + sources.mem_blobs.is_some_and(|m| m.contains_key(hash)) + || tokio::fs::symlink_metadata(sources.blobs_path.join(hash)) + .await + .is_ok_and(|m| m.is_file()) +} + /// The on-disk blob source — read `blobs_path/` fail-closed. /// /// SECURITY: `hash` comes from a committed `.socket/manifest.json` that the @@ -1954,6 +1983,65 @@ mod tests { assert!(result.error.is_some()); } + /// The CLI default (`SocketContent`) overwrites content an earlier + /// Socket patch wrote (its hash names a stored blob), as on a patch + /// update, and refuses any other mismatch like `Strict`. + #[tokio::test] + async fn socket_content_policy_overwrites_only_earlier_socket_output() { + let pkg_dir = tempfile::tempdir().unwrap(); + let blobs_dir = tempfile::tempdir().unwrap(); + let patched = b"new socket patch"; + let after_hash = compute_git_sha256_from_bytes(patched); + let previous = b"old socket patch"; + tokio::fs::write(blobs_dir.path().join(&after_hash), patched) + .await + .unwrap(); + tokio::fs::write( + blobs_dir + .path() + .join(compute_git_sha256_from_bytes(previous)), + previous, + ) + .await + .unwrap(); + let mut files = HashMap::new(); + files.insert( + "index.js".to_string(), + PatchFileInfo { + before_hash: "aaaa".to_string(), + after_hash, + }, + ); + let target = pkg_dir.path().join("index.js"); + let sources = PatchSources::blobs_only(blobs_dir.path()); + let run = || { + apply_package_patch( + "pkg:npm/test@1.0.0", + pkg_dir.path(), + &files, + &sources, + false, + MismatchPolicy::SocketContent, + ) + }; + + tokio::fs::write(&target, previous).await.unwrap(); + let result = run().await; + assert!(result.success, "{:?}", result.error); + assert_eq!(tokio::fs::read(&target).await.unwrap(), patched); + let v = &result.files_verified[0]; + assert!( + v.expected_hash.is_some() && v.current_hash != v.expected_hash, + "the overwrite keeps its warning signature" + ); + + let local = b"a local edit"; + tokio::fs::write(&target, local).await.unwrap(); + let result = run().await; + assert!(!result.success); + assert_eq!(tokio::fs::read(&target).await.unwrap(), local); + } + /// beforeHash mismatch across the three policies: the DEFAULT (Warn) /// overwrites with the verified patched content and keeps the /// promoted warning signature (`Ready` + `expected_hash: Some` + @@ -3319,7 +3407,7 @@ mod tests { /// #626: agent apply must not write through a `node_modules` link to /// first-party source (a workspace member / `file:` dir, scoped or not, /// or an `npm link` target outside the project). The user's fork does - /// not match the patch's beforeHash, which the default policy would + /// not match the patch's beforeHash, which mismatch tolerance would /// otherwise overwrite. Refused for every policy, dry run included, and /// the fork keeps its bytes. #[cfg(unix)] diff --git a/crates/socket-patch-core/src/patch/mod.rs b/crates/socket-patch-core/src/patch/mod.rs index 48ca5ea9f..3f1f45b23 100644 --- a/crates/socket-patch-core/src/patch/mod.rs +++ b/crates/socket-patch-core/src/patch/mod.rs @@ -11,3 +11,4 @@ pub mod rollback; pub mod shared_store; pub mod sidecars; pub(crate) mod store_copies; +pub mod user_patches; diff --git a/crates/socket-patch-core/src/patch/user_patches.rs b/crates/socket-patch-core/src/patch/user_patches.rs new file mode 100644 index 000000000..fc0ac8c18 --- /dev/null +++ b/crates/socket-patch-core/src/patch/user_patches.rs @@ -0,0 +1,1159 @@ +//! Read-only evidence of patches owned by the project, not Socket. +//! +//! A matching declaration is sufficient: the patch need not have been installed +//! yet, and need not touch the same file as the Socket fix. Never execute hooks +//! or fetch patch URLs to establish ownership. + +use std::collections::{BTreeSet, VecDeque}; +use std::path::Path; + +use serde_json::Value; + +use crate::formats::text::strip_bom; +use crate::utils::fs::read_regular_to_string; +use crate::utils::npm_range::npm_range_admits; +use crate::utils::purl::{normalize_purl, percent_decode_purl_component, strip_purl_qualifiers}; +use crate::utils::workspace_globs::{workspaces_include_as, WorkspacesDialect}; + +#[derive(Default, Debug)] +pub struct UserPatchIndex { + entries: Vec, +} + +#[derive(Debug)] +struct Evidence { + ecosystem: &'static str, + name: String, + version: Option, + source: String, +} + +impl UserPatchIndex { + /// Inspect the project, declared workspace members and ancestor workspace + /// settings. Errors in a potentially governing file protect its ecosystem; + /// unreadable metadata must not silently disable the consent check. + pub async fn discover(root: &Path) -> Self { + let mut index = Self::default(); + let root = std::path::absolute(root).unwrap_or_else(|_| root.to_path_buf()); + let mut pending = VecDeque::from([root.clone()]); + // A member invocation still inherits the workspace's patch policy. + // The walk stops at the checkout's `.git`, and never reaches the home + // directory (whose `~/.cargo/config.toml` is user-wide, not a + // workspace) or above it, as in a container build with no `.git`. + let home = crate::utils::fs::home_dir(); + for parent in root + .ancestors() + .skip(1) + .take_while(|_| !root.join(".git").exists()) + .take_while(|parent| home.as_deref().is_none_or(|home| !home.starts_with(parent))) + { + if parent.join("pnpm-workspace.yaml").exists() + || parent.join("go.work").exists() + || parent.join(".cargo/config.toml").exists() + || parent.join(".cargo/config").exists() + || read_regular_to_string(&parent.join("package.json")) + .await + .is_ok_and(|s| s.contains("\"workspaces\"")) + || read_regular_to_string(&parent.join("Cargo.toml")) + .await + .is_ok_and(|s| s.contains("[workspace]")) + { + pending.push_back(parent.to_path_buf()); + } + if parent.join(".git").exists() { + break; + } + } + let mut seen = BTreeSet::new(); + while let Some(dir) = pending.pop_front() { + if !seen.insert(dir.clone()) { + continue; + } + if seen.len() > 4096 { + index.unknown("npm", &dir, "workspace inspection limit exceeded"); + break; + } + let mut globs = Vec::new(); + if let Some(value) = index.json(&dir.join("package.json"), "npm").await { + index.native(&value, &dir.join("package.json")); + for field in [ + "dependencies", + "devDependencies", + "optionalDependencies", + "resolutions", + ] { + if let Some(map) = value[field].as_object() { + for spec in map.values().filter_map(Value::as_str) { + index.yarn(spec, &dir.join("package.json")); + } + } + } + let workspaces = value["workspaces"] + .as_array() + .or_else(|| value["workspaces"]["packages"].as_array()); + if let Some(workspaces) = workspaces { + globs.extend( + workspaces + .iter() + .filter_map(Value::as_str) + .map(str::to_owned), + ); + } + index.patch_package(&dir, &value).await; + } else { + index.patch_directory(&dir.join("patches"), false).await; + } + let yarn_lock = dir.join("yarn.lock"); + if let Some(text) = index.read(&yarn_lock, "npm").await { + // Only Berry has patch: locators. Every Berry lock carries + // builtin compat patches, so scan the resolution lines rather + // than parse what can be a very large lockfile. + for line in text.lines() { + let Some(value) = line.trim_start().strip_prefix("resolution:") else { + continue; + }; + let value = value.trim().trim_matches('"'); + // A locator has an ident before @patch:. + if let Some((_, source)) = value.split_once("@patch:") { + index.yarn(&format!("patch:{source}"), &yarn_lock); + } + } + } + for file in ["pnpm-workspace.yaml", "pnpm-lock.yaml"] { + let path = dir.join(file); + if let Some(text) = index.read(&path, "npm").await { + if file == "pnpm-lock.yaml" + && !text.contains("patchedDependencies") + && !text.contains("patch_hash") + && !text.contains("patched:") + { + continue; + } + match serde_saphyr::from_multiple_with_options::(&text, lock_options()) { + Ok(documents) => { + for value in documents { + index.native(&value, &path); + if file == "pnpm-workspace.yaml" { + if let Some(packages) = value["packages"].as_array() { + globs.extend( + packages + .iter() + .filter_map(Value::as_str) + .map(str::to_owned), + ); + } + } + } + } + Err(e) => index.unknown( + "npm", + &path, + &format!("cannot inspect patch metadata: {e}"), + ), + } + } + } + for file in ["package-lock.json", "npm-shrinkwrap.json", "bun.lock"] { + let path = dir.join(file); + // Like the pnpm lock: a lock that never says `patched` holds + // no patch evidence, so an unparseable one can't hide any. + let Some(text) = index + .read(&path, "npm") + .await + .filter(|text| text.contains("patched")) + else { + continue; + }; + if let Some(value) = index.parse_json(&text, &path, "npm") { + index.native(&value, &path); + if let Some(packages) = value["packages"].as_object() { + for (key, entry) in packages { + if !entry["patched"].is_null() { + let name = entry["name"].as_str().or_else(|| { + key.rsplit_once("node_modules/").map(|(_, name)| name) + }); + if let Some(name) = name { + index.add("npm", name, entry["version"].as_str(), &path); + } else { + index.unknown("npm", &path, "unidentified patched lock entry"); + } + } + } + } + } + } + index.composer(&dir).await; + index.cargo(&dir).await; + for file in ["go.mod", "go.work"] { + let path = dir.join(file); + if let Some(text) = index.read(&path, "golang").await { + for entry in crate::vendor::go_mod_edit::parse_replace_entries(&text) { + if !entry.socket_owned() { + index.add("golang", &entry.module, entry.version.as_deref(), &path); + } + } + // Workspace members' go.mod replaces still apply under go.work. + if file == "go.work" { + pending.extend( + crate::vendor::go_mod_edit::parse_use_dirs(&text) + .into_iter() + .map(|member| dir.join(member)), + ); + } + } + } + for pattern in globs + .iter() + .filter(|g| !g.starts_with('!') || g.starts_with("!(")) + { + pending.extend(index.workspace_members(&dir, pattern)); + } + } + index + } + + // Use the existing npm/Yarn glob grammar, with a bounded, link-free walk. + // Positive patterns are a conservative union: a patch in an excluded member + // may still protect the same release elsewhere, never silently lose it. + fn workspace_members(&mut self, root: &Path, pattern: &str) -> Vec { + if Path::new(pattern).is_absolute() { + self.unknown("npm", root, "workspace outside the inspection root"); + return Vec::new(); + } + // Resolve `..` lexically: `packages/b/../a` is `packages/a`, and a + // leading `../shared/*` re-roots the walk at the parent. + let mut root = root.to_path_buf(); + let mut segments: Vec<&str> = Vec::new(); + for segment in pattern.split('/') { + match segment { + "" | "." => {} + ".." => match segments.last() { + Some(last) if last.contains(['*', '?', '{', '[', '(']) => { + self.unknown("npm", &root, "workspace glob traverses a wildcard"); + return Vec::new(); + } + Some(_) => { + segments.pop(); + } + None => { + if !root.pop() { + self.unknown("npm", &root, "workspace outside the filesystem root"); + return Vec::new(); + } + } + }, + _ => segments.push(segment), + } + } + let pattern = segments.join("/"); + let (root, pattern) = (root.as_path(), pattern.as_str()); + let prefix: std::path::PathBuf = pattern + .split('/') + .take_while(|p| !p.contains(['*', '?', '{', '[', '('])) + .collect(); + let base = root.join(&prefix); + if !base.exists() && !base.is_symlink() { + return Vec::new(); + } + let max_depth = if pattern.contains("**") { + 32 + } else { + pattern + .split('/') + .count() + .saturating_sub(prefix.components().count()) + }; + let walk = walkdir::WalkDir::new(&base) + .follow_links(false) + .max_depth(max_depth) + .into_iter() + .filter_entry(|entry| { + (entry.file_type().is_dir() || entry.path_is_symlink()) + && !matches!( + entry.file_name().to_str(), + Some("node_modules" | ".git" | ".socket" | "target" | ".venv") + ) + }); + let mut out = Vec::new(); + let patterns = vec![pattern.to_string()]; + for (count, entry) in walk.enumerate() { + if count >= 16384 { + self.unknown("npm", root, "workspace inspection limit exceeded"); + break; + } + let entry = match entry { + Ok(entry) => entry, + Err(error) => { + self.unknown("npm", root, &format!("cannot inspect workspace: {error}")); + continue; + } + }; + let components: Vec<_> = entry + .path() + .strip_prefix(root) + .unwrap_or(entry.path()) + .components() + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .collect(); + if entry.depth() == 32 && entry.file_type().is_dir() { + self.unknown("npm", entry.path(), "workspace inspection depth exceeded"); + } + // Package managers only treat a directory with a package.json as + // a workspace; anything else under a `**` glob is just source. + let member = entry.path().join("package.json").is_file(); + if entry.file_type().is_symlink() { + // The walk doesn't follow links, so a link that is (or leads + // to) a member can't be inspected. + if entry.depth() == 0 + || (entry.path().is_dir() + && (member + || workspaces_include_as( + &patterns, + &components, + WorkspacesDialect::YARN_BERRY, + ) && !pattern.contains("**"))) + { + self.unknown( + "npm", + entry.path(), + "linked workspace cannot be inspected safely", + ); + } + continue; + } + if member + && workspaces_include_as(&patterns, &components, WorkspacesDialect::YARN_BERRY) + { + out.push(entry.path().to_path_buf()); + } + } + out + } + + /// All evidence for this release, sorted and deduplicated. Unknown ranges + /// protect every version of that name; known exact versions don't block an + /// unrelated release. Scoped npm identities remain case-sensitive. + pub fn conflicts(&self, purl: &str) -> Vec { + let normalized = normalize_purl(purl); + let base = strip_purl_qualifiers(&normalized); + let Some((ecosystem, coordinate)) = + base.strip_prefix("pkg:").and_then(|s| s.split_once('/')) + else { + return Vec::new(); + }; + let Some((name, version)) = coordinate.rsplit_once('@') else { + return Vec::new(); + }; + self.entries + .iter() + .filter(|e| { + e.ecosystem == ecosystem + && (e.name.is_empty() + || if ecosystem == "composer" { + e.name.eq_ignore_ascii_case(name) + } else if ecosystem == "cargo" { + // crates.io names are unique ignoring case and -/_. + e.name.len() == name.len() + && e.name.bytes().zip(name.bytes()).all(|(a, b)| { + let fold = |c: u8| { + if c == b'_' { + b'-' + } else { + c.to_ascii_lowercase() + } + }; + fold(a) == fold(b) + }) + } else { + e.name == name + }) + && e.version.as_deref().is_none_or(|selector| { + selector == version + || if ecosystem == "npm" { + // pnpm treats an explicit * as its all-versions + // bucket, including prereleases, rather than as + // an ordinary semver range. + selector.trim() == "*" + || npm_range_admits(selector, version).unwrap_or(true) + } else { + false + } + }) + }) + .map(|e| e.source.clone()) + .collect::>() + .into_iter() + .collect() + } + + fn add(&mut self, ecosystem: &'static str, name: &str, version: Option<&str>, path: &Path) { + self.entries.push(Evidence { + ecosystem, + name: name.into(), + version: version.map(str::to_owned), + source: path.display().to_string(), + }); + } + + fn unknown(&mut self, ecosystem: &'static str, path: &Path, reason: &str) { + self.entries.push(Evidence { + ecosystem, + name: String::new(), + version: None, + source: format!("{} ({reason})", path.display()), + }); + } + + async fn read(&mut self, path: &Path, ecosystem: &'static str) -> Option { + match read_regular_to_string(path).await { + Ok(text) => Some(strip_bom(&text).to_owned()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => { + self.unknown( + ecosystem, + path, + &format!("cannot inspect patch metadata: {e}"), + ); + None + } + } + } + + async fn json(&mut self, path: &Path, ecosystem: &'static str) -> Option { + let text = self.read(path, ecosystem).await?; + self.parse_json(&text, path, ecosystem) + } + + fn parse_json(&mut self, text: &str, path: &Path, ecosystem: &'static str) -> Option { + match serde_json::from_str(&crate::vendor::bun_lock_text::strip_jsonc(text)) { + Ok(value) => Some(value), + Err(e) => { + self.unknown( + ecosystem, + path, + &format!("cannot inspect patch metadata: {e}"), + ); + None + } + } + } + + fn native(&mut self, value: &Value, path: &Path) { + for field in [ + &value["patchedDependencies"], + &value["pnpm"]["patchedDependencies"], + ] { + if let Some(map) = field.as_object() { + for key in map.keys() { + let (name, version) = split_selector(key); + self.add("npm", name, version, path); + } + } else if !field.is_null() { + self.unknown("npm", path, "invalid patchedDependencies"); + } + } + // Older pnpm locks can carry patch hashes without a mirrored map. + for field in ["packages", "snapshots"] { + if let Some(map) = value[field].as_object() { + for (key, entry) in map { + if key.contains("patch_hash=") || !entry["patched"].is_null() { + if let Some((name, version)) = crate::formats::pnpm::pnpm_registry_key(key) + { + self.add("npm", name, Some(version), path); + } + } + } + } + } + } + + fn yarn(&mut self, spec: &str, path: &Path) { + if !spec.starts_with("patch:") { + return; + } + let mut source = spec.to_owned(); + let mut user_patch = false; + // Inspect every layer: a compatibility patch can wrap a user patch, + // or vice versa. Both still belong to the original package. + for _ in 0..16 { + if let Some(inner) = source + .strip_prefix("patch:") + .or_else(|| source.split_once("@patch:").map(|(_, rest)| rest)) + { + let Some((original, patches)) = inner.split_once('#') else { + self.unknown("npm", path, "unrecognized patch: reference"); + return; + }; + // Only Yarn's compatibility namespace is exempt. Plugins can + // supply their own builtin patches through getBuiltinPatch. + let patches = percent_decode_purl_component(patches); + user_patch |= !patches + .split("::") + .next() + .unwrap_or(&patches) + .split('&') + .all(|p| { + let p = p.rsplit('!').next().unwrap_or(p).trim_start_matches('~'); + p.starts_with("builtin') + }); + source = percent_decode_purl_component(original).into_owned(); + continue; + } + if user_patch { + let (name, version) = locator_target(&source); + self.add("npm", name, version, path); + } + return; + } + self.unknown( + "npm", + path, + "patch locator nesting exceeds inspection limit", + ); + } + + async fn patch_package(&mut self, dir: &Path, value: &Value) { + self.patch_directory(&dir.join("patches"), false).await; + let flag = regex::Regex::new(r#"--patch-dir(?:=|\s+)(?:"([^"]+)"|'([^']+)'|([^\s;&|]+))"#) + .unwrap(); + // `cd` as a command, not the tail of a word like `abcd`. + let cd = regex::Regex::new(r"(?:^|[\s;&|(])cd\s").unwrap(); + if let Some(scripts) = value["scripts"].as_object() { + for script in scripts + .values() + .filter_map(Value::as_str) + .filter(|s| s.contains("patch-package")) + { + // A `cd` after patch-package (`patch-package && cd ios && pod + // install`) can't move the directory it already read. + let moved = cd + .find(script) + .is_some_and(|cd| cd.start() < script.rfind("patch-package").unwrap_or(0)); + if moved || (script.contains("--patch-dir") && !flag.is_match(script)) { + self.unknown( + "npm", + &dir.join("package.json"), + "cannot resolve patch-package directory without executing the script", + ); + } + for captures in flag.captures_iter(script) { + let path = captures.iter().skip(1).flatten().next().unwrap().as_str(); + if path.contains(['$', '`']) || moved { + self.unknown( + "npm", + &dir.join("package.json"), + "dynamic patch-package directory; inspect it before opting in", + ); + } else { + self.patch_directory(&dir.join(path), true).await; + } + } + } + } + } + + async fn patch_directory(&mut self, dir: &Path, required: bool) { + let entries = match std::fs::read_dir(dir) { + Ok(entries) => entries, + Err(e) if !required && e.kind() == std::io::ErrorKind::NotFound => return, + Err(e) => { + self.unknown( + "npm", + dir, + &format!("cannot inspect patch-package directory: {e}"), + ); + return; + } + }; + for entry in entries { + let entry = match entry { + Ok(e) => e, + Err(e) => { + self.unknown("npm", dir, &e.to_string()); + continue; + } + }; + let file = entry.file_name().to_string_lossy().into_owned(); + let Some(stem) = file.strip_suffix(".patch") else { + continue; + }; + let leaf = stem.rsplit("++").next().unwrap_or(stem); + let parts: Vec<_> = leaf.split('+').collect(); + let scoped = leaf.starts_with('@'); + let version_idx = if scoped { 2 } else { 1 }; + if let Some(version) = parts.get(version_idx) { + let version = version.trim_end_matches(".dev"); + let name = if scoped { + format!("{}/{}", parts[0], parts[1]) + } else { + parts[0].to_string() + }; + self.add("npm", &name, Some(version), &entry.path()); + } else if required { + self.unknown("npm", &entry.path(), "unrecognized patch-package filename"); + } + } + } + + async fn composer(&mut self, dir: &Path) { + let manifest = dir.join("composer.json"); + let Some(value) = self.json(&manifest, "composer").await else { + return; + }; + self.composer_map(&value["extra"]["patches"], &manifest); + let file = value["extra"]["composer-patches"]["patches-file"] + .as_str() + .or_else(|| value["extra"]["patches-file"].as_str()) + .unwrap_or("patches.json"); + if file != "patches.json" && !dir.join(file).exists() { + self.unknown( + "composer", + &dir.join(file), + "configured patch definitions are missing", + ); + } + for path in [dir.join(file), dir.join("patches.lock.json")] { + if let Some(patches) = self.json(&path, "composer").await { + self.composer_map(&patches["patches"], &path); + } + } + let lock = dir.join("composer.lock"); + if let Some(value) = self.json(&lock, "composer").await { + for field in ["packages", "packages-dev"] { + if let Some(packages) = value[field].as_array() { + for package in packages { + self.composer_map(&package["extra"]["patches"], &lock); + } + } + } + } + } + + fn composer_map(&mut self, value: &Value, path: &Path) { + if let Some(map) = value.as_object() { + for name in map.keys() { + self.add("composer", name, None, path); + } + } else if let Some(patches) = value.as_array() { + for patch in patches { + if let Some(name) = patch["package"].as_str() { + self.add("composer", name, None, path); + } else { + self.unknown("composer", path, "unidentified Composer patch target"); + } + } + } else if !value.is_null() { + self.unknown("composer", path, "unrecognized Composer patch definitions"); + } + } + + async fn cargo(&mut self, dir: &Path) { + for file in ["Cargo.toml", ".cargo/config.toml", ".cargo/config"] { + let path = dir.join(file); + let Some(text) = self.read(&path, "cargo").await else { + continue; + }; + if !text.contains("patch") && !text.contains("replace") && !text.contains("paths") { + continue; + } + let doc = match text.parse::() { + Ok(doc) => doc, + Err(e) => { + self.unknown("cargo", &path, &e.to_string()); + continue; + } + }; + if let Some(tables) = doc.get("patch").and_then(toml_edit::Item::as_table_like) { + for (_, table) in tables.iter() { + if let Some(table) = table.as_table_like() { + for (key, entry) in table.iter() { + // Socket's managed paths/registries must remain eligible for updates. + let target = entry.get("path").and_then(toml_edit::Item::as_str); + if target.is_some_and(crate::vendor::cargo_config::path_is_socket_owned) + { + continue; + } + let name = entry + .get("package") + .and_then(toml_edit::Item::as_str) + .unwrap_or(key); + self.add("cargo", name, None, &path); + } + } + } + } + if let Some(table) = doc.get("replace").and_then(toml_edit::Item::as_table_like) { + for (key, _) in table.iter() { + let (name, version) = key + .split_once(':') + .map_or((key, None), |(n, v)| (n, Some(v))); + self.add("cargo", name, version, &path); + } + } + if doc + .get("paths") + .and_then(toml_edit::Item::as_array) + .is_some_and(|a| !a.is_empty()) + { + self.unknown( + "cargo", + &path, + "Cargo paths overrides may replace registry packages", + ); + } + } + } +} + +/// The package a Yarn locator installs. An alias (`alias@npm:real@1.0.0`) +/// installs `real`, which is the name the crawler reports. +fn locator_target(locator: &str) -> (&str, Option<&str>) { + let at = locator + .char_indices() + .skip(1) + .find(|&(_, c)| c == '@') + .map(|(i, _)| i); + let Some(at) = at else { + return (locator, None); + }; + let (name, range) = (&locator[..at], &locator[at + 1..]); + match range.strip_prefix("npm:") { + Some(target) if target.get(1..).is_some_and(|t| t.contains('@')) => split_selector(target), + Some(version) => (name, Some(version)), + None => (name, Some(range)), + } +} + +fn lock_options() -> serde_saphyr::Options { + // Lockfiles of large monorepos exceed the default 250k-node budget, and a + // parse failure would protect every npm package. + let mut budget = serde_saphyr::Budget::default(); + budget.max_events = 64_000_000; + budget.max_nodes = 32_000_000; + budget.max_total_scalar_bytes = 1 << 30; + let mut options = serde_saphyr::Options::default(); + options.budget = Some(budget); + options +} + +fn split_selector(key: &str) -> (&str, Option<&str>) { + match key.rsplit_once('@') { + Some((name, version)) if !name.is_empty() => (name, Some(version)), + _ => (key, None), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write(root: &Path, path: &str, text: &str) { + let path = root.join(path); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, text).unwrap(); + } + + #[tokio::test] + async fn native_declarations_match_scopes_ranges_and_exact_versions_without_patch_files() { + for (file, text) in [ + ("package.json", r#"{"pnpm":{"patchedDependencies":{"@s/pkg@^1.0.0":"missing.patch","all":"all.patch","exact@2.0.0":"exact.patch"}}}"#), + ("pnpm-workspace.yaml", "patchedDependencies:\n '@s/pkg@^1.0.0': missing.patch\n all: all.patch\n exact@2.0.0: exact.patch\n"), + ("pnpm-lock.yaml", "lockfileVersion: '9.0'\npatchedDependencies:\n '@s/pkg@^1.0.0': {hash: abc, path: missing.patch}\n all: all.patch\n exact@2.0.0: exact.patch\n"), + ("bun.lock", r#"{ // root mirror + "patchedDependencies": {"@s/pkg@^1.0.0":"missing.patch","all":"all.patch","exact@2.0.0":"exact.patch"}, + }"#), + ] { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), file, text); + let index = UserPatchIndex::discover(tmp.path()).await; + for purl in ["pkg:npm/%40s/pkg@1.2.0", "pkg:npm/all@4.0.0", "pkg:npm/exact@2.0.0"] { + assert!(!index.conflicts(purl).is_empty(), "{file}: {purl}"); + } + for purl in ["pkg:npm/@s/pkg@2.0.0", "pkg:npm/exact@2.0.1", "pkg:npm/other@1.0.0", "pkg:pypi/all@4.0.0"] { + assert!(index.conflicts(purl).is_empty(), "{file}: {purl}: {index:?}"); + } + } + } + + #[tokio::test] + async fn pnpm_lock_hashes_cover_legacy_and_multidocument_locks() { + for text in [ + "lockfileVersion: '6.0'\npackages:\n /@s/pkg@1.2.0(patch_hash=abc): {}\n", + "lockfileVersion: 5.4\npackages:\n /@s/pkg/1.2.0_patch_hash=abc:\n patched: true\n", + "lockfileVersion: '9.0'\n---\nlockfileVersion: '9.0'\nsnapshots:\n '@s/pkg@1.2.0(patch_hash=abc)': {}\n", + ] { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "pnpm-lock.yaml", text); + let index = UserPatchIndex::discover(tmp.path()).await; + assert!(!index.conflicts("pkg:npm/%40s/pkg@1.2.0").is_empty(), "{index:?}"); + assert!(index.conflicts("pkg:npm/@s/pkg@2.0.0").is_empty()); + } + } + + #[tokio::test] + async fn pnpm_all_versions_selector_protects_prereleases() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "pnpm-workspace.yaml", + "patchedDependencies:\n 'all@*': missing.patch\n 'range@^1.0.0': missing.patch\n", + ); + let index = UserPatchIndex::discover(tmp.path()).await; + assert!(!index.conflicts("pkg:npm/all@2.0.0-beta.1").is_empty()); + assert!(index.conflicts("pkg:npm/range@2.0.0-beta.1").is_empty()); + } + + #[tokio::test] + async fn npm_lock_marker_is_sufficient_without_manifest_declaration() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "package-lock.json", + r#"{"packages":{"node_modules/parent/node_modules/@s/pkg":{"version":"1.2.0","patched":{"path":"patches/fix.patch"}}}}"#, + ); + let index = UserPatchIndex::discover(tmp.path()).await; + assert!(!index.conflicts("pkg:npm/@s/pkg@1.2.0").is_empty()); + assert!(index.conflicts("pkg:npm/parent@1.2.0").is_empty()); + } + + #[tokio::test] + async fn yarn_protects_custom_patches_but_not_builtin_only_compatibility_patches() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "package.json", + r#"{"resolutions":{"alias":"patch:@s/pkg@npm%3A1.2.0#./fix.patch","typescript":"patch:typescript@npm%3A5.0.0#optional!builtin","plugin":"patch:plugin@npm%3A1.0.0#builtin","nested":"patch:nested@patch%3Anested@npm%253A1.0.0%23optional!builtin%3Ccompat/nested%3E#./outer.patch","wrapped":"patch:wrapped@patch%3Awrapped@npm%253A1.0.0%23./inner.patch#optional!builtin"}}"#, + ); + write(tmp.path(), "yarn.lock", "__metadata:\n version: 8\nentry:\n resolution: \"transitive@patch:transitive@npm%3A2.0.0#./fix.patch::version=2.0.0&hash=abc\"\n"); + let index = UserPatchIndex::discover(tmp.path()).await; + assert!(!index.conflicts("pkg:npm/@s/pkg@1.2.0").is_empty()); + assert!(!index.conflicts("pkg:npm/transitive@2.0.0").is_empty()); + assert!(index.conflicts("pkg:npm/typescript@5.0.0").is_empty()); + assert!(!index.conflicts("pkg:npm/plugin@1.0.0").is_empty()); + assert!(!index.conflicts("pkg:npm/nested@1.0.0").is_empty()); + assert!(!index.conflicts("pkg:npm/wrapped@1.0.0").is_empty()); + assert!(index.conflicts("pkg:npm/alias@1.2.0").is_empty()); + assert!(index.conflicts("pkg:npm/@s/pkg@1.3.0").is_empty()); + } + + #[tokio::test] + async fn patch_package_supports_scoped_nested_sequenced_dev_and_custom_directories() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "package.json", + r#"{"scripts":{"postinstall":"patch-package --patch-dir='custom fixes'"}}"#, + ); + write( + tmp.path(), + "custom fixes/parent++@s+pkg+1.2.0+001+fix.dev.patch", + "user diff", + ); + write(tmp.path(), "patches/plain+3.0.0.dev.patch", "user diff"); + let index = UserPatchIndex::discover(tmp.path()).await; + assert!(!index.conflicts("pkg:npm/@s/pkg@1.2.0").is_empty()); + assert!(!index.conflicts("pkg:npm/plain@3.0.0").is_empty()); + assert!(index.conflicts("pkg:npm/@s/pkg@1.3.0").is_empty()); + assert!(index.conflicts("pkg:npm/parent@1.2.0").is_empty()); + } + + #[tokio::test] + async fn workspace_members_and_parent_settings_are_both_consulted() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "pnpm-workspace.yaml", + "packages: ['packages/*']\npatchedDependencies:\n root@1.0.0: missing.patch\n", + ); + write(tmp.path(), "packages/a/package.json", "{}"); + write( + tmp.path(), + "packages/a/patches/child+2.0.0.patch", + "user diff", + ); + for root in [tmp.path().to_path_buf(), tmp.path().join("packages/a")] { + let index = UserPatchIndex::discover(&root).await; + assert!(!index.conflicts("pkg:npm/root@1.0.0").is_empty()); + assert!(!index.conflicts("pkg:npm/child@2.0.0").is_empty()); + assert!(index.conflicts("pkg:npm/other@1.0.0").is_empty()); + } + } + + #[tokio::test] + async fn workspace_globs_do_not_block_unrelated_packages() { + for pattern in [ + "packages/{a,b}", + "packages/[ab]", + "packages/@(a|b)", + "packages/**", + ] { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "package.json", + &serde_json::json!({"workspaces":[pattern]}).to_string(), + ); + write(tmp.path(), "packages/a/package.json", "{}"); + write( + tmp.path(), + "packages/a/patches/child+2.0.0.patch", + "user diff", + ); + let index = UserPatchIndex::discover(tmp.path()).await; + assert!( + !index.conflicts("pkg:npm/child@2.0.0").is_empty(), + "{pattern}: {index:?}" + ); + assert!( + index.conflicts("pkg:npm/other@1.0.0").is_empty(), + "{pattern}: {index:?}" + ); + } + } + + #[cfg(unix)] + #[tokio::test] + async fn linked_workspace_is_not_silently_ignored() { + let tmp = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "package.json", + r#"{"workspaces":["packages/*"]}"#, + ); + std::os::unix::fs::symlink(outside.path(), tmp.path().join("packages")).unwrap(); + let index = UserPatchIndex::discover(tmp.path()).await; + assert!(!index.conflicts("pkg:npm/child@1.0.0").is_empty()); + } + + #[tokio::test] + async fn composer_covers_external_locked_and_dependency_provided_patches() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "composer.json", + r#"{"extra":{"composer-patches":{"patches-file":"custom.json"},"patches":{"a/root":{"fix":"x.patch"}}}}"#, + ); + write( + tmp.path(), + "custom.json", + r#"{"patches":{"a/external":[{"url":"x.patch"}]}}"#, + ); + write( + tmp.path(), + "patches.lock.json", + r#"{"patches":[{"package":"a/locked","url":"x.patch"}]}"#, + ); + write( + tmp.path(), + "composer.lock", + r#"{"packages":[{"extra":{"patches":{"a/transitive":{"fix":"x.patch"}}}}]}"#, + ); + let index = UserPatchIndex::discover(tmp.path()).await; + for name in ["root", "external", "locked", "transitive"] { + assert!(!index + .conflicts(&format!("pkg:composer/a/{name}@1.0.0")) + .is_empty()); + } + assert!(index.conflicts("pkg:composer/a/other@1.0.0").is_empty()); + } + + #[tokio::test] + async fn cargo_and_go_preserve_user_overrides_and_recognize_socket_ownership() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "Cargo.toml", "[patch.crates-io]\nrenamed = { package = 'real', path = '../fork' }\nowned = { path = './.socket/vendor/cargo/uuid/owned' }\n[replace]\n'old:1.0.0' = { path = '../old' }\n"); + write(tmp.path(), "go.work", "go 1.23\nreplace (\n example.com/user v1.0.0 => ../fork\n example.com/owned v1.0.0 => ./.socket/go-patches/owned\n)\n"); + let index = UserPatchIndex::discover(tmp.path()).await; + for purl in [ + "pkg:cargo/real@1.0.0", + "pkg:cargo/Real@1.0.0", + "pkg:cargo/old@1.0.0", + "pkg:golang/example.com/user@v1.0.0", + ] { + assert!(!index.conflicts(purl).is_empty(), "{purl}: {index:?}"); + } + for purl in [ + "pkg:cargo/renamed@1.0.0", + "pkg:cargo/owned@1.0.0", + "pkg:cargo/old@2.0.0", + "pkg:golang/example.com/owned@v1.0.0", + ] { + assert!(index.conflicts(purl).is_empty(), "{purl}: {index:?}"); + } + } + + #[tokio::test] + async fn cd_after_patch_package_keeps_the_default_directory() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "package.json", + r#"{"scripts":{"postinstall":"patch-package && cd ios && pod install"}}"#, + ); + write(tmp.path(), "patches/plain+1.0.0.patch", "user diff"); + let index = UserPatchIndex::discover(tmp.path()).await; + assert!(!index.conflicts("pkg:npm/plain@1.0.0").is_empty()); + assert!( + index.conflicts("pkg:npm/other@1.0.0").is_empty(), + "{index:?}" + ); + + write( + tmp.path(), + "package.json", + r#"{"scripts":{"postinstall":"cd app && patch-package"}}"#, + ); + let index = UserPatchIndex::discover(tmp.path()).await; + assert!(!index.conflicts("pkg:npm/other@1.0.0").is_empty()); + } + + #[tokio::test] + async fn a_word_ending_in_cd_is_not_a_directory_change() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "package.json", + r#"{"scripts":{"postinstall":"tsc -p ./src/abcd && patch-package"}}"#, + ); + let index = UserPatchIndex::discover(tmp.path()).await; + assert!( + index.conflicts("pkg:npm/other@1.0.0").is_empty(), + "{index:?}" + ); + } + + #[tokio::test] + async fn yarn_alias_patch_protects_the_real_package() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "package.json", + r#"{"dependencies":{"alias":"patch:alias@npm%3Areal%401.0.0#./fix.patch"}}"#, + ); + let index = UserPatchIndex::discover(tmp.path()).await; + assert!( + !index.conflicts("pkg:npm/real@1.0.0").is_empty(), + "{index:?}" + ); + assert!( + index.conflicts("pkg:npm/real@2.0.0").is_empty(), + "{index:?}" + ); + } + + #[test] + fn locator_target_reads_plain_scoped_and_aliased_locators() { + assert_eq!(locator_target("real@npm:1.0.0"), ("real", Some("1.0.0"))); + assert_eq!(locator_target("@s/p@npm:1.0.0"), ("@s/p", Some("1.0.0"))); + assert_eq!(locator_target("a@npm:real@1.0.0"), ("real", Some("1.0.0"))); + assert_eq!(locator_target("a@npm:@s/p@1.0.0"), ("@s/p", Some("1.0.0"))); + assert_eq!(locator_target("a@npm:"), ("a", Some(""))); + assert_eq!(locator_target("bare"), ("bare", None)); + } + + #[tokio::test] + async fn cargo_path_merely_under_socket_vendor_is_still_a_user_override() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "Cargo.toml", + "[patch.crates-io]\nserde = { path = '.socket/vendor/../../forks/serde' }\n", + ); + let index = UserPatchIndex::discover(tmp.path()).await; + assert!(!index.conflicts("pkg:cargo/serde@1.0.0").is_empty()); + } + + #[tokio::test] + async fn double_star_workspace_ignores_non_member_directories() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "pnpm-workspace.yaml", + "packages: ['packages/**']\n", + ); + write(tmp.path(), "packages/a/package.json", "{}"); + write( + tmp.path(), + "packages/a/src/fixtures/bad/package.json", + "{broken", + ); + write(tmp.path(), "packages/a/dist/x.js", ""); + let index = UserPatchIndex::discover(tmp.path()).await; + // pnpm itself reads that broken manifest as a project, so it still + // protects npm; directories without one are not inspected at all. + assert!(!index.conflicts("pkg:npm/x@1.0.0").is_empty()); + std::fs::remove_file(tmp.path().join("packages/a/src/fixtures/bad/package.json")).unwrap(); + let index = UserPatchIndex::discover(tmp.path()).await; + assert!(index.conflicts("pkg:npm/x@1.0.0").is_empty(), "{index:?}"); + } + + #[tokio::test] + async fn dot_dot_workspace_globs_resolve_lexically() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "app/package.json", + r#"{"workspaces":["packages/b/../a", "../shared/*"]}"#, + ); + write(tmp.path(), "app/packages/a/package.json", "{}"); + write( + tmp.path(), + "app/packages/a/patches/one+1.0.0.patch", + "user diff", + ); + write(tmp.path(), "shared/s/package.json", "{}"); + write(tmp.path(), "shared/s/patches/two+2.0.0.patch", "user diff"); + let index = UserPatchIndex::discover(&tmp.path().join("app")).await; + assert!( + !index.conflicts("pkg:npm/one@1.0.0").is_empty(), + "{index:?}" + ); + assert!( + !index.conflicts("pkg:npm/two@2.0.0").is_empty(), + "{index:?}" + ); + assert!( + index.conflicts("pkg:npm/other@1.0.0").is_empty(), + "{index:?}" + ); + } + + #[tokio::test] + async fn go_work_members_replaces_are_consulted() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "go.work", "go 1.23\nuse (\n ./app\n)\n"); + write( + tmp.path(), + "app/go.mod", + "module example.com/app\nreplace example.com/user => ../fork\n", + ); + for root in [tmp.path().to_path_buf(), tmp.path().join("app")] { + let index = UserPatchIndex::discover(&root).await; + assert!(!index + .conflicts("pkg:golang/example.com/user@v1.0.0") + .is_empty()); + assert!(index + .conflicts("pkg:golang/example.com/other@v1.0.0") + .is_empty()); + } + } + + #[tokio::test] + async fn unknown_metadata_does_not_silently_allow_patching() { + for text in [ + r#"{"patchedDependencies": "invalid"}"#, + r#"{"scripts":{"postinstall":"patch-package --patch-dir $PATCH_DIR"}}"#, + r#"{"scripts":{"postinstall":"patch-package --patch-dir missing"}}"#, + "{broken json", + ] { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "package.json", text); + let index = UserPatchIndex::discover(tmp.path()).await; + assert!( + !index.conflicts("pkg:npm/anything@1.0.0").is_empty(), + "{text}" + ); + assert!(index.conflicts("pkg:pypi/anything@1.0.0").is_empty()); + } + } +} diff --git a/docs/ecosystems.md b/docs/ecosystems.md index deda930d5..236a4833a 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -1459,3 +1459,40 @@ Prebuilt binaries are published for: | Linux | x86_64, ARM64, 32-bit ARM hard-float (`arm-unknown-linux-gnueabihf` / `-musleabihf`), i686 | | Windows | x86_64, ARM64, i686 | | Android | ARM64 | + +## Existing project patches + +Socket Patch treats declared project patches as a separate source of ownership. +`scan` and `apply` skip their packages and report `user_patch_conflict`; +explicit `get` and `vendor` refuse the conflict before patching. Use +`--allow-user-patches` only after reviewing how the fixes interact. This flag +permits an attempt; it does not merge diffs or bypass unsupported backend cases. +Installed file content that matches neither the original nor Socket-patched hash +also fails by default. See the [CLI contract](../crates/socket-patch-cli/CLI_CONTRACT.md#existing-project-patches) +for exact matching rules, diagnostics and detection limits. + +The protection applies before any comparison of changed filenames. Two fixes can +interact even when their diffs do not overlap, and a package manager may reapply +its own diff after Socket changes the dependency source. + +| Mechanism | Research and interaction | +| --- | --- | +| pnpm | [`pnpm patch`](https://pnpm.io/cli/patch) records `patchedDependencies`; selectors can be exact versions, ranges, or bare names. Configuration moved into [`pnpm-workspace.yaml`](https://pnpm.io/settings). pnpm's [selector grouping](https://github.com/pnpm/pnpm/blob/v10.20.0/patching/config/src/groupPatchedDependencies.ts) treats `*` as all versions, including prereleases. Both declarations and lock markers matter, including before installation. | +| Yarn | [`patch:`](https://yarnpkg.com/protocol/patch) changes package source at fetch time. [`yarn patch`](https://yarnpkg.com/features/patching) keeps the diff separately; replacing a locator is not a merge of that diff with Socket's artifact. Yarn builtin compatibility patches are distinguished from user patch paths. | +| `patch-package` | Its [documented workflow](https://github.com/ds300/patch-package) applies diffs after installation, supports workspace-local setups and `--patch-dir`. Its [filename parser](https://github.com/ds300/patch-package/blob/master/src/PackageDetails.ts) identifies scoped/nested targets, patch sequences and dev-only patches. Looking only for a dependency on `patch-package` is insufficient. | +| npm / Bun | [npm patch](https://docs.npmjs.com/cli/v12/commands/npm-patch/) and [Bun patch](https://bun.sh/docs/pm/cli/patch) persist project diffs. Socket's pre-existing native npm/Bun backend refusals remain in force after consent; a successful source rewrite alone does not establish that the user's diff was preserved. | +| Composer | [Composer Patches](https://docs.cweagans.net/composer-patches/usage/defining-patches/) accepts inline and external definitions. [Configuration](https://docs.cweagans.net/composer-patches/usage/configuration/) can relocate that file, and dependencies can provide patches. [`patches.lock.json`](https://docs.cweagans.net/composer-patches/api/patches-lock-json/) is additional read-only evidence. | +| Cargo / Go | Cargo's [`[patch]`, `[replace]`, and `paths`](https://doc.rust-lang.org/cargo/reference/overriding-dependencies.html) and Go's [`replace`](https://go.dev/ref/mod#go-mod-file-replace) can select a user fork or directory. These source choices must not be treated as pristine registry copies. Socket-owned wiring is distinguished from user replacements. | + +Python local/VCS sources, Ruby path/git dependencies, JVM dependency substitutions, +NuGet source choices, and custom build hooks do not share a portable diff registry. +Their existing source/layout restrictions still apply; the consent detector does +not claim to interpret every possible plugin, script or external patch pipeline. +Unknown edits in a file Socket would replace are protected by the default hash +mismatch refusal. A separate, untouched file with an unrecorded local fix cannot +be detected by that hash check. + +Patch declarations and diffs remain the user's files. Consent is not persisted in +`socket.yml`, the manifest, or an environment variable; an automated hook must +specify it deliberately on each invocation if the project intends to combine +patches. Review the resulting installation and run the project's own tests. diff --git a/docs/usage.md b/docs/usage.md index 5f9841df8..54a5dcd39 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -238,3 +238,27 @@ A package converted from hosted to vendored returns to upstream on `vendor --rev Vendored reversal preserves unrelated edits and refuses unsafe drift. Keep its ledger with the artifacts. Full details and per-ecosystem restoration limits are in the [rollback contract](../crates/socket-patch-cli/CLI_CONTRACT.md#rollback-command-contract-v50). + +## Packages you already patch + +By default, `scan` skips packages with declared project patches (pnpm, Yarn, +`patch-package`, native npm/Bun, Composer, Cargo or Go) and reports where the +patch was found, and `apply` leaves them out while applying everything else. +Explicit `get` and `vendor` refuse those conflicts. +A user patch is protected even if it changes a different file than Socket's fix. + +After reviewing the interaction, you can explicitly approve an attempt: + +```sh +socket-patch scan --package minimist --mode agent --allow-user-patches +socket-patch apply --allow-user-patches --strict +``` + +The second form still refuses modified file content. Without `--strict`, explicit +consent permits replacing a mismatched file with Socket's verified content; it +does not merge your changes. Without consent, mismatched files fail by default, +unless they hold an earlier Socket patch's content (a patch update). +`--yes` and `--force` do not approve combining declared project patches. Some +package-manager combinations remain unsupported even with consent. See +[existing project patches](ecosystems.md#existing-project-patches) for the +mechanisms checked and the limitations.