From 0c7b59ec9007c92bb7fdf493e76e83c94986f5cf Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 21 Jul 2026 12:28:00 -0400 Subject: [PATCH 1/5] feat!: remove unlock + --break-lock, fold lock cleanup into repair, reorder --help v4 breaking CLI cleanup, part 1: - Remove the `unlock` subcommand. A leftover apply.lock from a crashed run never blocks acquisition (the kernel releases a dead holder's advisory lock), so unlock's inspect path had no recovery scenario and its --release file deletion is now automatic in repair. The patch_unlocked/patch_unlock_failed telemetry events, Command::Unlock envelope variant, and SOCKET_UNLOCK_RELEASE are retired with it. - Remove the global --break-lock flag and SOCKET_BREAK_LOCK. It never stole a live holder's lock (deliberately), and stale files never contend, so all it did was emit a lock_broken audit event for a reclaim plain acquisition performs anyway. acquire_or_emit now returns the LockGuard directly; the lock_held hint advises waiting / --lock-timeout. rollback's warnings[] stays present, now always empty. The never-unlink-while-live invariant survives, pinned by acquire_or_emit_preserves_mutual_exclusion. - repair deletes the leftover apply.lock as its final housekeeping step on every completion path (after releasing its own guard; skipped on --dry-run; live holders still refused with lock_held exit 1). - Reorder --help to workflow-first: scan, apply, vex, vendor, setup, rollback, get, list, remove, repair. CHANGELOG carries migration notes under [Unreleased]; the 4.0.0 version-sync is deferred to batch with the remaining breaking changes. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 28 + README.md | 318 ++++---- crates/socket-patch-cli/CLI_CONTRACT.md | 26 +- crates/socket-patch-cli/src/args.rs | 20 - crates/socket-patch-cli/src/commands/apply.rs | 13 +- .../socket-patch-cli/src/commands/lock_cli.rs | 393 ++-------- crates/socket-patch-cli/src/commands/mod.rs | 1 - .../socket-patch-cli/src/commands/remove.rs | 20 +- .../socket-patch-cli/src/commands/repair.rs | 55 +- .../socket-patch-cli/src/commands/rollback.rs | 32 +- .../socket-patch-cli/src/commands/unlock.rs | 460 ----------- .../socket-patch-cli/src/commands/vendor.rs | 13 +- crates/socket-patch-cli/src/json_envelope.rs | 18 +- crates/socket-patch-cli/src/lib.rs | 39 +- crates/socket-patch-cli/src/main.rs | 9 +- .../socket-patch-cli/tests/cli_global_args.rs | 21 +- .../socket-patch-cli/tests/cli_parse_apply.rs | 18 +- .../socket-patch-cli/tests/cli_parse_get.rs | 6 +- .../socket-patch-cli/tests/cli_parse_main.rs | 15 +- .../tests/cli_parse_repair.rs | 6 +- .../tests/cli_parse_rollback.rs | 15 +- .../socket-patch-cli/tests/cli_parse_scan.rs | 1 - .../tests/cli_parse_unlock.rs | 167 ---- .../tests/cli_parse_vendor.rs | 13 - .../socket-patch-cli/tests/cli_parse_vex.rs | 6 +- .../tests/cli_remove_silent.rs | 47 +- crates/socket-patch-cli/tests/cli_sigpipe.rs | 10 +- crates/socket-patch-cli/tests/common/mod.rs | 2 +- .../socket-patch-cli/tests/e2e_safety_lock.rs | 126 +-- .../tests/e2e_safety_unlock.rs | 730 ------------------ .../tests/get_batch_paths_e2e.rs | 6 +- .../tests/in_process_get_manifest_path.rs | 2 +- .../tests/remove_rollback_api_overrides.rs | 1 - .../tests/repair_invariants.rs | 122 +++ .../socket-patch-core/src/patch/apply_lock.rs | 20 +- .../socket-patch-core/src/utils/telemetry.rs | 51 +- 36 files changed, 546 insertions(+), 2284 deletions(-) delete mode 100644 crates/socket-patch-cli/src/commands/unlock.rs delete mode 100644 crates/socket-patch-cli/tests/cli_parse_unlock.rs delete mode 100644 crates/socket-patch-cli/tests/e2e_safety_unlock.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index a2ec3e85..b0d24918 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,34 @@ in this file — see `.github/workflows/release.yml` (`version` job). ## [Unreleased] +### Removed (BREAKING — lands in v4.0) + +- **The `unlock` subcommand.** Folded into `repair`, which now deletes the + leftover `<.socket>/apply.lock` file as its final housekeeping step (skipped + under `--dry-run`, refused with `lock_held` while another live socket-patch + process holds the lock). Rationale: a leftover lock file from a crashed run + never blocked acquisition in the first place — the OS releases a dead + holder's advisory lock along with its file handle — so `unlock`'s inspect + path had no recovery scenario, and its `--release` file deletion is now + automatic. Migration: `unlock --release` → `repair`; the probe-style + "is anything holding the lock?" check → run the mutating command (optionally + with `--lock-timeout`) and branch on `errorCode: lock_held`. + `SOCKET_UNLOCK_RELEASE` is gone with the subcommand, and the + `patch_unlocked` / `patch_unlock_failed` telemetry events are retired. +- **The global `--break-lock` flag and `SOCKET_BREAK_LOCK` env var.** It never + stole a live holder's lock (deliberately, since that defeats mutual + exclusion) and a stale file never contends, so all it did was emit a + `lock_broken` audit event for a reclaim that plain acquisition performs + anyway. The `lock_broken` warning event and rollback's `warnings[]` + `lock_broken` entry are no longer emitted (`warnings` stays present, now + always empty). The `lock_held` stderr hint now advises waiting / + `--lock-timeout` instead of pointing at the removed commands. + +### Changed (BREAKING — lands in v4.0) + +- **`--help` command order** is now workflow-first: `scan`, `apply`, `vex`, + `vendor`, `setup`, then `rollback`, `get`, `list`, `remove`, `repair`. + ### Added - **Hosted patch mode: `scan --mode hosted` (a.k.a. the hidden `--redirect`).** diff --git a/README.md b/README.md index 5682c15d..fd7e55c3 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,6 @@ Each flag has a matching `SOCKET_*` environment variable. **Precedence is CLI ar | `--dry-run` | `SOCKET_DRY_RUN` | Preview the operation without making any mutations. | | `-y, --yes` | `SOCKET_YES` | Skip interactive confirmation prompts. | | `--lock-timeout ` | `SOCKET_LOCK_TIMEOUT` | Seconds to wait for `.socket/apply.lock` before giving up. `0`/unset = a single non-blocking try; a positive value retries with backoff. Only meaningful for mutating commands (`apply`, `rollback`, `repair`, `remove`). | -| `--break-lock` | `SOCKET_BREAK_LOCK` | Reclaim a stale `.socket/apply.lock` (one left by a crashed run) before acquiring it — the file itself is never deleted, and a lock with a live holder is refused. Use only when no other socket-patch process is running; emits an auditable `lock_broken` event in the JSON envelope. | | `--debug` | `SOCKET_DEBUG` | Emit verbose debug logs to stderr. | | `--no-telemetry` | `SOCKET_TELEMETRY_DISABLED` | Disable anonymous usage telemetry. | @@ -174,54 +173,6 @@ Each flag has a matching `SOCKET_*` environment variable. **Precedence is CLI ar The tables below list only the **command-specific** flags. Every command also accepts the [Global Options](#global-options) above. -### `get` - -Get security patches from Socket API and apply them. Accepts a UUID, CVE ID, GHSA ID, PURL, or package name. The identifier type is auto-detected but can be forced with a flag. - -Alias: `download` - -**Usage:** -```bash -socket-patch get [options] -``` - -**Command-specific options** (plus all [Global Options](#global-options)): -| Flag | Description | -|------|-------------| -| `--id` | Force identifier to be treated as a UUID | -| `--cve` | Force identifier to be treated as a CVE ID | -| `--ghsa` | Force identifier to be treated as a GHSA ID | -| `-p, --package` | Force identifier to be treated as a package name | -| `--save-only` | Download patch without applying it (alias: `--no-apply`) | -| `--one-off` | Apply patch immediately without saving to the `.socket` folder | -| `--all-releases` | Download patches for every release/distribution variant of a matched package (PyPI wheel/sdist, RubyGems platform, Maven classifier), not just the installed one | - -> Authenticated lookups require an org: pass `--org ` (or set `SOCKET_ORG_SLUG`) when using `SOCKET_API_TOKEN`. - -**Examples:** -```bash -# Get patch by UUID -socket-patch get 550e8400-e29b-41d4-a716-446655440000 - -# Get patch by CVE -socket-patch get CVE-2024-12345 - -# Get patch by GHSA -socket-patch get GHSA-xxxx-yyyy-zzzz - -# Get patch by package name (fuzzy matches installed packages) -socket-patch get lodash - -# Download only, don't apply -socket-patch get CVE-2024-12345 --save-only - -# Apply to global packages -socket-patch get lodash -g - -# JSON output for scripting -socket-patch get CVE-2024-12345 --json -y -``` - ### `scan` Scan installed packages for available security patches. `scan --mode agent --prune` is the single command bots need for full auto-update: it discovers patches, applies them, and garbage-collects orphan blob files plus manifest entries for uninstalled packages — all in one invocation. @@ -334,6 +285,39 @@ socket-patch apply --vex socket.vex.json > committed vendored artifact is the patch, so there is nothing for `apply` to do — even when > the installed tree (e.g. `node_modules/`) is absent. +### `vex` + +Generate an [OpenVEX](https://github.com/openvex) 0.2.0 attestation describing the vulnerabilities that the applied patches have mitigated. See [OpenVEX attestations](#openvex-attestations) below for the full workflow. + +**Usage:** +```bash +socket-patch vex [options] +``` + +**Command-specific options** (plus all [Global Options](#global-options)): +| Flag | Description | +|------|-------------| +| `-O, --output ` | Write the VEX document to this path instead of stdout. Required when combined with `--json`. (env: `SOCKET_VEX_OUTPUT`) | +| `--product ` | Override the auto-detected top-level product PURL/identifier. (env: `SOCKET_VEX_PRODUCT`) | +| `--no-verify` | Skip the on-disk file-hash check and trust the manifest — useful on a build machine that doesn't have the patched files laid out. (env: `SOCKET_VEX_NO_VERIFY`) | +| `--doc-id ` | Override the document `@id`. Default is a random `urn:uuid:` regenerated each run; pin this for a reproducible identifier. (env: `SOCKET_VEX_DOC_ID`) | +| `--compact` | Emit compact JSON instead of pretty-printed. (env: `SOCKET_VEX_COMPACT`) | + +**Examples:** +```bash +# Print a VEX document to stdout (human-readable status goes to stderr) +socket-patch vex + +# Write the document to a file +socket-patch vex --output socket.vex.json + +# CI shape: VEX doc to file, machine-readable envelope to stdout +socket-patch vex --json --output socket.vex.json + +# Generate on a build box without verifying on-disk files +socket-patch vex --no-verify --output socket.vex.json +``` + ### `vendor` `apply`'s **committable** sibling — the standalone command behind @@ -400,6 +384,65 @@ socket-patch vendor --json > Prefer one command? [`scan --mode vendored`](#scan) discovers, downloads, *and* vendors in a single > pass. +### `setup` + +Configure your project so patches are **re-applied automatically after install** — no manual `socket-patch apply` step in CI. `setup` is a one-time operation: run it, commit the change together with your `.socket/` patches, and every later install handles the rest. It is strictly **opt-in** — nothing is hooked unless you run `setup` and commit the result. + +- **npm / yarn / pnpm / bun** — writes a `postinstall` script into `package.json` so any install re-applies patches (pnpm: root package only). +- **Python (pip / uv / poetry / pdm / hatch)** — Python has no universal post-install hook, so `setup` instead commits a **`socket-patch[hook]`** dependency (for classic Poetry, the equivalent `socket-patch = { extras = ["hook"] }`). Installing it lays down a startup `.pth` (shipped by the small `socket-patch-hook` wheel) that re-applies your committed `.socket/` patches the next time the interpreter runs. It is package-manager-agnostic (it rides the interpreter, not any one installer) and **fail-open** — a hook error can never break interpreter startup. +- **Ruby gems (Bundler)** — adds a managed `plugin "socket-patch"` block to the `Gemfile` and commits an in-tree Bundler plugin under `.socket/bundler-plugin/`. It re-applies patches on every `bundle install` (cached *and* fresh). (Requires the `socket-patch` CLI on `PATH`.) +- **Composer (PHP)** — appends `socket-patch apply` to `composer.json`'s `post-install-cmd` / `post-update-cmd` script events, so patches re-apply on every `composer install` / `composer update`. (Requires the `socket-patch` CLI on `PATH`.) +- **Cargo & Go** — *apply-only, no `setup` hook.* A one-click auto-repatch-on-build isn't possible for these, so `setup` skips them. Patch with `socket-patch apply` directly: **cargo** patches the crate in place (in `vendor/` or the registry cache, rewriting `.cargo-checksum.json` so `cargo build` accepts it); **go** writes a project-local patched copy under `.socket/go-patches/` plus a `go.mod` `replace` directive (the module cache is `go.sum`-verified, so in-place patching can't build). Commit `go.mod` + `.socket/go-patches/` so a clone builds the patched bytes. Declare them in `setup.manual` for VEX attestation. +- **Apply-only ecosystems** (nuget · maven · deno) — no native install hook to wire, so `setup` reports `no_files`; patch them on demand with `socket-patch apply`. + +**Usage:** +```bash +socket-patch setup # configure (interactive) +socket-patch setup --check # verify configured; non-zero exit if not (CI gate) +socket-patch setup --remove # revert what setup added +``` + +**Command-specific options** (plus all [Global Options](#global-options) — `--dry-run`, `--yes`, `--json`, `--cwd`): +| Flag | Description | +|------|-------------| +| `--check` | Read-only verification that every manifest is configured; exits non-zero if any still needs setup. Never writes (safe in CI). Conflicts with `--remove`. | +| `--remove` | Revert the install hooks `setup` added (npm `package.json` scripts, the Python `socket-patch[hook]` dependency, and the gem Bundler plugin wiring). | + +#### Disabling / opting out (Python hook) + +The Python hook is designed to be easy to skip or remove: + +- **Per interpreter / CI step:** set `SOCKET_PATCH_HOOK=off` (or `SOCKET_NO_HOOK=1`). This is checked *before any hook code runs*, so it fully bypasses the hook for that process. +- **Remove from a project:** `socket-patch setup --remove`, then `pip uninstall socket-patch-hook`. +- **Never opted in:** if you don't run `setup`, there is no hook — it is opt-in by design. + +#### What the Python hook does, and its safety model + +On interpreter startup, *only when the set of installed packages changed*, the hook runs `socket-patch apply --offline --ecosystems pypi` for the project that owns the current virtualenv, re-applying only the patches committed in that project's `.socket/`. Specifically: + +- It is **anchored to the virtualenv** it is installed in (not the working directory), so a `python` started from an unrelated directory cannot pull in a foreign `.socket/manifest.json`. +- It **verifies each file's hash before patching** and **never writes outside the installed package directory** (path-escaping manifest keys are refused). +- It resolves the `socket-patch` binary from the **installed `socket-patch` package** (not from `PATH`), so an unexpected binary on `PATH` is not executed. +- It runs **offline** (no network at startup) and is **fail-open** (any error is swallowed; it can never abort the interpreter). + +**Examples:** +```bash +# Interactive setup (all detected ecosystems, auto-detected) +socket-patch setup + +# Non-interactive +socket-patch setup -y + +# Preview changes +socket-patch setup --dry-run + +# Verify configuration in CI (exits non-zero if not set up) +socket-patch setup --check + +# JSON output for scripting +socket-patch setup --json -y +``` + ### `rollback` Rollback patches to restore original files. If no identifier is given, all patches are rolled back. Packages managed by [`vendor`](#vendor) are excluded — their patch lives in the committed artifact, not the installed tree — and are listed in the JSON output's `vendored` array (use `remove` or `vendor --revert` to undo them). @@ -432,6 +475,54 @@ socket-patch rollback --dry-run socket-patch rollback --json ``` +### `get` + +Get security patches from Socket API and apply them. Accepts a UUID, CVE ID, GHSA ID, PURL, or package name. The identifier type is auto-detected but can be forced with a flag. + +Alias: `download` + +**Usage:** +```bash +socket-patch get [options] +``` + +**Command-specific options** (plus all [Global Options](#global-options)): +| Flag | Description | +|------|-------------| +| `--id` | Force identifier to be treated as a UUID | +| `--cve` | Force identifier to be treated as a CVE ID | +| `--ghsa` | Force identifier to be treated as a GHSA ID | +| `-p, --package` | Force identifier to be treated as a package name | +| `--save-only` | Download patch without applying it (alias: `--no-apply`) | +| `--one-off` | Apply patch immediately without saving to the `.socket` folder | +| `--all-releases` | Download patches for every release/distribution variant of a matched package (PyPI wheel/sdist, RubyGems platform, Maven classifier), not just the installed one | + +> Authenticated lookups require an org: pass `--org ` (or set `SOCKET_ORG_SLUG`) when using `SOCKET_API_TOKEN`. + +**Examples:** +```bash +# Get patch by UUID +socket-patch get 550e8400-e29b-41d4-a716-446655440000 + +# Get patch by CVE +socket-patch get CVE-2024-12345 + +# Get patch by GHSA +socket-patch get GHSA-xxxx-yyyy-zzzz + +# Get patch by package name (fuzzy matches installed packages) +socket-patch get lodash + +# Download only, don't apply +socket-patch get CVE-2024-12345 --save-only + +# Apply to global packages +socket-patch get lodash -g + +# JSON output for scripting +socket-patch get CVE-2024-12345 --json -y +``` + ### `list` List all patches in the local manifest. @@ -502,12 +593,14 @@ socket-patch remove "pkg:npm/lodash@4.17.20" --json ### `repair` -Download missing blobs and clean up unused blobs. +Download missing blobs, clean up unused blobs, and reset the advisory lock state. Alias: `gc` `repair` cleans up the `.socket/` directory without running a scan — useful when you've manually adjusted the manifest, recovered from a partial-failure state, or just want to free space. For the combined workflow (discover + apply + GC in one pass), use `scan --mode agent --prune --json --yes` instead. +As its final step, `repair` removes the leftover `.socket/apply.lock` file that mutating commands retain between runs (skipped under `--dry-run`). A leftover file from a crashed run never blocks anything — the OS releases a dead process's lock automatically — so this is pure housekeeping. If another socket-patch process is actively running, `repair` refuses up front with `lock_held` (exit 1); it never steals a live lock — wait for the other process to finish, or budget a wait with `--lock-timeout`. + **Usage:** ```bash socket-patch repair [options] @@ -533,129 +626,9 @@ socket-patch repair --download-only socket-patch repair --json ``` -### `unlock` - -Inspect — and optionally release — the `.socket/apply.lock` advisory lock that the mutating commands (`apply`, `rollback`, `repair`, `remove`, `vendor`) take. Exits `0` when the lock is free and `1` when another socket-patch process holds it, so CI gates and monitoring can pattern-match the exit code without parsing JSON. - -Reach for it when recovering from a crashed run that left a stale `apply.lock` behind: `unlock` tells you whether anything still holds the lock, and `unlock --release` deletes the file once it's confirmed free. A held lock is never released — for that scenario re-run the failing mutating command with [`--break-lock`](#global-options) (or wait for the holder with `--lock-timeout`). - -**Usage:** -```bash -socket-patch unlock [options] -``` - -**Command-specific options** (plus all [Global Options](#global-options)): -| Flag | Description | -|------|-------------| -| `--release` | When the lock is free, also delete the lock file (it is normally retained across runs). Refused when the lock is held. (env: `SOCKET_UNLOCK_RELEASE`) | - -**Examples:** -```bash -# Is anything holding the lock? -socket-patch unlock - -# Free a stale lock left by a crashed run -socket-patch unlock --release - -# JSON output for scripting -socket-patch unlock --json -``` - -### `setup` - -Configure your project so patches are **re-applied automatically after install** — no manual `socket-patch apply` step in CI. `setup` is a one-time operation: run it, commit the change together with your `.socket/` patches, and every later install handles the rest. It is strictly **opt-in** — nothing is hooked unless you run `setup` and commit the result. - -- **npm / yarn / pnpm / bun** — writes a `postinstall` script into `package.json` so any install re-applies patches (pnpm: root package only). -- **Python (pip / uv / poetry / pdm / hatch)** — Python has no universal post-install hook, so `setup` instead commits a **`socket-patch[hook]`** dependency (for classic Poetry, the equivalent `socket-patch = { extras = ["hook"] }`). Installing it lays down a startup `.pth` (shipped by the small `socket-patch-hook` wheel) that re-applies your committed `.socket/` patches the next time the interpreter runs. It is package-manager-agnostic (it rides the interpreter, not any one installer) and **fail-open** — a hook error can never break interpreter startup. -- **Ruby gems (Bundler)** — adds a managed `plugin "socket-patch"` block to the `Gemfile` and commits an in-tree Bundler plugin under `.socket/bundler-plugin/`. It re-applies patches on every `bundle install` (cached *and* fresh). (Requires the `socket-patch` CLI on `PATH`.) -- **Composer (PHP)** — appends `socket-patch apply` to `composer.json`'s `post-install-cmd` / `post-update-cmd` script events, so patches re-apply on every `composer install` / `composer update`. (Requires the `socket-patch` CLI on `PATH`.) -- **Cargo & Go** — *apply-only, no `setup` hook.* A one-click auto-repatch-on-build isn't possible for these, so `setup` skips them. Patch with `socket-patch apply` directly: **cargo** patches the crate in place (in `vendor/` or the registry cache, rewriting `.cargo-checksum.json` so `cargo build` accepts it); **go** writes a project-local patched copy under `.socket/go-patches/` plus a `go.mod` `replace` directive (the module cache is `go.sum`-verified, so in-place patching can't build). Commit `go.mod` + `.socket/go-patches/` so a clone builds the patched bytes. Declare them in `setup.manual` for VEX attestation. -- **Apply-only ecosystems** (nuget · maven · deno) — no native install hook to wire, so `setup` reports `no_files`; patch them on demand with `socket-patch apply`. - -**Usage:** -```bash -socket-patch setup # configure (interactive) -socket-patch setup --check # verify configured; non-zero exit if not (CI gate) -socket-patch setup --remove # revert what setup added -``` - -**Command-specific options** (plus all [Global Options](#global-options) — `--dry-run`, `--yes`, `--json`, `--cwd`): -| Flag | Description | -|------|-------------| -| `--check` | Read-only verification that every manifest is configured; exits non-zero if any still needs setup. Never writes (safe in CI). Conflicts with `--remove`. | -| `--remove` | Revert the install hooks `setup` added (npm `package.json` scripts, the Python `socket-patch[hook]` dependency, and the gem Bundler plugin wiring). | - -#### Disabling / opting out (Python hook) - -The Python hook is designed to be easy to skip or remove: - -- **Per interpreter / CI step:** set `SOCKET_PATCH_HOOK=off` (or `SOCKET_NO_HOOK=1`). This is checked *before any hook code runs*, so it fully bypasses the hook for that process. -- **Remove from a project:** `socket-patch setup --remove`, then `pip uninstall socket-patch-hook`. -- **Never opted in:** if you don't run `setup`, there is no hook — it is opt-in by design. - -#### What the Python hook does, and its safety model - -On interpreter startup, *only when the set of installed packages changed*, the hook runs `socket-patch apply --offline --ecosystems pypi` for the project that owns the current virtualenv, re-applying only the patches committed in that project's `.socket/`. Specifically: - -- It is **anchored to the virtualenv** it is installed in (not the working directory), so a `python` started from an unrelated directory cannot pull in a foreign `.socket/manifest.json`. -- It **verifies each file's hash before patching** and **never writes outside the installed package directory** (path-escaping manifest keys are refused). -- It resolves the `socket-patch` binary from the **installed `socket-patch` package** (not from `PATH`), so an unexpected binary on `PATH` is not executed. -- It runs **offline** (no network at startup) and is **fail-open** (any error is swallowed; it can never abort the interpreter). - -**Examples:** -```bash -# Interactive setup (all detected ecosystems, auto-detected) -socket-patch setup - -# Non-interactive -socket-patch setup -y - -# Preview changes -socket-patch setup --dry-run - -# Verify configuration in CI (exits non-zero if not set up) -socket-patch setup --check - -# JSON output for scripting -socket-patch setup --json -y -``` - -### `vex` - -Generate an [OpenVEX](https://github.com/openvex) 0.2.0 attestation describing the vulnerabilities that the applied patches have mitigated. See [OpenVEX attestations](#openvex-attestations) below for the full workflow. - -**Usage:** -```bash -socket-patch vex [options] -``` - -**Command-specific options** (plus all [Global Options](#global-options)): -| Flag | Description | -|------|-------------| -| `-O, --output ` | Write the VEX document to this path instead of stdout. Required when combined with `--json`. (env: `SOCKET_VEX_OUTPUT`) | -| `--product ` | Override the auto-detected top-level product PURL/identifier. (env: `SOCKET_VEX_PRODUCT`) | -| `--no-verify` | Skip the on-disk file-hash check and trust the manifest — useful on a build machine that doesn't have the patched files laid out. (env: `SOCKET_VEX_NO_VERIFY`) | -| `--doc-id ` | Override the document `@id`. Default is a random `urn:uuid:` regenerated each run; pin this for a reproducible identifier. (env: `SOCKET_VEX_DOC_ID`) | -| `--compact` | Emit compact JSON instead of pretty-printed. (env: `SOCKET_VEX_COMPACT`) | - -**Examples:** -```bash -# Print a VEX document to stdout (human-readable status goes to stderr) -socket-patch vex - -# Write the document to a file -socket-patch vex --output socket.vex.json - -# CI shape: VEX doc to file, machine-readable envelope to stdout -socket-patch vex --json --output socket.vex.json - -# Generate on a build box without verifying on-disk files -socket-patch vex --no-verify --output socket.vex.json -``` - ### Undoing things -Six commands undo different layers of socket-patch state — pick by what you want back: +Five commands undo different layers of socket-patch state — pick by what you want back: | Command | What it undoes | |---------|----------------| @@ -663,8 +636,7 @@ Six commands undo different layers of socket-patch state — pick by what you wa | [`remove`](#remove) | Rollback **plus** deletes the manifest entry and reverts any vendoring — **permanent**, the patch is fully gone in one command | | [`vendor --revert`](#vendor) | **Un-vendors wholesale**: restores the recorded original lockfile fragments byte-for-byte and removes the `.socket/vendor/` artifacts — works without a manifest | | [`scan --prune`](#scan) | **Reconciles, doesn't reverse**: drops manifest entries for packages no longer installed and garbage-collects orphan blob/diff/archive files — installed patches stay | -| [`repair`](#repair) (alias `gc`) | **Restores health, not originals**: re-downloads missing blobs, rebuilds missing/corrupt vendored artifacts, and cleans up unused ones | -| [`unlock --release`](#unlock) | **Frees a stale lock** left by a crashed run — never touches patches or the manifest, and refuses while the lock is held | +| [`repair`](#repair) (alias `gc`) | **Restores health, not originals**: re-downloads missing blobs, rebuilds missing/corrupt vendored artifacts, cleans up unused ones, and removes the leftover `apply.lock` from a crashed run | ## OpenVEX attestations diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index d5ca249b..ff505ba1 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -8,17 +8,18 @@ This document defines the **public surface** of the `socket-patch` binary. Anyth | Name | Visible alias(es) | Notes | |---|---|---| +| `scan` | — | Crawl installed packages for available patches | | `apply` | — | Apply patches from the local manifest | +| `vex` | — | Emit an OpenVEX 0.2.0 attestation derived from the local manifest | +| `vendor` | — | Eject patched dependencies into committable `.socket/vendor/` and rewire lockfiles | +| `setup` | — | Wire automatic-patching install hooks (npm/pypi/gem) | | `rollback` | — | Restore original files; takes optional positional `identifier` | | `get` | `download` | Fetch + apply patch; requires positional `identifier` | -| `scan` | — | Crawl installed packages for available patches | | `list` | — | Print patches in the local manifest | | `remove` | — | Remove patch from manifest (rolls back first); requires positional `identifier` | -| `setup` | — | Wire automatic-patching install hooks (npm/pypi/gem) | -| `repair` | `gc` | Download missing blobs, rebuild missing/corrupt vendored artifacts, clean up unused ones | -| `unlock` | — | Inspect (and optionally `--release`) the `<.socket>/apply.lock` advisory lock the mutating subcommands take; exits 0 when free, 1 when held (see [`src/commands/unlock.rs`](src/commands/unlock.rs)) | -| `vendor` | — | Eject patched dependencies into committable `.socket/vendor/` and rewire lockfiles | -| `vex` | — | Emit an OpenVEX 0.2.0 attestation derived from the local manifest | +| `repair` | `gc` | Download missing blobs, rebuild missing/corrupt vendored artifacts, clean up unused ones, and delete the leftover `<.socket>/apply.lock` as a final housekeeping step (skipped under `--dry-run`; refuses with `lock_held` when a live process holds the lock) | + +**Removed in v4.0:** the `unlock` subcommand (fold: `repair` now cleans up the lock file; a leftover lock from a crashed run never blocks acquisition — the OS releases a dead holder's advisory lock — so there is no stale-lock state to inspect or clear before a mutating command). **Bare-UUID fallback.** `socket-patch ` is rewritten to `socket-patch get `. The UUID shape checked is the standard 8-4-4-4-12 hex pattern (case-insensitive). See [`src/lib.rs::looks_like_uuid`](src/lib.rs). @@ -49,7 +50,6 @@ In v3.0 every subcommand accepts the same set of "global" flags via a single sha | `--dry-run` | — | `SOCKET_DRY_RUN` | `false` | bool | Preview, no mutations | | `--yes` | `-y` | `SOCKET_YES` | `false` | bool | Skip prompts | | `--lock-timeout` | — | `SOCKET_LOCK_TIMEOUT` | (none) | seconds (u64) | How long to wait for `<.socket>/apply.lock`. Unset and `0` both mean a single non-blocking try; a positive value retries with a 100 ms backoff. Only meaningful on the mutating subcommands | -| `--break-lock` | — | `SOCKET_BREAK_LOCK` | `false` | bool | Reclaim a stale `apply.lock` left by a crashed run (the file is never deleted — that would defeat mutual exclusion). Refuses with `lock_held` when a live process holds it; emits an auditable `lock_broken` warning event | | `--debug` | — | `SOCKET_DEBUG` | `false` | bool | Verbose debug logs to stderr | | `--no-telemetry` | — | `SOCKET_TELEMETRY_DISABLED` | `false` | bool | Disable anonymous usage telemetry | @@ -80,7 +80,6 @@ Beyond the globals above, each subcommand defines a small set of local arguments | `rollback` | optional positional `identifier`; `--one-off` | `SOCKET_ONE_OFF` | Rollback target | | `vex` | `--output` / `-O`, `--product`, `--no-verify`, `--doc-id`, `--compact` | `SOCKET_VEX_OUTPUT`, `SOCKET_VEX_PRODUCT`, `SOCKET_VEX_NO_VERIFY`, `SOCKET_VEX_DOC_ID`, `SOCKET_VEX_COMPACT` | OpenVEX 0.2.0 document generation; see "vex output channels" below | | `repair` | `--download-only` | `SOCKET_DOWNLOAD_ONLY` | Repair-specific cleanup mode (mutually exclusive with `--offline`; combining them is a usage error, exit 2) | -| `unlock` | `--release` | `SOCKET_UNLOCK_RELEASE` | When the lock is free, also delete the lock file (normally retained across runs). Refused when the lock is held — reclaiming a held-looking lock is `--break-lock`'s job on the mutating subcommand | | `setup` | `--check`, `--remove` (mutually exclusive); `--exclude` (CSV member paths); honors global `--ecosystems` | `SOCKET_SETUP_EXCLUDE`, `SOCKET_ECOSYSTEMS` | Wire / verify / revert the automatic-patching install hooks. `--exclude` skips + persists workspace members (property 9). See [Setup command contract](#setup-command-contract) | `scan --apply` opts JSON callers into the full discover → select → apply pipeline. Without it, `scan --json` stays read-only (discovery + `updates` array only). No effect outside `--json` mode — the non-JSON path always prompts the user interactively. @@ -108,7 +107,7 @@ The rewriter reads a fixed set of candidate files from the project root: the npm * `.socket/vendor/state.json` — the **vendored**-mode ledger (see "Ownership, state, and reversal" below): wiring edits with verbatim pre-vendor originals, artifact fingerprints, optional `detached` records. * `.socket/vendor/redirect-state.json` — the **hosted**-mode ledger (`RedirectState` in `socket-patch-core/src/patch/redirect/state.rs`): `{ version, mode: "hosted", edits[], records{} }`. `edits` are recorded `FileEdit`s (append-only across re-runs — merge, never clobber: the pre-redirect originals a future revert needs live here); `records` maps PURL → the full manifest `PatchRecord` so a post-install `vex` can attest redirected patches with no manifest entry. The `mode` string is opaque to the loader (pre-rename ledgers carrying `"redirect"` still load; a hosted re-run normalizes them to `"hosted"`). Written identically by this CLI and by the depscan backend's hosted PR flow (`github-patch-pr-hosted.ts`). -`--dry-run` previews what `apply` / `rollback` / `scan --apply` / `repair` / `remove` / `unlock --release` would do without mutating disk. In JSON mode, the envelope is populated with would-be actions and counts (`remove --dry-run` skips the confirmation prompt — there is nothing to confirm — and flips its would-be `Removed` events to `Verified` previews, so `summary.removed` stays "entries actually deleted"; `unlock --release --dry-run` keeps `released: false` and reports the preview via additive `dryRun` + `wouldRelease` fields). +`--dry-run` previews what `apply` / `rollback` / `scan --apply` / `repair` / `remove` would do without mutating disk. In JSON mode, the envelope is populated with would-be actions and counts (`remove --dry-run` skips the confirmation prompt — there is nothing to confirm — and flips its would-be `Removed` events to `Verified` previews, so `summary.removed` stays "entries actually deleted"). `repair --dry-run` also skips the final lock-file deletion. The hidden alias `--no-apply` on `get --save-only` is **part of the contract** — it does not appear in `--help` but is widely used in existing scripts. @@ -622,7 +621,6 @@ All v3.0 env vars use the `SOCKET_*` prefix. Three legacy `SOCKET_PATCH_*` names | `SOCKET_DRY_RUN` | `--dry-run` | `false` | — | | `SOCKET_YES` | `--yes` / `-y` | `false` | — | | `SOCKET_LOCK_TIMEOUT` | `--lock-timeout` | (none) | Seconds to wait for `apply.lock`; unset/`0` = single non-blocking try. | -| `SOCKET_BREAK_LOCK` | `--break-lock` | `false` | Reclaim a stale `apply.lock`; refused when a live process holds it. | | `SOCKET_DEBUG` | `--debug` | `false` | **Renamed in v3.0** (was `SOCKET_PATCH_DEBUG`). | | `SOCKET_TELEMETRY_DISABLED` | `--no-telemetry` | `false` | **Renamed in v3.0** (was `SOCKET_PATCH_TELEMETRY_DISABLED`). | | `SOCKET_FORCE` | `apply --force` / `-f` | `false` | Local to `apply`. | @@ -632,7 +630,6 @@ All v3.0 env vars use the `SOCKET_*` prefix. Three legacy `SOCKET_PATCH_*` names | `SOCKET_ALL_RELEASES` | `get --all-releases` / `scan --all-releases` | `false` | Local to `get`/`scan`. Download patches for every release/distribution variant, not just the installed one. | | `SOCKET_SKIP_ROLLBACK` | `remove --skip-rollback` | `false` | Local to `remove`. | | `SOCKET_DOWNLOAD_ONLY` | `repair --download-only` | `false` | Local to `repair`. | -| `SOCKET_UNLOCK_RELEASE` | `unlock --release` | `false` | Local to `unlock`. Delete the lock file when free; refused when held. | | `SOCKET_SETUP_EXCLUDE` | `setup --exclude` | (none) | Local to `setup`; comma-separated workspace-member paths, persisted to `setup.exclude`. | | `SOCKET_VEX` | `apply --vex` / `scan --vex` / `vendor --vex` | (none) | Embedded OpenVEX output path. The `SOCKET_VEX_*` knobs (`_PRODUCT`, `_NO_VERIFY`, `_DOC_ID`, `_COMPACT`) are shared with the standalone `vex` command; on the host commands they bind to `--vex-product` etc. | | `SOCKET_VEX_OUTPUT` | `vex --output` / `-O` | (none) | Local to the standalone `vex`: document output path (required with `--json`). | @@ -678,7 +675,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified ```jsonc { - "command": "apply" | "rollback" | "get" | "scan" | "list" | "remove" | "repair" | "setup" | "unlock" | "vendor" | "vex", + "command": "scan" | "apply" | "vex" | "vendor" | "setup" | "rollback" | "get" | "list" | "remove" | "repair", "status": "success" | "partialFailure" | "error" | "noManifest" | "paidRequired" | "notFound", "dryRun": false, "events": [ , ... ], @@ -818,9 +815,8 @@ The remaining commands still emit their pre-v3.0 ad-hoc JSON shapes and will mig - ⏳ `rollback` — still emits per-package result records. - ⏳ `setup` — still emits its own `{ status, updated, alreadyConfigured, errors, files }` shape (and the `--check` / `--remove` variants), now documented in full under [Setup command contract](#setup-command-contract). -Two commands are **intentionally not** plain-envelope and will stay that way (not migration debt): +One command is **intentionally not** plain-envelope and will stay that way (not migration debt): -- `unlock` — **mixed**: the free path emits a flat `{ "command": "unlock", "status": "free", "lockFile": "...", "released": bool }` object (jq-friendly; documented at `src/commands/unlock.rs::emit_free`; under `--dry-run` it gains additive `dryRun: true` and — with `--release` — `wouldRelease: bool` fields, `released` staying truthfully false), while the held/error paths emit the standard envelope (`status: "error"`, code `lock_held` / `lock_io`). - `vex` — **hybrid**: the OpenVEX document is itself JSON and is the primary output; the envelope appears only under `--json --output `. See the [vex output channels](#vex-output-channels) table. ### `patches[]` entry shape for `get` and `scan --apply` @@ -916,7 +912,7 @@ Exit `1` when `status` is `partialFailure` (any `events[*].action == "failed"`) | `1` | Error (missing/invalid manifest, fetch failed, apply failed, selection cancelled in non-JSON mode, etc.) | | `2` | Usage error: clap parse failures (unknown flag/value, missing required arg — including the clap-enforced `setup --check --remove` conflict) and the conflicts the commands enforce themselves — `scan`'s cross-mode conflicts (`--mode` combined with a DIFFERENT mode's boolean spelling, rejected in `resolve_mode_flags`), `repair --offline --download-only`. `vex` also exits `2` on hard errors before document generation (see its tri-state table below) | -`list` returns **`0`** for an empty manifest and **`1`** for a missing manifest — these are distinct and load-bearing. `unlock` returns **`0`** when the lock is free and **`1`** when it is held (its `--release` refusal on a held lock is that same exit 1). +`list` returns **`0`** for an empty manifest and **`1`** for a missing manifest — these are distinct and load-bearing. Every mutating subcommand returns **`1`** with `errorCode: lock_held` when another live socket-patch process holds `<.socket>/apply.lock`. `vex` exit codes are tri-state: diff --git a/crates/socket-patch-cli/src/args.rs b/crates/socket-patch-cli/src/args.rs index a14955fd..76320a72 100644 --- a/crates/socket-patch-cli/src/args.rs +++ b/crates/socket-patch-cli/src/args.rs @@ -267,21 +267,6 @@ pub struct GlobalArgs { #[arg(long = "lock-timeout", env = "SOCKET_LOCK_TIMEOUT")] pub lock_timeout: Option, - /// Reclaim a stale `<.socket>/apply.lock` left behind by a - /// crashed run (the file is never deleted — deleting a lock file - /// defeats mutual exclusion). Refuses with `lock_held` if a live - /// socket-patch process still holds the lock. Emits a - /// `lock_broken` warning event in the JSON envelope so the - /// action is auditable. Only meaningful for mutating - /// subcommands; other commands accept it silently. - #[arg( - long = "break-lock", - env = "SOCKET_BREAK_LOCK", - default_value_t = false, - value_parser = parse_bool_flag, - )] - pub break_lock: bool, - /// Emit verbose debug logs to stderr. #[arg( long = "debug", @@ -381,7 +366,6 @@ pub const GLOBAL_ARG_ENV_VARS: &[&str] = &[ "SOCKET_DRY_RUN", "SOCKET_YES", "SOCKET_LOCK_TIMEOUT", - "SOCKET_BREAK_LOCK", "SOCKET_DEBUG", "SOCKET_TELEMETRY_DISABLED", ]; @@ -401,7 +385,6 @@ pub const LOCAL_ARG_ENV_VARS: &[&str] = &[ "SOCKET_DOWNLOAD_ONLY", "SOCKET_SETUP_EXCLUDE", "SOCKET_VENDOR_REVERT", - "SOCKET_UNLOCK_RELEASE", "SOCKET_BATCH_SIZE", "SOCKET_VEX", "SOCKET_VEX_OUTPUT", @@ -472,7 +455,6 @@ impl Default for GlobalArgs { dry_run: false, yes: false, lock_timeout: None, - break_lock: false, debug: false, no_telemetry: false, } @@ -826,7 +808,6 @@ mod tests { assert!(!cli.common.global); assert!(!cli.common.dry_run); assert!(!cli.common.yes); - assert!(!cli.common.break_lock); assert!(!cli.common.debug); assert!(!cli.common.no_telemetry); }); @@ -1128,7 +1109,6 @@ mod tests { ("SOCKET_SKIP_ROLLBACK", &["socket-patch", "remove", "x"]), ("SOCKET_DOWNLOAD_ONLY", &["socket-patch", "repair"]), ("SOCKET_VENDOR_REVERT", &["socket-patch", "vendor"]), - ("SOCKET_UNLOCK_RELEASE", &["socket-patch", "unlock"]), ("SOCKET_VEX_NO_VERIFY", &["socket-patch", "vex"]), ("SOCKET_VEX_COMPACT", &["socket-patch", "vex"]), // The embedded `--vex-*` twins share the same env vars and must diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index aebbaf4e..f0301341 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -20,7 +20,7 @@ use std::time::Duration; use crate::args::{apply_env_toggles, GlobalArgs}; use crate::commands::fetch_stage::{stage_patch_sources, StageOutcome, StagedSources}; -use crate::commands::lock_cli::{acquire_or_emit, lock_broken_event}; +use crate::commands::lock_cli::acquire_or_emit; use crate::commands::vex::{generate_vex_from_manifest_path, VexEmbedArgs}; use crate::ecosystem_dispatch::{find_packages_for_purls, partition_purls}; use crate::json_envelope::{ @@ -592,20 +592,16 @@ pub async fn run(args: ApplyArgs) -> i32 { // `.socket/` directory. The guard releases on function return; see // `socket_patch_core::patch::apply_lock`. let socket_dir = manifest_path.parent().unwrap_or(Path::new(".")); - let acquired = match acquire_or_emit( + let _lock = match acquire_or_emit( socket_dir, Command::Apply, args.common.json, - args.common.silent, args.common.dry_run, Duration::from_secs(args.common.lock_timeout.unwrap_or(0)), - args.common.break_lock, ) { - Ok(acquired) => acquired, + Ok(guard) => guard, Err(code) => return code, }; - let _lock = acquired.guard; - let lock_was_broken = acquired.broke_lock; // Package-manager layout detection. yarn-berry PnP keeps packages // inside `.yarn/cache/*.zip` and resolves them via `.pnp.cjs` — @@ -687,9 +683,6 @@ pub async fn run(args: ApplyArgs) -> i32 { if args.common.json { let mut env = Envelope::new(Command::Apply); env.dry_run = args.common.dry_run; - if lock_was_broken { - env.record(lock_broken_event(socket_dir)); - } for result in &results { env.record(result_to_event(result, args.common.dry_run)); // Mismatch overwrites ride as Skipped warning events diff --git a/crates/socket-patch-cli/src/commands/lock_cli.rs b/crates/socket-patch-cli/src/commands/lock_cli.rs index 0347b9d8..07f295bc 100644 --- a/crates/socket-patch-cli/src/commands/lock_cli.rs +++ b/crates/socket-patch-cli/src/commands/lock_cli.rs @@ -16,29 +16,7 @@ use std::time::Duration; use socket_patch_core::patch::apply_lock::{acquire, LockError, LockGuard}; -use crate::json_envelope::{Command, Envelope, EnvelopeError, PatchAction, PatchEvent}; - -/// Stable `errorCode` tag emitted as a `Skipped` warning event when -/// `--break-lock` actually reclaims a stale pre-existing lock file. -/// Integration tests and downstream consumers pattern-match on the -/// literal string. -pub(crate) const LOCK_BROKEN_CODE: &str = "lock_broken"; - -/// Outcome of a successful lock acquisition. Callers attach a -/// `lock_broken` event to their own envelope when [`broke_lock`] is -/// true, so the audit trail follows the same conventions as the -/// rest of the command's output. -/// -/// [`broke_lock`]: LockAcquired::broke_lock -#[derive(Debug)] -pub(crate) struct LockAcquired { - pub(crate) guard: LockGuard, - /// True iff `--break-lock` was set AND a pre-existing - /// `apply.lock` file (with no live holder) was reclaimed. - /// False when the file didn't exist (nothing to break) — the - /// flag was a no-op in that case so no warning is warranted. - pub(crate) broke_lock: bool, -} +use crate::json_envelope::{Command, Envelope, EnvelopeError}; /// Try to acquire `/apply.lock` and return the guard, or /// emit a failure envelope and a non-zero exit code. @@ -53,69 +31,33 @@ pub(crate) struct LockAcquired { /// try-once shape. Positive values wait with a 100 ms backoff — /// see `socket_patch_core::patch::apply_lock::acquire`. /// -/// `break_lock = true` reclaims a *stale* `/apply.lock` -/// via a non-blocking acquire. The motivating case is a crashed prior -/// run that left the file behind — harmless to a fresh acquire (the -/// kernel already released the dead holder's advisory lock), but worth -/// an audit event. It is **not** a force-steal: if a live holder still -/// owns the lock the helper refuses with `lock_held` rather than -/// stealing. The file is deliberately never unlinked — an unlink -/// defeats mutual exclusion, because a competitor (live holder or -/// mid-acquire racer) can keep or take an advisory lock on the -/// orphaned inode while a fresh acquire locks its replacement. When a -/// pre-existing file is reclaimed with no live holder the return -/// value's `broke_lock` is true and the caller should attach a -/// `lock_broken` warning event to their envelope. +/// A leftover `apply.lock` from a crashed run never contends: the +/// kernel released the dead holder's advisory lock along with its +/// file handle, so the acquire reclaims the file in place. `Held` +/// therefore always means a *live* process. The file is never +/// unlinked here — an unlink defeats mutual exclusion, because a +/// competitor (live holder or mid-acquire racer) can keep or take an +/// advisory lock on the orphaned inode while a fresh acquire locks +/// its replacement. The only sanctioned deletion is `repair`'s final +/// cleanup, which runs after its own guard is released. pub(crate) fn acquire_or_emit( socket_dir: &Path, command: Command, json: bool, - silent: bool, dry_run: bool, timeout: Duration, - break_lock: bool, -) -> Result { - // `--break-lock` is a *non-blocking* try-once probe (the caller's - // `timeout` never applies to it) that never unlinks the lock file — - // see the doc comment for why removal would defeat mutual - // exclusion. Snapshot whether a lock file existed *before* the - // acquire: it opens with `create(true)`, so afterwards the file - // always exists, and only a *pre-existing* leftover counts as - // "broke a lock" (mirroring `unlock`'s `lock_existed` - // source-of-truth pattern). - let lock_existed = break_lock && socket_dir.join("apply.lock").exists(); - let acquire_timeout = if break_lock { Duration::ZERO } else { timeout }; - - match acquire(socket_dir, acquire_timeout) { - Ok(guard) => { - // No live holder — the acquire's guard IS the lock; a - // crashed run's leftover needs no removal (the kernel - // already released the dead holder's advisory lock). - if lock_existed && !silent && !json { - eprintln!( - "Warning: --break-lock reclaimed stale {} (no live holder).", - socket_dir.join("apply.lock").display() - ); - } - Ok(LockAcquired { - guard, - broke_lock: lock_existed, - }) - } +) -> Result { + match acquire(socket_dir, timeout) { + Ok(guard) => Ok(guard), Err(LockError::Held) => { - // A live holder exists. For `--break-lock` that means - // refuse rather than steal, with two consequences for the - // message: the probe never waited, so it must not claim a - // "(waited …)" (`break_probe_held_message` takes no timeout - // precisely so the wrong value can't be passed back in); - // and re-advising --break-lock — which was just refused — - // would be self-defeating, so only the inspect hint remains. - let (msg, hint) = if break_lock { - (break_probe_held_message(), Hint::UnlockOnly) - } else { - (held_message(timeout), Hint::UnlockOrBreakLock) - }; - emit(command, json, dry_run, "lock_held", &msg, hint); + emit( + command, + json, + dry_run, + "lock_held", + &held_message(timeout), + Hint::Wait, + ); Err(1) } Err(LockError::Io { path, source }) => { @@ -126,30 +68,6 @@ pub(crate) fn acquire_or_emit( } } -/// Build the warning event that callers attach to their envelope -/// when [`LockAcquired::broke_lock`] is true. Artifact-level (no -/// PURL) since the action targets the `.socket/` directory itself, -/// not a specific package. -pub(crate) fn lock_broken_event(socket_dir: &Path) -> PatchEvent { - PatchEvent::artifact(PatchAction::Skipped).with_reason( - LOCK_BROKEN_CODE, - format!( - "--break-lock reclaimed stale {}/apply.lock (no live holder)", - socket_dir.display() - ), - ) -} - -/// Contention message for the `--break-lock` pre-acquire probe. That -/// probe is hard-wired to a non-blocking try-once (`Duration::ZERO`), so -/// the message must never claim a wait, regardless of the caller's -/// `--lock-timeout`. Kept timeout-free on purpose: the call site cannot -/// thread the full budget back in and fabricate a "(waited …)" clause -/// for time that was never spent. -fn break_probe_held_message() -> String { - held_message(Duration::ZERO) -} - /// Human-readable description of a `lock_held` contention for the given /// wait budget. A zero budget means the historical non-blocking /// try-once, so we omit the "(waited …)" clause entirely. @@ -193,14 +111,13 @@ pub(crate) fn error_envelope( env } -/// Remediation hint appended under the human-mode error line. The -/// `--break-lock` advice is only valid when the caller hasn't already -/// tried it — a refused `--break-lock` (live holder) must not advise -/// rerunning with `--break-lock`, which is exactly what just failed. +/// Remediation hint appended under the human-mode error line. `Held` +/// always means a live process (leftover files never contend), so the +/// only honest advice is to wait — pointing at another socket-patch +/// command would just hit the same contention. enum Hint { None, - UnlockOnly, - UnlockOrBreakLock, + Wait, } fn emit(command: Command, json: bool, dry_run: bool, code: &str, message: &str, hint: Hint) { @@ -217,12 +134,9 @@ fn emit(command: Command, json: bool, dry_run: bool, code: &str, message: &str, eprintln!("Error: {message}."); match hint { Hint::None => {} - Hint::UnlockOnly => { - eprintln!(" Run `socket-patch unlock` to inspect."); - } - Hint::UnlockOrBreakLock => { + Hint::Wait => { eprintln!( - " Run `socket-patch unlock` to inspect, or rerun with --break-lock if you're sure no holder exists." + " Wait for it to finish, or retry with --lock-timeout to wait for the lock." ); } } @@ -236,43 +150,18 @@ mod tests { #[test] fn acquire_or_emit_succeeds_on_fresh_dir() { let dir = tempfile::tempdir().unwrap(); - let acquired = acquire_or_emit( - dir.path(), - Command::Apply, - false, - true, - false, - Duration::ZERO, - false, - ) - .unwrap(); - assert!(!acquired.broke_lock); - drop(acquired.guard); + let guard = + acquire_or_emit(dir.path(), Command::Apply, false, false, Duration::ZERO).unwrap(); + drop(guard); } #[test] fn acquire_or_emit_returns_one_on_contention() { let dir = tempfile::tempdir().unwrap(); - let _first = acquire_or_emit( - dir.path(), - Command::Apply, - false, - true, - false, - Duration::ZERO, - false, - ) - .unwrap(); - let code = acquire_or_emit( - dir.path(), - Command::Apply, - false, - true, - false, - Duration::ZERO, - false, - ) - .unwrap_err(); + let _first = + acquire_or_emit(dir.path(), Command::Apply, false, false, Duration::ZERO).unwrap(); + let code = acquire_or_emit(dir.path(), Command::Apply, false, false, Duration::ZERO) + .unwrap_err(); assert_eq!(code, 1); } @@ -283,10 +172,8 @@ mod tests { &dir.path().join("nope"), Command::Apply, false, - true, false, Duration::ZERO, - false, ) .unwrap_err(); assert_eq!(code, 1); @@ -299,25 +186,15 @@ mod tests { #[test] fn acquire_or_emit_honors_lock_timeout() { let dir = tempfile::tempdir().unwrap(); - let _first = acquire_or_emit( - dir.path(), - Command::Apply, - false, - true, - false, - Duration::ZERO, - false, - ) - .unwrap(); + let _first = + acquire_or_emit(dir.path(), Command::Apply, false, false, Duration::ZERO).unwrap(); let start = std::time::Instant::now(); let code = acquire_or_emit( dir.path(), Command::Apply, false, - true, false, Duration::from_millis(250), - false, ) .unwrap_err(); let elapsed = start.elapsed(); @@ -329,137 +206,49 @@ mod tests { ); } - /// `break_lock=true` against a pre-existing lock file with no - /// holder reclaims the file in place and acquires. `broke_lock` - /// flag surfaces so callers can attach the warning event. + /// A leftover lock file from a crashed run never contends — the + /// kernel released the dead holder's advisory lock along with its + /// file handle, so a plain acquire reclaims the file in place. + /// This is the fact that made `--break-lock` redundant (and, with + /// it, the `unlock` subcommand): there is no stale-lock state a + /// user ever needs to clear before running a mutating command. #[test] - fn acquire_or_emit_break_lock_reclaims_stale_file_and_acquires() { + fn acquire_or_emit_reclaims_stale_leftover_file() { let dir = tempfile::tempdir().unwrap(); // Pre-stage a lock file with no holder — simulates the // post-crash leftover scenario. std::fs::write(dir.path().join("apply.lock"), b"").unwrap(); - let acquired = acquire_or_emit( - dir.path(), - Command::Apply, - false, - true, - false, - Duration::ZERO, - true, - ) - .unwrap(); - assert!( - acquired.broke_lock, - "broke_lock should be true when a stale lock file existed and was reclaimed" - ); - // The lock file persists (never unlinked) and we hold it. - assert!(dir.path().join("apply.lock").is_file()); - } - - /// `break_lock=true` on a clean directory (no lock file) is a - /// no-op for the warning surface — `broke_lock` stays false so - /// callers don't emit a spurious event. - #[test] - fn acquire_or_emit_break_lock_is_noop_when_no_file() { - let dir = tempfile::tempdir().unwrap(); - let acquired = acquire_or_emit( - dir.path(), - Command::Apply, - false, - true, - false, - Duration::ZERO, - true, - ) - .unwrap(); - assert!( - !acquired.broke_lock, - "broke_lock should be false when there was nothing to remove" - ); - } - - /// Regression: `--break-lock` must NOT steal a lock from a *live* - /// holder. A bare `remove_file` + re-`acquire` would unlink the - /// holder's inode and lock a fresh one, leaving two processes both - /// "holding" the lock and racing on every file write. With the - /// probe-before-break guard, contention is refused with exit 1 and - /// the holder keeps the lock (its file stays on disk). - #[test] - fn acquire_or_emit_break_lock_refuses_when_live_holder() { - let dir = tempfile::tempdir().unwrap(); - // A genuinely live holder: guard stays alive for the test. - let _held = acquire(dir.path(), Duration::ZERO).unwrap(); + let guard = + acquire_or_emit(dir.path(), Command::Apply, false, false, Duration::ZERO).unwrap(); + // The file persists (never unlinked here) and we hold the lock: + // a competitor's acquire is contended while the guard is live. assert!(dir.path().join("apply.lock").is_file()); - - let code = acquire_or_emit( - dir.path(), - Command::Apply, - false, - true, - false, - Duration::ZERO, - true, // break_lock - ) - .unwrap_err(); - assert_eq!( - code, 1, - "break-lock must refuse a live holder, not steal it" - ); - // The original holder's lock file is untouched. - assert!(dir.path().join("apply.lock").is_file()); - } - - /// Companion to the refusal test: while a live holder is present, - /// `--break-lock` must leave the holder's exclusivity intact — i.e. - /// a follow-up plain acquire still sees the lock as `Held`. This is - /// the real safety property (no double-acquire), distinct from the - /// exit code. - #[test] - fn acquire_or_emit_break_lock_does_not_break_mutual_exclusion() { - let dir = tempfile::tempdir().unwrap(); - let _held = acquire(dir.path(), Duration::ZERO).unwrap(); - - // The break-lock attempt is refused... - let _ = acquire_or_emit( - dir.path(), - Command::Apply, - false, - true, - false, - Duration::ZERO, - true, - ) - .unwrap_err(); - - // ...and the lock is still genuinely exclusive: a fresh acquire - // is still contended. If break-lock had stolen the lock, the - // first holder's guard would no longer be authoritative and this - // would (wrongly) succeed. assert!(matches!( acquire(dir.path(), Duration::ZERO), Err(LockError::Held) )); + drop(guard); } - /// Regression: the break-lock sequence must not open a window in - /// which a competitor can be robbed of a lock it legitimately - /// acquired. The buggy shape probed, then `remove_file`d the lock - /// file, then re-acquired: a competitor that flocked (or had merely - /// *opened*) the file before the unlink kept a valid lock on the - /// orphaned inode while the re-acquire locked a fresh one — two - /// live holders at once, the exact double-hold the probe exists to - /// prevent. The fixed shape never unlinks: the probe guard is the - /// lock. + /// Regression guard carried over from the `--break-lock` era: the + /// wrapper must never open a window in which a competitor can be + /// robbed of a lock it legitimately acquired. The historical buggy + /// shape probed, then `remove_file`d the lock file, then + /// re-acquired: a competitor that flocked (or had merely *opened*) + /// the file before the unlink kept a valid lock on the orphaned + /// inode while the re-acquire locked a fresh one — two live holders + /// at once. `acquire_or_emit` never unlinks: the acquire's guard is + /// the lock. /// /// The competitor thread increments a shared holder count only /// while it genuinely holds the OS lock, as does the main thread /// for the guard `acquire_or_emit` hands back. With real mutual /// exclusion the count can never exceed 1, so the test is - /// deterministic-green on correct code; under the buggy window the - /// hammer lands in the gap within a handful of iterations. + /// deterministic-green on correct code; under a buggy unlink window + /// the hammer lands in the gap within a handful of iterations. #[test] - fn break_lock_window_cannot_defeat_mutual_exclusion() { + fn acquire_or_emit_preserves_mutual_exclusion() { use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Arc; @@ -470,8 +259,8 @@ mod tests { let stop = Arc::new(AtomicBool::new(false)); // Competitor: grabs the lock the instant it is free, holds it - // briefly, releases, retries. Mirrors a concurrent - // `socket-patch apply` racing a `--break-lock` invocation. + // briefly, releases, retries. Mirrors two concurrent + // `socket-patch` mutating commands racing in one directory. let hammer = { let lock_dir = lock_dir.clone(); let holders = Arc::clone(&holders); @@ -495,22 +284,16 @@ mod tests { if violated.load(Ordering::SeqCst) { break; } - // silent=true so refused iterations stay quiet; refusal - // (the hammer currently holds) is a correct outcome here. - if let Ok(acquired) = acquire_or_emit( - &lock_dir, - Command::Apply, - false, - true, - false, - Duration::ZERO, - true, // break_lock - ) { + // Refusal (the hammer currently holds) is a correct + // outcome here — only a double-hold is a violation. + if let Ok(guard) = + acquire_or_emit(&lock_dir, Command::Apply, false, false, Duration::ZERO) + { if holders.fetch_add(1, Ordering::SeqCst) != 0 { violated.store(true, Ordering::SeqCst); } holders.fetch_sub(1, Ordering::SeqCst); - drop(acquired); + drop(guard); } } stop.store(true, Ordering::SeqCst); @@ -518,8 +301,8 @@ mod tests { assert!( !violated.load(Ordering::SeqCst), - "--break-lock let two processes hold the apply lock at once: \ - the lock file must never be unlinked" + "two processes held the apply lock at once: \ + the lock file must never be unlinked by the acquire path" ); } @@ -558,26 +341,6 @@ mod tests { ); } - /// Regression: the `--break-lock` pre-acquire probe is a non-blocking - /// try-once, so its `lock_held` refusal must NEVER claim a wait — even - /// when the caller passes a positive `--lock-timeout`. The earlier - /// code threaded the full `timeout` into the probe's message, so a - /// `--break-lock --lock-timeout 250ms` against a live holder reported - /// `(waited 250ms)` despite refusing immediately. The probe message is - /// now timeout-free by construction; this pins that it carries no wait - /// clause. - #[test] - fn break_probe_held_message_never_claims_a_wait() { - let msg = break_probe_held_message(); - assert!( - !msg.contains("waited"), - "break-lock probe refuses immediately and must not claim a wait: {msg}" - ); - // It is still the same identity sentence the rest of the code - // emits for contention, just without the trailing budget clause. - assert_eq!(msg, held_message(Duration::ZERO)); - } - /// The `--json` failure envelope (previously emitted only via /// `println!`, so untested) has the stable error shape downstream /// consumers pattern-match on: top-level `status: "error"` and @@ -606,18 +369,4 @@ mod tests { assert_eq!(v["dryRun"], true); assert_eq!(v["error"]["code"], "lock_io"); } - - #[test] - fn lock_broken_event_uses_documented_code() { - let dir = tempfile::tempdir().unwrap(); - let event = lock_broken_event(dir.path()); - let v: serde_json::Value = - serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap(); - assert_eq!(v["action"], "skipped"); - assert_eq!(v["errorCode"], LOCK_BROKEN_CODE); - assert!( - v.as_object().unwrap().get("purl").is_none(), - "lock_broken is an artifact-level event — no purl" - ); - } } diff --git a/crates/socket-patch-cli/src/commands/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs index d813d4ac..4df9b91d 100644 --- a/crates/socket-patch-cli/src/commands/mod.rs +++ b/crates/socket-patch-cli/src/commands/mod.rs @@ -9,6 +9,5 @@ pub(crate) mod repair_vendor; pub mod rollback; pub mod scan; pub mod setup; -pub mod unlock; pub mod vendor; pub mod vex; diff --git a/crates/socket-patch-cli/src/commands/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs index fa616778..31f627ec 100644 --- a/crates/socket-patch-cli/src/commands/remove.rs +++ b/crates/socket-patch-cli/src/commands/remove.rs @@ -13,7 +13,7 @@ use super::get::short_uuid; use super::rollback::{all_files_already_original, rollback_patches}; use super::vendor::dispatch_revert_one; use crate::args::{apply_env_toggles, GlobalArgs}; -use crate::commands::lock_cli::{acquire_or_emit, lock_broken_event}; +use crate::commands::lock_cli::acquire_or_emit; use crate::json_envelope::{Command, Envelope, EnvelopeError, PatchAction, PatchEvent, Status}; use crate::output::confirm; @@ -151,20 +151,16 @@ pub async fn run(args: RemoveArgs) -> i32 { // self-deadlock — so the outer remove invocation holds it for // both the rollback and the manifest mutation. let socket_dir = manifest_path.parent().unwrap_or(Path::new(".")); - let acquired = match acquire_or_emit( + let _lock = match acquire_or_emit( socket_dir, Command::Remove, args.common.json, - args.common.silent, args.common.dry_run, Duration::from_secs(args.common.lock_timeout.unwrap_or(0)), - args.common.break_lock, ) { - Ok(acquired) => acquired, + Ok(guard) => guard, Err(code) => return code, }; - let _lock = acquired.guard; - let lock_was_broken = acquired.broke_lock; // Read manifest to show what will be removed and confirm. On the // pure-detached path there is no manifest to read or mutate; an empty @@ -218,8 +214,6 @@ pub async fn run(args: RemoveArgs) -> i32 { &args, detached, detached_state, - lock_was_broken, - socket_dir, api_token.as_deref(), org_slug.as_deref(), ) @@ -533,9 +527,6 @@ pub async fn run(args: RemoveArgs) -> i32 { } else { PatchAction::Removed }; - if lock_was_broken { - env.record(lock_broken_event(socket_dir)); - } // Chronological: the vendor revert ran before the rollback // and the manifest mutation. Reverted events bypass // `record` so `summary.removed` stays equal to the number @@ -604,8 +595,6 @@ async fn remove_detached_only( args: &RemoveArgs, detached: Vec<(String, VendorEntry)>, mut state: VendorState, - lock_was_broken: bool, - socket_dir: &Path, api_token: Option<&str>, org_slug: Option<&str>, ) -> i32 { @@ -644,9 +633,6 @@ async fn remove_detached_only( let mut env = Envelope::new(Command::Remove); env.dry_run = args.common.dry_run; - if lock_was_broken { - env.record(lock_broken_event(socket_dir)); - } for (key, entry) in &detached { let outcome = dispatch_revert_one(entry, &args.common.cwd, args.common.dry_run).await; for w in &outcome.warnings { diff --git a/crates/socket-patch-cli/src/commands/repair.rs b/crates/socket-patch-cli/src/commands/repair.rs index 96dd4a94..c45195c0 100644 --- a/crates/socket-patch-cli/src/commands/repair.rs +++ b/crates/socket-patch-cli/src/commands/repair.rs @@ -14,7 +14,7 @@ use std::path::Path; use std::time::Duration; use crate::args::{apply_env_toggles, parse_bool_flag, GlobalArgs}; -use crate::commands::lock_cli::{acquire_or_emit, error_envelope, lock_broken_event}; +use crate::commands::lock_cli::{acquire_or_emit, error_envelope}; use crate::json_envelope::{Command, Envelope, PatchAction, PatchEvent, Status}; #[derive(Args)] @@ -127,32 +127,22 @@ pub async fn run(args: RepairArgs) -> i32 { } // Serialize against concurrent socket-patch runs targeting the - // same `.socket/` directory. See `apply_lock`. + // same `.socket/` directory. See `apply_lock`. A live holder makes + // repair refuse with `lock_held` — it never steals the lock. let socket_dir = manifest_path.parent().unwrap_or(Path::new(".")); - let acquired = match acquire_or_emit( + let lock = match acquire_or_emit( socket_dir, Command::Repair, args.common.json, - args.common.silent, args.common.dry_run, Duration::from_secs(args.common.lock_timeout.unwrap_or(0)), - args.common.break_lock, ) { - Ok(acquired) => acquired, + Ok(guard) => guard, Err(code) => return code, }; - let _lock = acquired.guard; - let lock_was_broken = acquired.broke_lock; - - match repair_inner(&args, &manifest_path).await { - Ok((mut env, counts)) => { - if lock_was_broken { - // Audit trail for `--break-lock`. Event ordering is - // documented as best-effort; appending keeps the - // `Envelope::record` invariant intact (events + summary - // stay in sync). - env.record(lock_broken_event(socket_dir)); - } + + let exit_code = match repair_inner(&args, &manifest_path).await { + Ok((env, counts)) => { // A repair where some artifacts failed to download is marked a // partial failure inside `repair_inner` (a `Failed` event plus // `mark_partial_failure`). Mirror `apply`: surface that as a @@ -195,7 +185,36 @@ pub async fn run(args: RepairArgs) -> i32 { } 1 } + }; + + // Clean slate: repair owns the lock-file cleanup (the mutating + // commands deliberately leave `apply.lock` behind between runs). + // Drop our guard FIRST so the unlink races nothing we hold, then + // best-effort delete. A live holder never reaches here — contention + // already returned above. The residual window (a competitor that + // acquires between the drop and the unlink gets its file orphaned) + // is microseconds at the tail of a finished repair and worth the + // trade; see `apply_lock`'s module doc. + drop(lock); + if !args.common.dry_run { + let lock_file = socket_dir.join("apply.lock"); + match std::fs::remove_file(&lock_file) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => { + // Housekeeping only: a leftover lock file is harmless, so + // a failed delete warns (human mode) without flipping the + // exit code of an otherwise-finished repair. + if !args.common.silent && !args.common.json { + eprintln!( + "Warning: could not remove lock file {}: {e}", + lock_file.display() + ); + } + } + } } + exit_code } /// Aggregate counts surfaced by `repair_inner` for telemetry use. diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index 65e2b0ca..15bc6646 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -16,7 +16,7 @@ use std::time::Duration; use crate::args::{apply_env_toggles, parse_bool_flag, GlobalArgs}; use crate::commands::apply::is_local_go; -use crate::commands::lock_cli::{acquire_or_emit, LOCK_BROKEN_CODE}; +use crate::commands::lock_cli::acquire_or_emit; use crate::commands::remove::patch_matches; use crate::ecosystem_dispatch::{find_packages_for_rollback, partition_purls}; use crate::json_envelope::Command as EnvelopeCommand; @@ -288,20 +288,16 @@ pub async fn run(args: RollbackArgs) -> i32 { // same `.socket/` directory. See // `socket_patch_core::patch::apply_lock`. let socket_dir = manifest_path.parent().unwrap_or(Path::new(".")); - let acquired = match acquire_or_emit( + let _lock = match acquire_or_emit( socket_dir, EnvelopeCommand::Rollback, args.common.json, - args.common.silent, args.common.dry_run, Duration::from_secs(args.common.lock_timeout.unwrap_or(0)), - args.common.break_lock, ) { - Ok(acquired) => acquired, + Ok(guard) => guard, Err(code) => return code, }; - let _lock = acquired.guard; - let lock_was_broken = acquired.broke_lock; match rollback_patches_inner(&args, &manifest_path).await { Ok((success, results, vendored)) => { @@ -316,21 +312,11 @@ pub async fn run(args: RollbackArgs) -> i32 { let failed_count = results.iter().filter(|r| !r.success).count(); if args.common.json { - // `warnings` carries non-fatal audit info — currently - // just the `lock_broken` notice when --break-lock fired. - // Empty array stays present in the JSON shape so - // consumers can rely on `.warnings[]` without - // null-checking. - let mut warnings = Vec::new(); - if lock_was_broken { - warnings.push(serde_json::json!({ - "code": LOCK_BROKEN_CODE, - "message": format!( - "--break-lock reclaimed stale {}/apply.lock (no live holder)", - socket_dir.display() - ), - })); - } + // `warnings` carries non-fatal audit info. Nothing + // populates it today (the `lock_broken` notice left with + // `--break-lock`), but the empty array stays present in + // the JSON shape so consumers can rely on `.warnings[]` + // without null-checking. println!( "{}", serde_json::to_string_pretty(&serde_json::json!({ @@ -339,7 +325,7 @@ pub async fn run(args: RollbackArgs) -> i32 { "alreadyOriginal": already_original_count, "failed": failed_count, "dryRun": args.common.dry_run, - "warnings": warnings, + "warnings": [], // Vendor-owned purls excluded from in-place rollback // (benign — `remove` or `vendor --revert` undo them). "vendored": vendored, diff --git a/crates/socket-patch-cli/src/commands/unlock.rs b/crates/socket-patch-cli/src/commands/unlock.rs deleted file mode 100644 index 6208066a..00000000 --- a/crates/socket-patch-cli/src/commands/unlock.rs +++ /dev/null @@ -1,460 +0,0 @@ -//! `socket-patch unlock` — inspect (and optionally release) the -//! `<.socket>/apply.lock` advisory file lock used by mutating -//! subcommands. -//! -//! Default behavior (no flags): probes the lock and prints -//! `status: "free" | "held"`. Returns 0 when free, 1 when held — -//! lets CI gating and monitoring tooling pattern-match the exit -//! code without parsing JSON. -//! -//! With `--release`: when the lock is free, also deletes the lock -//! file. The file is normally retained across runs (see -//! `apply_lock` docs — the inode persists so subsequent acquires -//! don't race on file creation), so `--release` exists for -//! operators who want a true clean slate. Refused when the lock is -//! held — that's the `--break-lock` flag's job on the mutating -//! subcommands, and routing the two through different verbs makes -//! the dangerous override explicit. Under the global `--dry-run` -//! flag the release is previewed, not performed: the leftover file -//! stays on disk and the JSON body carries `dryRun` + `wouldRelease` -//! instead of flipping `released`. - -use std::path::Path; -use std::time::Duration; - -use clap::Args; -use socket_patch_core::patch::apply_lock::{acquire, LockError}; -use socket_patch_core::utils::telemetry::{track_patch_unlock_failed, track_patch_unlocked}; - -use crate::args::{apply_env_toggles, parse_bool_flag, GlobalArgs}; -use crate::commands::lock_cli::error_envelope; -use crate::json_envelope::Command; - -#[derive(Args)] -pub struct UnlockArgs { - #[command(flatten)] - pub common: GlobalArgs, - - /// When the lock is free, also delete the lock file. Refused if - /// the lock is currently held — use `--break-lock` on the - /// mutating subcommand instead for that scenario. - /// - /// `value_parser = parse_bool_flag` matches the `GlobalArgs` bool - /// flags: clap's default bool parser accepts only the literal - /// strings `true`/`false` from the env binding, so - /// `SOCKET_UNLOCK_RELEASE=1` (or an exported-but-empty - /// `SOCKET_UNLOCK_RELEASE=`) aborted every `unlock` invocation. - /// (`main`'s empty-var scrub also removes a blank - /// `SOCKET_UNLOCK_RELEASE` via `LOCAL_ARG_ENV_VARS`, but the - /// parser itself must not depend on it — library callers of - /// `Cli::parse` never run the scrub.) - #[arg( - long = "release", - env = "SOCKET_UNLOCK_RELEASE", - default_value_t = false, - value_parser = parse_bool_flag, - )] - pub release: bool, -} - -pub async fn run(args: UnlockArgs) -> i32 { - apply_env_toggles(&args.common); - - // Derive the lock directory exactly like the mutating subcommands - // do (`manifest_path.parent()`) — they're the processes whose lock - // this command exists to observe. Hardcoding `/.socket` here - // would probe a directory nobody locks whenever `--manifest-path` - // points elsewhere. - let manifest_path = args.common.resolved_manifest_path(); - let socket_dir = manifest_path.parent().unwrap_or(Path::new(".")); - let lock_file = socket_dir.join("apply.lock"); - let api_token = args.common.api_token.as_deref(); - let org_slug = args.common.org.as_deref(); - - // No `.socket/` at all → treat as "free" (no one could be - // holding a lock that doesn't exist). Useful for fresh repos - // where the operator wants to confirm no stale state remains. - if !socket_dir.exists() { - // No lock to inspect → was_held=false. Nothing existed to - // remove, so `released` is false regardless of whether the - // user passed --release. Telemetry and the emitted envelope - // must agree on this. - track_patch_unlocked(false, false, api_token, org_slug).await; - return emit_free( - args.common.json, - args.common.silent, - &lock_file, - false, - false, - args.release, - args.common.dry_run, - ); - } - - // Snapshot whether a lock file already exists *before* acquiring. - // `acquire` opens the file with `create(true)`, so after the call - // the file always exists — even when the operator's tree was - // clean. To honestly report whether `--release` removed a - // pre-existing leftover (vs. a file the probe itself just - // created), we have to capture this now. - let lock_existed = lock_file.exists(); - - match acquire(socket_dir, Duration::ZERO) { - Ok(guard) => { - // We successfully claimed the lock — nobody else holds - // it. Release our handle before deleting the file so the - // delete races nothing. - drop(guard); - - let removed = if args.release && !args.common.dry_run { - match std::fs::remove_file(&lock_file) { - // `remove_file` here almost always returns `Ok` - // (the probe's `acquire` ensured the file exists), - // so we can't infer from it whether a real leftover - // was present — `lock_existed` is the source of - // truth for that. We still delete the file (the - // operator asked for a clean slate), but only claim - // we "released" something when a lock file was there - // before we probed. - Ok(()) => lock_existed, - // NotFound: the file was never created (e.g. socket - // dir existed but no run has acquired the lock yet). - // Treat as success. - Err(e) if e.kind() == std::io::ErrorKind::NotFound => false, - Err(e) => { - let msg = format!( - "failed to remove lock file at {}: {}", - lock_file.display(), - e - ); - track_patch_unlock_failed(&msg, api_token, org_slug).await; - emit_error( - args.common.json, - args.common.silent, - args.common.dry_run, - "lock_io", - &msg, - ); - return 1; - } - } - } else { - // `--dry-run` previews the release without performing it — - // the pre-existing leftover (the thing a real `--release` - // would delete) stays on disk. Deleting a file the probe - // itself just created is NOT the previewed mutation though: - // leaving it would make the dry run the only mode that - // *adds* state, so restore the tree to what we found. - if args.common.dry_run && !lock_existed { - let _ = std::fs::remove_file(&lock_file); - } - false - }; - let would_remove = args.release && args.common.dry_run && lock_existed; - track_patch_unlocked(false, removed, api_token, org_slug).await; - emit_free( - args.common.json, - args.common.silent, - &lock_file, - removed, - would_remove, - args.release, - args.common.dry_run, - ) - } - Err(LockError::Held) => { - track_patch_unlock_failed("lock held by another process", api_token, org_slug).await; - let msg = format!( - "another socket-patch process is operating in {}", - socket_dir.display() - ); - if args.common.json { - let env = error_envelope(Command::Unlock, args.common.dry_run, "lock_held", &msg); - println!("{}", env.to_pretty_json()); - } else if !args.common.silent { - eprintln!("Lock is held: {msg}."); - if args.release { - eprintln!( - " Refusing to release a held lock. Re-run the failing mutating command with --break-lock if you're sure no holder exists." - ); - } else { - eprintln!( - " Re-run the failing mutating command with --break-lock if you're sure no holder exists." - ); - } - } - 1 - } - Err(LockError::Io { path, source }) => { - let msg = format!("failed to open lock file at {}: {}", path.display(), source); - track_patch_unlock_failed(&msg, api_token, org_slug).await; - emit_error( - args.common.json, - args.common.silent, - args.common.dry_run, - "lock_io", - &msg, - ); - 1 - } - } -} - -/// Print the "free" success envelope and return exit code 0. -/// `removed` is true when `--release` actually deleted the file -/// (vs. the no-op case where the file didn't exist). `would_remove` -/// is the `--dry-run` preview of `removed`: a pre-existing leftover -/// that a real `--release` would have deleted. -/// `silent` suppresses the human-readable lines (the JSON envelope is -/// machine output and always prints) — same `--silent` contract as the -/// sibling subcommands. -fn emit_free( - json: bool, - silent: bool, - lock_file: &Path, - removed: bool, - would_remove: bool, - release: bool, - dry_run: bool, -) -> i32 { - if json { - // Build the success body by hand rather than re-using the - // shared `Envelope` shape — the `events`/`summary` fields - // don't carry useful information here, and a flat - // `{status, lockFile, ...}` is friendlier to jq pipelines. - // We still tag `command: "unlock"` so generic consumers - // can route on subcommand identity. - let mut body = serde_json::json!({ - "command": "unlock", - "status": "free", - "lockFile": lock_file.display().to_string(), - "released": removed, - }); - // `released` stays truthful on a dry run (nothing was deleted), - // so the preview rides separate additive fields — same shape as - // `setup`'s dry-run-only `dryRun`/`wouldUpdate` pair. - if dry_run { - body["dryRun"] = serde_json::json!(true); - if release { - body["wouldRelease"] = serde_json::json!(would_remove); - } - } - println!("{}", serde_json::to_string_pretty(&body).unwrap()); - } else if silent { - // Suppress the informational lines; the exit code carries the verdict. - } else if release && removed { - println!("Lock is free. Removed {}.", lock_file.display()); - } else if would_remove { - println!( - "Lock is free. Would remove {} (dry run).", - lock_file.display() - ); - } else if release { - println!("Lock is free (no lock file to remove)."); - } else { - println!("Lock is free."); - } - 0 -} - -fn emit_error(json: bool, silent: bool, dry_run: bool, code: &str, message: &str) { - if json { - let env = error_envelope(Command::Unlock, dry_run, code, message); - println!("{}", env.to_pretty_json()); - } else if !silent { - eprintln!("Error: {message}."); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use socket_patch_core::patch::apply_lock::acquire as core_acquire; - - /// Build a `UnlockArgs` rooted at a tempdir for the test. - fn args_in(cwd: &Path, release: bool) -> UnlockArgs { - UnlockArgs { - common: GlobalArgs { - cwd: cwd.to_path_buf(), - json: true, // exercise the JSON path in unit tests - silent: true, - ..GlobalArgs::default() - }, - release, - } - } - - /// No `.socket/` directory at all → report `free`, exit 0. - /// Mirrors what a fresh `git clone` looks like. - #[tokio::test] - async fn run_reports_free_when_socket_dir_missing() { - let dir = tempfile::tempdir().unwrap(); - let code = run(args_in(dir.path(), false)).await; - assert_eq!(code, 0); - } - - /// `.socket/` exists but no run has taken the lock yet — still - /// `free`. We exercise this by creating the directory ourselves. - #[tokio::test] - async fn run_reports_free_when_socket_dir_clean() { - let dir = tempfile::tempdir().unwrap(); - std::fs::create_dir_all(dir.path().join(".socket")).unwrap(); - let code = run(args_in(dir.path(), false)).await; - assert_eq!(code, 0); - } - - /// A stale lock *file* left on disk by a crashed run — with **no** - /// live OS holder — must read as `free` (exit 0), and a plain probe - /// must leave that file in place. Guards against a regression where - /// the verdict keys off `apply.lock` merely *existing* rather than - /// off a live advisory lock. (The e2e suite proves this via a - /// release-then-reprobe; this pins it at the unit level too.) - #[tokio::test] - async fn run_reports_free_when_stale_lock_file_present_but_not_held() { - let dir = tempfile::tempdir().unwrap(); - let socket_dir = dir.path().join(".socket"); - std::fs::create_dir_all(&socket_dir).unwrap(); - // Leftover file, but nobody holds the OS lock. - std::fs::write(socket_dir.join("apply.lock"), b"").unwrap(); - - let code = run(args_in(dir.path(), false)).await; - assert_eq!(code, 0, "an unheld leftover lock file must read as free"); - assert!( - socket_dir.join("apply.lock").is_file(), - "a plain (no --release) probe must not delete the file" - ); - } - - /// Active holder (via core `acquire`) → `unlock` reports - /// `held`, exits 1, and the file remains on disk. - #[tokio::test] - async fn run_reports_held_when_lock_actively_held() { - let dir = tempfile::tempdir().unwrap(); - let socket_dir = dir.path().join(".socket"); - std::fs::create_dir_all(&socket_dir).unwrap(); - - // Hold the lock for the duration of this test. `_guard` is - // bound so its drop doesn't fire until function return. - let _guard = core_acquire(&socket_dir, Duration::ZERO).unwrap(); - - let code = run(args_in(dir.path(), false)).await; - assert_eq!(code, 1); - assert!(socket_dir.join("apply.lock").is_file()); - } - - /// `--release` against a free lock with a leftover file removes - /// the file. - #[tokio::test] - async fn run_deletes_lock_file_when_release_and_free() { - let dir = tempfile::tempdir().unwrap(); - let socket_dir = dir.path().join(".socket"); - std::fs::create_dir_all(&socket_dir).unwrap(); - std::fs::write(socket_dir.join("apply.lock"), b"").unwrap(); - assert!(socket_dir.join("apply.lock").is_file()); - - let code = run(args_in(dir.path(), true)).await; - assert_eq!(code, 0); - assert!( - !socket_dir.join("apply.lock").exists(), - "--release should have deleted the file" - ); - } - - /// `--release` against a clean `.socket/` (no pre-existing lock - /// file) succeeds, and does not leave behind the file that the - /// probe's `acquire` created on demand. Guards the regression - /// where the probe-created file masqueraded as a released - /// leftover. - #[tokio::test] - async fn run_release_cleans_up_probe_created_file() { - let dir = tempfile::tempdir().unwrap(); - let socket_dir = dir.path().join(".socket"); - std::fs::create_dir_all(&socket_dir).unwrap(); - assert!(!socket_dir.join("apply.lock").exists()); - - let code = run(args_in(dir.path(), true)).await; - assert_eq!(code, 0); - assert!( - !socket_dir.join("apply.lock").exists(), - "--release must not leave a probe-created lock file behind" - ); - } - - /// `--release` against a stale (unheld) leftover removes it and - /// exits 0 — the recovery path. Distinct from - /// `run_deletes_lock_file_when_release_and_free` only in intent - /// (post-crash leftover), but kept as a named guard so the - /// stale-file recovery contract is explicit. - #[tokio::test] - async fn run_release_removes_stale_unheld_lock_file() { - let dir = tempfile::tempdir().unwrap(); - let socket_dir = dir.path().join(".socket"); - std::fs::create_dir_all(&socket_dir).unwrap(); - std::fs::write(socket_dir.join("apply.lock"), b"crashed-run-leftover").unwrap(); - - let code = run(args_in(dir.path(), true)).await; - assert_eq!(code, 0); - assert!( - !socket_dir.join("apply.lock").exists(), - "--release must remove an unheld stale lock file" - ); - } - - /// `--release --dry-run` previews the release without performing - /// it: the pre-existing leftover file must survive. Regression - /// guard: `run` never read `common.dry_run`, so the global - /// `--dry-run` ("Preview, no mutations") flag was silently ignored - /// and the file was deleted anyway. - #[tokio::test] - async fn run_release_dry_run_keeps_leftover_lock_file() { - let dir = tempfile::tempdir().unwrap(); - let socket_dir = dir.path().join(".socket"); - std::fs::create_dir_all(&socket_dir).unwrap(); - std::fs::write(socket_dir.join("apply.lock"), b"crashed-run-leftover").unwrap(); - - let mut args = args_in(dir.path(), true); - args.common.dry_run = true; - let code = run(args).await; - assert_eq!(code, 0); - assert!( - socket_dir.join("apply.lock").is_file(), - "--dry-run must not delete the leftover lock file" - ); - } - - /// `--release --dry-run` against a clean `.socket/` must not leave - /// the probe-created file behind — a dry run may not *add* state - /// either. Companion to `run_release_cleans_up_probe_created_file`. - #[tokio::test] - async fn run_release_dry_run_leaves_no_probe_created_file() { - let dir = tempfile::tempdir().unwrap(); - let socket_dir = dir.path().join(".socket"); - std::fs::create_dir_all(&socket_dir).unwrap(); - assert!(!socket_dir.join("apply.lock").exists()); - - let mut args = args_in(dir.path(), true); - args.common.dry_run = true; - let code = run(args).await; - assert_eq!(code, 0); - assert!( - !socket_dir.join("apply.lock").exists(), - "--release --dry-run must not leave a probe-created lock file behind" - ); - } - - /// `--release` against a HELD lock refuses (exit 1), file stays. - #[tokio::test] - async fn run_refuses_release_when_held() { - let dir = tempfile::tempdir().unwrap(); - let socket_dir = dir.path().join(".socket"); - std::fs::create_dir_all(&socket_dir).unwrap(); - let _guard = core_acquire(&socket_dir, Duration::ZERO).unwrap(); - - let code = run(args_in(dir.path(), true)).await; - assert_eq!(code, 1); - assert!( - socket_dir.join("apply.lock").is_file(), - "lock file should still exist — --release must refuse when held" - ); - } -} diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index b2a284bf..b5d1854e 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -38,7 +38,7 @@ use std::time::Duration; use crate::args::{apply_env_toggles, GlobalArgs}; use crate::commands::apply::{result_to_event, variant_matches_installed}; use crate::commands::fetch_stage::{stage_vendor_sources_in_memory, MemStageOutcome}; -use crate::commands::lock_cli::{acquire_or_emit, lock_broken_event}; +use crate::commands::lock_cli::acquire_or_emit; use crate::commands::vex::{generate_vex_from_manifest_path, VexEmbedArgs}; use crate::ecosystem_dispatch::{find_packages_for_purls, partition_purls}; use crate::json_envelope::{ @@ -297,26 +297,19 @@ pub async fn run(args: VendorArgs) -> i32 { // Same lock as apply/rollback: vendor mutates the same lockfiles and // `.socket/` tree, so a separate lock would allow an apply↔vendor race. - let acquired = match acquire_or_emit( + let _lock = match acquire_or_emit( &socket_dir, Command::Vendor, args.common.json, - args.common.silent, args.common.dry_run, Duration::from_secs(args.common.lock_timeout.unwrap_or(0)), - args.common.break_lock, ) { - Ok(acquired) => acquired, + Ok(guard) => guard, Err(code) => return code, }; - let _lock = acquired.guard; - let lock_was_broken = acquired.broke_lock; let mut env = Envelope::new(Command::Vendor); env.dry_run = args.common.dry_run; - if lock_was_broken { - env.record(lock_broken_event(&socket_dir)); - } let mut exit = if args.revert { run_revert(&args, &mut env).await diff --git a/crates/socket-patch-cli/src/json_envelope.rs b/crates/socket-patch-cli/src/json_envelope.rs index 6490710f..034c6be7 100644 --- a/crates/socket-patch-cli/src/json_envelope.rs +++ b/crates/socket-patch-cli/src/json_envelope.rs @@ -343,17 +343,16 @@ impl AppliedVia { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub enum Command { + Scan, Apply, + Vex, + Vendor, + Setup, Rollback, Get, - Scan, List, Remove, Repair, - Setup, - Unlock, - Vendor, - Vex, } /// Top-level status. Serializes camelCase. @@ -847,17 +846,16 @@ mod tests { // `rename_all` arm can't silently change what `command` a // consumer routes on. for (command, tag) in [ + (Command::Scan, "scan"), (Command::Apply, "apply"), + (Command::Vex, "vex"), + (Command::Vendor, "vendor"), + (Command::Setup, "setup"), (Command::Rollback, "rollback"), (Command::Get, "get"), - (Command::Scan, "scan"), (Command::List, "list"), (Command::Remove, "remove"), (Command::Repair, "repair"), - (Command::Setup, "setup"), - (Command::Unlock, "unlock"), - (Command::Vendor, "vendor"), - (Command::Vex, "vex"), ] { let serialized = serde_json::to_string(&command).unwrap(); assert_eq!(serialized, format!("\"{tag}\""), "Command::{command:?}"); diff --git a/crates/socket-patch-cli/src/lib.rs b/crates/socket-patch-cli/src/lib.rs index fb354f0d..8d73b44b 100644 --- a/crates/socket-patch-cli/src/lib.rs +++ b/crates/socket-patch-cli/src/lib.rs @@ -31,9 +31,24 @@ pub struct Cli { #[derive(Subcommand)] pub enum Commands { + /// Scan installed packages for available security patches + Scan(commands::scan::ScanArgs), + /// Apply security patches to dependencies Apply(commands::apply::ApplyArgs), + /// Generate an OpenVEX 0.2.0 attestation describing the + /// vulnerabilities mitigated by the applied patches. + Vex(commands::vex::VexArgs), + + /// Eject patched dependencies into committable `.socket/vendor/` + /// and rewire lockfiles so fresh checkouts build with the patches + /// (no socket-patch or Socket API needed). `--revert` undoes it. + Vendor(commands::vendor::VendorArgs), + + /// Configure package.json postinstall scripts to apply patches + Setup(commands::setup::SetupArgs), + /// Rollback patches to restore original files Rollback(commands::rollback::RollbackArgs), @@ -41,19 +56,14 @@ pub enum Commands { #[command(visible_alias = "download")] Get(commands::get::GetArgs), - /// Scan installed packages for available security patches - Scan(commands::scan::ScanArgs), - /// List all patches in the local manifest List(commands::list::ListArgs), /// Remove a patch from the manifest by PURL or UUID (rolls back files first) Remove(commands::remove::RemoveArgs), - /// Configure package.json postinstall scripts to apply patches - Setup(commands::setup::SetupArgs), - - /// Download missing blobs and clean up unused blobs. + /// Download missing blobs, clean up unused blobs, and reset the + /// advisory lock state. /// /// `repair` (alias `gc`) is a first-class command for cleaning up /// the `.socket/` directory without running a scan. For the @@ -62,21 +72,6 @@ pub enum Commands { /// their own when the user wants to clean up without an apply pass. #[command(visible_alias = "gc")] Repair(commands::repair::RepairArgs), - - /// Inspect (and optionally release) the `<.socket>/apply.lock` - /// advisory file lock used by mutating subcommands. Exits 0 - /// when free, 1 when held. Pass `--release` to also delete the - /// lock file when it is free. - Unlock(commands::unlock::UnlockArgs), - - /// Eject patched dependencies into committable `.socket/vendor/` - /// and rewire lockfiles so fresh checkouts build with the patches - /// (no socket-patch or Socket API needed). `--revert` undoes it. - Vendor(commands::vendor::VendorArgs), - - /// Generate an OpenVEX 0.2.0 attestation describing the - /// vulnerabilities mitigated by the applied patches. - Vex(commands::vex::VexArgs), } /// Check whether `s` looks like a UUID (8-4-4-4-12 hex pattern). diff --git a/crates/socket-patch-cli/src/main.rs b/crates/socket-patch-cli/src/main.rs index e824e981..ab03fb26 100644 --- a/crates/socket-patch-cli/src/main.rs +++ b/crates/socket-patch-cli/src/main.rs @@ -57,17 +57,16 @@ async fn main() { }; let exit_code = match cli.command { + Commands::Scan(args) => commands::scan::run(args).await, Commands::Apply(args) => commands::apply::run(args).await, + Commands::Vex(args) => commands::vex::run(args).await, + Commands::Vendor(args) => commands::vendor::run(args).await, + Commands::Setup(args) => commands::setup::run(args).await, Commands::Rollback(args) => commands::rollback::run(args).await, Commands::Get(args) => commands::get::run(args).await, - Commands::Scan(args) => commands::scan::run(args).await, Commands::List(args) => commands::list::run(args).await, Commands::Remove(args) => commands::remove::run(args).await, - Commands::Setup(args) => commands::setup::run(args).await, Commands::Repair(args) => commands::repair::run(args).await, - Commands::Unlock(args) => commands::unlock::run(args).await, - Commands::Vendor(args) => commands::vendor::run(args).await, - Commands::Vex(args) => commands::vex::run(args).await, }; std::process::exit(exit_code); diff --git a/crates/socket-patch-cli/tests/cli_global_args.rs b/crates/socket-patch-cli/tests/cli_global_args.rs index 067f6a92..90bad4b6 100644 --- a/crates/socket-patch-cli/tests/cli_global_args.rs +++ b/crates/socket-patch-cli/tests/cli_global_args.rs @@ -31,7 +31,7 @@ use socket_patch_cli::Cli; /// being listed here — closing the "someone forgot the flatten on a new /// command and nobody noticed" gap this file claims to guard. const SUBCOMMANDS_NO_POSITIONAL: &[&str] = &[ - "apply", "list", "scan", "setup", "repair", "rollback", "unlock", "vendor", "vex", + "apply", "list", "scan", "setup", "repair", "rollback", "vendor", "vex", ]; /// Subcommands that require a positional identifier. @@ -98,7 +98,6 @@ fn global_flag_cases() -> Vec<(&'static str, Option<&'static str>, fn(&GlobalArg ("--yes", None, |c| assert!(c.yes)), ("--debug", None, |c| assert!(c.debug)), ("--no-telemetry", None, |c| assert!(c.no_telemetry)), - ("--break-lock", None, |c| assert!(c.break_lock)), ("--lock-timeout", Some("30"), |c| { assert_eq!(c.lock_timeout, Some(30)) }), @@ -119,7 +118,6 @@ fn common_of(cli: &Cli) -> &GlobalArgs { Remove(a) => &a.common, Setup(a) => &a.common, Repair(a) => &a.common, - Unlock(a) => &a.common, Vendor(a) => &a.common, Vex(a) => &a.common, } @@ -209,7 +207,6 @@ fn global_flag_cases_cover_every_global_field() { dry_run: _, yes: _, lock_timeout: _, - break_lock: _, debug: _, no_telemetry: _, strict: _, @@ -218,11 +215,11 @@ fn global_flag_cases_cover_every_global_field() { patch_server_url: _, } = common; - // 24 fields ↔ 24 long-flag cases. Bump both this count and add a case when + // 23 fields ↔ 23 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(), - 24, + 23, "every GlobalArgs field needs a long-flag case in global_flag_cases()", ); @@ -435,7 +432,6 @@ fn env_vars_populate_global_args() { ("SOCKET_DRY_RUN", "true"), ("SOCKET_YES", "true"), ("SOCKET_LOCK_TIMEOUT", "30"), - ("SOCKET_BREAK_LOCK", "true"), ("SOCKET_DEBUG", "true"), ("SOCKET_TELEMETRY_DISABLED", "true"), ]; @@ -486,7 +482,6 @@ fn env_vars_populate_global_args() { assert!(args.common.dry_run); assert!(args.common.yes); assert_eq!(args.common.lock_timeout, Some(30)); - assert!(args.common.break_lock); assert!(args.common.debug); assert!(args.common.no_telemetry); } else { @@ -526,7 +521,6 @@ fn bool_env_vars_accept_one_and_yes() { ("SOCKET_SILENT", "y"), ("SOCKET_DRY_RUN", "1"), ("SOCKET_YES", "yes"), - ("SOCKET_BREAK_LOCK", "1"), ("SOCKET_DEBUG", "1"), ("SOCKET_TELEMETRY_DISABLED", "1"), ]; @@ -549,10 +543,6 @@ fn bool_env_vars_accept_one_and_yes() { assert!(args.common.silent, "SOCKET_SILENT=y must parse as true"); assert!(args.common.dry_run, "SOCKET_DRY_RUN=1 must parse as true"); assert!(args.common.yes, "SOCKET_YES=yes must parse as true"); - assert!( - args.common.break_lock, - "SOCKET_BREAK_LOCK=1 must parse as true" - ); assert!(args.common.debug, "SOCKET_DEBUG=1 must parse as true"); assert!( args.common.no_telemetry, @@ -642,7 +632,7 @@ fn bool_env_vars_reject_zero_and_falsey() { #[serial_test::serial] fn empty_bool_env_var_resolves_to_false_not_crash() { // (env var, accessor) for every boolean global. - let bool_vars: [(&str, fn(&GlobalArgs) -> bool); 11] = [ + let bool_vars: [(&str, fn(&GlobalArgs) -> bool); 10] = [ ("SOCKET_OFFLINE", |c| c.offline), ("SOCKET_STRICT", |c| c.strict), ("SOCKET_GLOBAL", |c| c.global), @@ -651,7 +641,6 @@ fn empty_bool_env_var_resolves_to_false_not_crash() { ("SOCKET_SILENT", |c| c.silent), ("SOCKET_DRY_RUN", |c| c.dry_run), ("SOCKET_YES", |c| c.yes), - ("SOCKET_BREAK_LOCK", |c| c.break_lock), ("SOCKET_DEBUG", |c| c.debug), ("SOCKET_TELEMETRY_DISABLED", |c| c.no_telemetry), ]; @@ -889,7 +878,7 @@ fn production_defaults_populate_when_unset() { assert!(c.ecosystems.is_none()); assert!(!c.strict); assert!(!c.offline && !c.global && !c.json && !c.verbose && !c.silent); - assert!(!c.dry_run && !c.yes && !c.break_lock && !c.debug && !c.no_telemetry); + assert!(!c.dry_run && !c.yes && !c.debug && !c.no_telemetry); assert!(c.lock_timeout.is_none()); assert!(c.global_prefix.is_none()); diff --git a/crates/socket-patch-cli/tests/cli_parse_apply.rs b/crates/socket-patch-cli/tests/cli_parse_apply.rs index 0add2674..2fafefd4 100644 --- a/crates/socket-patch-cli/tests/cli_parse_apply.rs +++ b/crates/socket-patch-cli/tests/cli_parse_apply.rs @@ -29,7 +29,7 @@ fn parse_apply(extra: &[&str]) -> ApplyArgs { /// Used to prove that a single flag flips *only* its own field — without /// this, each positive test ignores all other fields, so a parser bug that /// cross-wired `--yes` into `--force` (auto-approve → silently bypass the -/// beforeHash check) or any flag into `--break-lock` / `--global` would still +/// beforeHash check) or any flag into `--global` would still /// stay green. Keep this in sync with the boolean flags in the contract. fn bool_flags(a: &ApplyArgs) -> Vec<(&'static str, bool)> { vec![ @@ -42,7 +42,6 @@ fn bool_flags(a: &ApplyArgs) -> Vec<(&'static str, bool)> { ("yes", a.common.yes), ("debug", a.common.debug), ("no_telemetry", a.common.no_telemetry), - ("break_lock", a.common.break_lock), ("force", a.force), ("check", a.check), ("vex_no_verify", a.vex.vex_no_verify), @@ -87,8 +86,9 @@ fn defaults_match_contract() { // The remaining global defaults from the contract table. These were // previously unpinned, which let a dangerous default-value drift slip - // through silently — e.g. `--break-lock` defaulting to `true` would make - // `apply` steal a live lock, or the API/proxy URLs silently retargeting. + // through silently — e.g. `--yes` defaulting to `true` would make + // `apply` auto-approve every prompt, or the API/proxy URLs silently + // retargeting. assert_eq!(a.common.api_url, "https://api.socket.dev"); assert_eq!(a.common.api_token, None); assert_eq!(a.common.org, None); @@ -96,7 +96,6 @@ fn defaults_match_contract() { assert!(!a.common.yes); assert!(!a.common.debug); assert!(!a.common.no_telemetry); - assert!(!a.common.break_lock); assert_eq!(a.common.lock_timeout, None); // `apply --check` is read-only audit mode. It MUST default off, otherwise @@ -298,13 +297,6 @@ fn no_telemetry_long() { assert_only_true(&a, &["no_telemetry"]); } -#[test] -fn break_lock_long() { - let a = parse_apply(&["--break-lock"]); - assert!(a.common.break_lock); - assert_only_true(&a, &["break_lock"]); -} - /// Bare boolean flags are `SetTrue` (num_args = 0): they must NOT swallow the /// following token as a value. If `--force` silently became value-taking, a /// wrapper invoking `apply --force ` would change meaning. Assert @@ -332,7 +324,6 @@ fn all_bools_settable_together() { "--yes", "--debug", "--no-telemetry", - "--break-lock", "--force", "--check", ]); @@ -348,7 +339,6 @@ fn all_bools_settable_together() { "yes", "debug", "no_telemetry", - "break_lock", "force", "check", ], diff --git a/crates/socket-patch-cli/tests/cli_parse_get.rs b/crates/socket-patch-cli/tests/cli_parse_get.rs index c229608e..020f4651 100644 --- a/crates/socket-patch-cli/tests/cli_parse_get.rs +++ b/crates/socket-patch-cli/tests/cli_parse_get.rs @@ -52,7 +52,6 @@ const SOCKET_ENV_VARS: &[&str] = &[ "SOCKET_DRY_RUN", "SOCKET_YES", "SOCKET_LOCK_TIMEOUT", - "SOCKET_BREAK_LOCK", "SOCKET_DEBUG", "SOCKET_TELEMETRY_DISABLED", // GetArgs-specific @@ -145,7 +144,6 @@ struct Snap { dry_run: bool, yes: bool, lock_timeout: Option, - break_lock: bool, debug: bool, no_telemetry: bool, id: bool, @@ -181,7 +179,6 @@ fn snapshot(a: &GetArgs) -> Snap { dry_run: a.common.dry_run, yes: a.common.yes, lock_timeout: a.common.lock_timeout, - break_lock: a.common.break_lock, debug: a.common.debug, no_telemetry: a.common.no_telemetry, id: a.id, @@ -225,7 +222,6 @@ fn expected_defaults(identifier: &str) -> Snap { dry_run: false, yes: false, lock_timeout: None, - break_lock: false, debug: false, no_telemetry: false, id: false, @@ -246,7 +242,7 @@ fn defaults_with_only_required_identifier() { let a = parse_get(&["some-id"]); // Pin the *entire* default surface in one shot against the independent // oracle. This covers fields the old test silently skipped (manifest_path, - // proxy_url, offline, verbose, silent, dry_run, lock_timeout, break_lock, + // proxy_url, offline, verbose, silent, dry_run, lock_timeout, // debug, no_telemetry, ecosystems) — any of which could regress to a // non-default and go unnoticed under a field-cherry-picked assertion. assert_eq!(snapshot(&a), expected_defaults("some-id")); diff --git a/crates/socket-patch-cli/tests/cli_parse_main.rs b/crates/socket-patch-cli/tests/cli_parse_main.rs index e0f9c76f..d4b2b81d 100644 --- a/crates/socket-patch-cli/tests/cli_parse_main.rs +++ b/crates/socket-patch-cli/tests/cli_parse_main.rs @@ -76,7 +76,7 @@ fn help_flag_triggers_display_help() { // listed in the rendered help. let help = err.to_string(); for name in [ - "apply", "rollback", "get", "scan", "list", "remove", "setup", "repair", "unlock", "vex", + "scan", "apply", "vex", "vendor", "setup", "rollback", "get", "list", "remove", "repair", ] { assert!( help.contains(name), @@ -167,17 +167,16 @@ fn repair_subcommand_parses() { } #[test] -fn unlock_subcommand_parses() { - // `unlock` is one of the two newest subcommands and the second-to-last - // arm in main.rs's dispatch match — keep its name + dispatch wiring - // covered alongside the older commands. - let cli = parse(&["socket-patch", "unlock"]).expect("unlock must parse with no positional"); - assert!(matches!(cli.command, Commands::Unlock(_))); +fn unlock_subcommand_is_removed() { + // BREAKING (4.0): the `unlock` subcommand was folded into `repair` + // (which now deletes the leftover `apply.lock` after finishing). + // Pin the removal so the name can't quietly come back half-wired. + let err = expect_err(parse(&["socket-patch", "unlock"])); + assert_eq!(err.kind(), clap::error::ErrorKind::InvalidSubcommand); } #[test] fn vex_subcommand_parses() { - // `vex` is the last arm in main.rs's dispatch match; lock its name in. let cli = parse(&["socket-patch", "vex"]).expect("vex must parse with no positional"); assert!(matches!(cli.command, Commands::Vex(_))); } diff --git a/crates/socket-patch-cli/tests/cli_parse_repair.rs b/crates/socket-patch-cli/tests/cli_parse_repair.rs index f03bda68..81d7e7e9 100644 --- a/crates/socket-patch-cli/tests/cli_parse_repair.rs +++ b/crates/socket-patch-cli/tests/cli_parse_repair.rs @@ -59,7 +59,6 @@ const SOCKET_ENV_VARS: &[&str] = &[ "SOCKET_DRY_RUN", "SOCKET_YES", "SOCKET_LOCK_TIMEOUT", - "SOCKET_BREAK_LOCK", "SOCKET_DEBUG", "SOCKET_TELEMETRY_DISABLED", // RepairArgs-specific @@ -155,7 +154,6 @@ struct Snap { dry_run: bool, yes: bool, lock_timeout: Option, - break_lock: bool, debug: bool, no_telemetry: bool, download_only: bool, @@ -184,7 +182,6 @@ fn snapshot(a: &RepairArgs) -> Snap { dry_run: a.common.dry_run, yes: a.common.yes, lock_timeout: a.common.lock_timeout, - break_lock: a.common.break_lock, debug: a.common.debug, no_telemetry: a.common.no_telemetry, download_only: a.download_only, @@ -220,7 +217,6 @@ fn expected_defaults() -> Snap { dry_run: false, yes: false, lock_timeout: None, - break_lock: false, debug: false, no_telemetry: false, download_only: false, @@ -235,7 +231,7 @@ fn repair_defaults_match_contract() { // Pin the *entire* default surface in one shot against the independent // oracle. The previous version only checked download_mode, cwd, // manifest_path, dry_run, offline, download_only and json — leaving - // api_url, proxy_url, verbose, silent, yes, lock_timeout, break_lock, + // api_url, proxy_url, verbose, silent, yes, lock_timeout, // debug, no_telemetry, global, global_prefix, ecosystems, api_token and // org free to regress unnoticed. assert_eq!(snapshot(&args), expected_defaults()); diff --git a/crates/socket-patch-cli/tests/cli_parse_rollback.rs b/crates/socket-patch-cli/tests/cli_parse_rollback.rs index f0d582f6..5616a80d 100644 --- a/crates/socket-patch-cli/tests/cli_parse_rollback.rs +++ b/crates/socket-patch-cli/tests/cli_parse_rollback.rs @@ -25,8 +25,8 @@ fn parse_rollback(extra: &[&str]) -> RollbackArgs { /// Every boolean toggle on `rollback`, as `(contract name, current value)`. /// Used to prove that a single flag flips *only* its own field — without this, /// each positive test ignores all other fields, so a parser bug that -/// cross-wired e.g. `--one-off` into `--global`, `--silent` into `--break-lock` -/// (stealing a live lock), or any flag into another would still stay green. +/// cross-wired e.g. `--one-off` into `--global`, `--silent` into `--yes` +/// (auto-approving prompts), or any flag into another would still stay green. /// Keep this in sync with the boolean flags in the contract. fn bool_flags(a: &RollbackArgs) -> Vec<(&'static str, bool)> { vec![ @@ -39,7 +39,6 @@ fn bool_flags(a: &RollbackArgs) -> Vec<(&'static str, bool)> { ("yes", a.common.yes), ("debug", a.common.debug), ("no_telemetry", a.common.no_telemetry), - ("break_lock", a.common.break_lock), ("one_off", a.one_off), ] } @@ -82,7 +81,6 @@ fn defaults_no_positional() { assert_eq!(args.common.download_mode, "diff"); assert!(!args.common.yes); assert_eq!(args.common.lock_timeout, None); - assert!(!args.common.break_lock); assert!(!args.common.debug); assert!(!args.common.no_telemetry); // Belt-and-suspenders: with no args, NO boolean toggle may be on. @@ -285,13 +283,6 @@ fn lock_timeout_long() { assert_eq!(args.common.lock_timeout, Some(30)); } -#[test] -fn break_lock_long() { - let args = parse_rollback(&["--break-lock"]); - assert!(args.common.break_lock); - assert_only_true(&args, &["break_lock"]); -} - #[test] fn debug_long() { let args = parse_rollback(&["--debug"]); @@ -321,7 +312,6 @@ fn all_bools_settable_together() { "--yes", "--debug", "--no-telemetry", - "--break-lock", "--one-off", ]); assert_only_true( @@ -336,7 +326,6 @@ fn all_bools_settable_together() { "yes", "debug", "no_telemetry", - "break_lock", "one_off", ], ); diff --git a/crates/socket-patch-cli/tests/cli_parse_scan.rs b/crates/socket-patch-cli/tests/cli_parse_scan.rs index 6edfd6bb..ada7de39 100644 --- a/crates/socket-patch-cli/tests/cli_parse_scan.rs +++ b/crates/socket-patch-cli/tests/cli_parse_scan.rs @@ -32,7 +32,6 @@ const SCAN_ENV_VARS: &[&str] = &[ "SOCKET_API_TOKEN", "SOCKET_API_URL", "SOCKET_BATCH_SIZE", - "SOCKET_BREAK_LOCK", "SOCKET_CWD", "SOCKET_DEBUG", "SOCKET_DOWNLOAD_MODE", diff --git a/crates/socket-patch-cli/tests/cli_parse_unlock.rs b/crates/socket-patch-cli/tests/cli_parse_unlock.rs deleted file mode 100644 index 3cbc6cb3..00000000 --- a/crates/socket-patch-cli/tests/cli_parse_unlock.rs +++ /dev/null @@ -1,167 +0,0 @@ -//! CLI contract tests for the `unlock` subcommand's parse surface. -//! -//! Focus: the `--release` / `SOCKET_UNLOCK_RELEASE` env binding. -//! Regression guard: the flag shipped without `value_parser = -//! parse_bool_flag`, so clap's default bool parser accepted only the -//! literal strings `true`/`false` from the env — `SOCKET_UNLOCK_RELEASE=1` -//! (or an exported-but-empty `SOCKET_UNLOCK_RELEASE=`) aborted every -//! `unlock` invocation with a ValueValidation error. (`main`'s -//! empty-var scrub also removes a blank `SOCKET_UNLOCK_RELEASE` via -//! `LOCAL_ARG_ENV_VARS`, but the parser itself must not depend on it — -//! library callers of `Cli::parse` never run the scrub, as these -//! tests' direct `try_parse_from` shows.) Same bug class previously -//! fixed on `repair --download-only` and `rollback --one-off`. -//! -//! ## Hermeticity -//! -//! Mirrors `cli_parse_repair.rs`: every parse runs with the full -//! `SOCKET_*` surface scrubbed (see [`EnvScrub`]) and every test is -//! `#[serial_test::serial]` so the process-global env mutation can't -//! race a concurrent parse. - -use clap::Parser; -use socket_patch_cli::{Cli, Commands}; - -/// Every `SOCKET_*` env var that clap consults while parsing `unlock` -/// (its own `--release` flag plus the flattened `GlobalArgs`). -const SOCKET_ENV_VARS: &[&str] = &[ - // GlobalArgs - "SOCKET_CWD", - "SOCKET_MANIFEST_PATH", - "SOCKET_API_URL", - "SOCKET_API_TOKEN", - "SOCKET_ORG_SLUG", - "SOCKET_PROXY_URL", - "SOCKET_ECOSYSTEMS", - "SOCKET_DOWNLOAD_MODE", - "SOCKET_VENDOR_SOURCE", - "SOCKET_VENDOR_URL", - "SOCKET_PATCH_SERVER_URL", - "SOCKET_OFFLINE", - "SOCKET_STRICT", - "SOCKET_GLOBAL", - "SOCKET_GLOBAL_PREFIX", - "SOCKET_JSON", - "SOCKET_VERBOSE", - "SOCKET_SILENT", - "SOCKET_DRY_RUN", - "SOCKET_YES", - "SOCKET_LOCK_TIMEOUT", - "SOCKET_BREAK_LOCK", - "SOCKET_DEBUG", - "SOCKET_TELEMETRY_DISABLED", - // UnlockArgs-specific - "SOCKET_UNLOCK_RELEASE", -]; - -/// RAII guard that removes every [`SOCKET_ENV_VARS`] entry on -/// construction and restores the prior value on drop. Pair with -/// `#[serial_test::serial]` so the global env mutation never races -/// another test. -struct EnvScrub(Vec<(&'static str, Option)>); - -impl EnvScrub { - fn new() -> Self { - let saved = SOCKET_ENV_VARS - .iter() - .map(|&k| { - let prev = std::env::var(k).ok(); - std::env::remove_var(k); - (k, prev) - }) - .collect(); - EnvScrub(saved) - } -} - -impl Drop for EnvScrub { - fn drop(&mut self) { - for (k, v) in &self.0 { - match v { - Some(val) => std::env::set_var(k, val), - None => std::env::remove_var(k), - } - } - } -} - -fn release_of(cli: Cli) -> bool { - match cli.command { - Commands::Unlock(a) => a.release, - _ => panic!("expected Unlock"), - } -} - -/// Drift guard for [`SOCKET_ENV_VARS`]: parsing `unlock` consults every -/// flattened `GlobalArgs` env binding, so the scrub list must cover all -/// of `GLOBAL_ARG_ENV_VARS` (plus the subcommand-local -/// `SOCKET_UNLOCK_RELEASE`). Regression: the hand-rolled list omitted -/// `SOCKET_VENDOR_SOURCE` / `SOCKET_VENDOR_URL` / -/// `SOCKET_PATCH_SERVER_URL` / `SOCKET_STRICT`, so an ambient -/// `SOCKET_VENDOR_SOURCE=bogus` in the developer's shell or CI aborted -/// every parse in this file — spuriously failing all four tests. -#[test] -fn env_scrub_covers_every_global_args_env_var() { - for var in socket_patch_cli::args::GLOBAL_ARG_ENV_VARS { - assert!( - SOCKET_ENV_VARS.contains(var), - "SOCKET_ENV_VARS is missing the GlobalArgs binding {var}; \ - an ambient {var} would leak into every parse in this file" - ); - } - assert!( - SOCKET_ENV_VARS.contains(&"SOCKET_UNLOCK_RELEASE"), - "SOCKET_ENV_VARS must scrub unlock's own env binding" - ); -} - -#[test] -#[serial_test::serial] -fn unlock_release_defaults_to_false() { - let _scrub = EnvScrub::new(); - let cli = Cli::try_parse_from(["socket-patch", "unlock"]).expect("parse"); - assert!( - !release_of(cli), - "bare `unlock` must default release to false" - ); -} - -#[test] -#[serial_test::serial] -fn unlock_release_long_flag_sets_true() { - let _scrub = EnvScrub::new(); - let cli = Cli::try_parse_from(["socket-patch", "unlock", "--release"]).expect("parse"); - assert!(release_of(cli), "`--release` must set the flag"); -} - -/// Regression: an exported-but-empty `SOCKET_UNLOCK_RELEASE=` — the -/// shell/CI idiom for blanking a variable without unsetting it — must -/// mean "unset, fall back to the default (false)", not abort every -/// `unlock` invocation with a ValueValidation error. -#[test] -#[serial_test::serial] -fn empty_unlock_release_env_var_parses_as_false_not_crash() { - let _scrub = EnvScrub::new(); - std::env::set_var("SOCKET_UNLOCK_RELEASE", ""); - let parsed = Cli::try_parse_from(["socket-patch", "unlock"]); - std::env::remove_var("SOCKET_UNLOCK_RELEASE"); - let cli = parsed.expect("empty SOCKET_UNLOCK_RELEASE must not abort the parse"); - assert!( - !release_of(cli), - "empty SOCKET_UNLOCK_RELEASE must resolve to false" - ); -} - -/// Regression: the truthy env spellings must work — -/// `SOCKET_UNLOCK_RELEASE=1` must behave exactly like `--release` -/// instead of aborting the parse. -#[test] -#[serial_test::serial] -fn truthy_unlock_release_env_var_sets_flag() { - let _scrub = EnvScrub::new(); - std::env::set_var("SOCKET_UNLOCK_RELEASE", "1"); - let parsed = Cli::try_parse_from(["socket-patch", "unlock"]); - std::env::remove_var("SOCKET_UNLOCK_RELEASE"); - let cli = parsed.expect("SOCKET_UNLOCK_RELEASE=1 must parse"); - assert!(release_of(cli), "SOCKET_UNLOCK_RELEASE=1 must set release"); -} diff --git a/crates/socket-patch-cli/tests/cli_parse_vendor.rs b/crates/socket-patch-cli/tests/cli_parse_vendor.rs index 31db6683..8d431be2 100644 --- a/crates/socket-patch-cli/tests/cli_parse_vendor.rs +++ b/crates/socket-patch-cli/tests/cli_parse_vendor.rs @@ -56,7 +56,6 @@ const SOCKET_ENV_VARS: &[&str] = &[ "SOCKET_DRY_RUN", "SOCKET_YES", "SOCKET_LOCK_TIMEOUT", - "SOCKET_BREAK_LOCK", "SOCKET_DEBUG", "SOCKET_TELEMETRY_DISABLED", // VendorArgs-specific @@ -172,7 +171,6 @@ struct Snap { dry_run: bool, yes: bool, lock_timeout: Option, - break_lock: bool, debug: bool, no_telemetry: bool, force: bool, @@ -203,7 +201,6 @@ fn snapshot(a: &VendorArgs) -> Snap { dry_run: a.common.dry_run, yes: a.common.yes, lock_timeout: a.common.lock_timeout, - break_lock: a.common.break_lock, debug: a.common.debug, no_telemetry: a.common.no_telemetry, force: a.force, @@ -243,7 +240,6 @@ fn expected_defaults() -> Snap { dry_run: false, yes: false, lock_timeout: None, - break_lock: false, debug: false, no_telemetry: false, force: false, @@ -418,15 +414,6 @@ fn lock_timeout_flag_sets_lock_timeout() { assert_eq!(snapshot(&a), want); } -#[test] -#[serial_test::serial] -fn break_lock_flag_sets_break_lock() { - let a = parse_vendor(&["--break-lock"]); - let mut want = expected_defaults(); - want.break_lock = true; - assert_eq!(snapshot(&a), want); -} - #[test] #[serial_test::serial] fn ecosystems_csv_splits_into_vec() { diff --git a/crates/socket-patch-cli/tests/cli_parse_vex.rs b/crates/socket-patch-cli/tests/cli_parse_vex.rs index ec6e1f1b..75d6839d 100644 --- a/crates/socket-patch-cli/tests/cli_parse_vex.rs +++ b/crates/socket-patch-cli/tests/cli_parse_vex.rs @@ -11,7 +11,7 @@ //! and `scan`, the ambient env var broke those commands too (including //! `apply` running from a postinstall hook). The fix wires //! `value_parser = parse_bool_flag`, matching the `GlobalArgs` bool flags -//! and `repair --download-only` / `unlock --release`. `main`'s empty-var +//! and `repair --download-only`. `main`'s empty-var //! scrub also removes exported-but-empty values (the vars are in //! `LOCAL_ARG_ENV_VARS`), but these library-level parses bypass `main`, so //! the value parser must accept the empty string itself. @@ -55,7 +55,6 @@ const SOCKET_ENV_VARS: &[&str] = &[ "SOCKET_DRY_RUN", "SOCKET_YES", "SOCKET_LOCK_TIMEOUT", - "SOCKET_BREAK_LOCK", "SOCKET_DEBUG", "SOCKET_TELEMETRY_DISABLED", // VexArgs / VexEmbedArgs @@ -212,7 +211,6 @@ struct Snap { dry_run: bool, yes: bool, lock_timeout: Option, - break_lock: bool, debug: bool, no_telemetry: bool, output: Option, @@ -245,7 +243,6 @@ fn snapshot(a: &VexArgs) -> Snap { dry_run: a.common.dry_run, yes: a.common.yes, lock_timeout: a.common.lock_timeout, - break_lock: a.common.break_lock, debug: a.common.debug, no_telemetry: a.common.no_telemetry, output: a.output.clone(), @@ -285,7 +282,6 @@ fn expected_defaults() -> Snap { dry_run: false, yes: false, lock_timeout: None, - break_lock: false, debug: false, no_telemetry: false, output: None, diff --git a/crates/socket-patch-cli/tests/cli_remove_silent.rs b/crates/socket-patch-cli/tests/cli_remove_silent.rs index 9c5b08df..e6f35a44 100644 --- a/crates/socket-patch-cli/tests/cli_remove_silent.rs +++ b/crates/socket-patch-cli/tests/cli_remove_silent.rs @@ -2,11 +2,10 @@ //! //! CLI_CONTRACT.md defines `--silent` as "Errors only". Regression //! guard: `remove` gated all of its human-readable chatter on `!json` -//! alone, hardcoded `silent: false` into `acquire_or_emit` (so the -//! `--break-lock` stale-lock warning printed anyway), and passed only -//! `json` as `rollback_patches`' silent param — so `remove --silent` -//! printed everything. Same bug class previously fixed in `list`, -//! `repair`, and `get`. Runs fully offline: the patch record has no +//! alone, and passed only `json` as `rollback_patches`' silent param — +//! so `remove --silent` printed everything. Same bug class previously +//! fixed in `list`, `repair`, and `get`. Runs fully offline: the patch +//! record has no //! files (so rollback fetches no blobs) and the project dir has no //! installed packages, so the internal rollback takes the //! "not installed" path and the manifest mutation needs no network. @@ -127,12 +126,10 @@ fn remove_silent_produces_no_output_on_success() { ); } -/// `--silent` must also reach the lock helper: reclaiming a stale -/// `apply.lock` via `--break-lock` prints a stderr warning that -/// `acquire_or_emit` gates on its `silent` param — which `remove` -/// hardcoded to `false`. +/// A leftover `apply.lock` with no live holder must not disturb a +/// silent remove: the acquire reclaims it in place with no chatter. #[test] -fn remove_silent_suppresses_break_lock_warning() { +fn remove_silent_reclaims_stale_lock_without_output() { let tmp = tempfile::tempdir().expect("tempdir"); let socket = make_socket_dir(tmp.path()); std::fs::write(socket.join("apply.lock"), b"").expect("write stale lock"); @@ -143,36 +140,20 @@ fn remove_silent_suppresses_break_lock_warning() { "pkg:npm/__remove_silent_test__@1.0.0", "--silent", "--yes", - "--break-lock", "--skip-rollback", ], ); assert_eq!( code, 0, - "remove must succeed; stdout={stdout:?} stderr={stderr:?}" - ); - assert!( - !stderr.contains("reclaimed stale"), - "--silent must suppress the stale-lock warning; got {stderr:?}" - ); - - // Control run: without --silent the warning must appear. - let tmp2 = tempfile::tempdir().expect("tempdir"); - let socket2 = make_socket_dir(tmp2.path()); - std::fs::write(socket2.join("apply.lock"), b"").expect("write stale lock"); - let (loud_code, _loud_stdout, loud_stderr) = run_remove( - tmp2.path(), - &[ - "pkg:npm/__remove_silent_test__@1.0.0", - "--yes", - "--break-lock", - "--skip-rollback", - ], + "remove must succeed through a stale lock file; stdout={stdout:?} stderr={stderr:?}" ); - assert_eq!(loud_code, 0); + let stderr_rest: Vec<&str> = stderr + .lines() + .filter(|l| !l.contains("SOCKET_API_TOKEN") && !l.trim().is_empty()) + .collect(); assert!( - loud_stderr.contains("reclaimed stale"), - "non-silent --break-lock must print the stale-lock warning; got {loud_stderr:?}" + stderr_rest.is_empty(), + "--silent must produce no stderr chatter; got {stderr_rest:?}" ); } diff --git a/crates/socket-patch-cli/tests/cli_sigpipe.rs b/crates/socket-patch-cli/tests/cli_sigpipe.rs index f72173b9..2be3188c 100644 --- a/crates/socket-patch-cli/tests/cli_sigpipe.rs +++ b/crates/socket-patch-cli/tests/cli_sigpipe.rs @@ -22,12 +22,16 @@ const BINARY: &str = env!("CARGO_BIN_EXE_socket-patch"); /// `libc::SIGPIPE`, inlined so the test crate needs no libc dependency. const SIGPIPE: i32 = 13; -/// `unlock` in an empty directory is the cheapest command that writes to -/// stdout: offline, lock-free, no manifest needed — prints "Lock is free." +/// `list` against an empty manifest is the cheapest command that writes +/// to stdout: offline, lock-free — prints "No patches found in manifest." /// and exits 0 when stdout is healthy. #[test] fn closed_stdout_pipe_is_not_a_panic() { let dir = tempfile::tempdir().expect("tempdir"); + let socket = dir.path().join(".socket"); + std::fs::create_dir_all(&socket).expect("create .socket"); + std::fs::write(socket.join("manifest.json"), r#"{ "patches": {} }"#) + .expect("write manifest"); // Build a pipe and close the read end BEFORE the child spawns, so the // child's first stdout write hits EPIPE deterministically (piping to a @@ -36,7 +40,7 @@ fn closed_stdout_pipe_is_not_a_panic() { drop(reader); let mut cmd = Command::new(BINARY); - cmd.arg("unlock") + cmd.arg("list") .current_dir(dir.path()) .stdout(Stdio::from(writer)) .stderr(Stdio::piped()); diff --git a/crates/socket-patch-cli/tests/common/mod.rs b/crates/socket-patch-cli/tests/common/mod.rs index 795e32c6..d5a34df8 100644 --- a/crates/socket-patch-cli/tests/common/mod.rs +++ b/crates/socket-patch-cli/tests/common/mod.rs @@ -608,7 +608,7 @@ mod oracle_selftests { // No `error` object → both error accessors return None (not a panic, // not a stale hit), so success-path consumers asserting `None` stay // honest. - let ok = parse_json_envelope(r#"{"status":"free","command":"unlock"}"#); + let ok = parse_json_envelope(r#"{"status":"success","command":"list"}"#); assert_eq!(envelope_error_code(&ok), None); assert_eq!(envelope_error_message(&ok), None); diff --git a/crates/socket-patch-cli/tests/e2e_safety_lock.rs b/crates/socket-patch-cli/tests/e2e_safety_lock.rs index 21f3e007..b89ee558 100644 --- a/crates/socket-patch-cli/tests/e2e_safety_lock.rs +++ b/crates/socket-patch-cli/tests/e2e_safety_lock.rs @@ -167,14 +167,19 @@ fn lock_held_human_mode_mentions_other_process() { ); // Pin the actual contention contract phrase rather than just // "another"+"process": the binary prints the lock_held message and - // the actionable unlock/break-lock hint. + // the wait hint. Held always means a live process, so the only + // honest advice is to wait (or budget a wait via --lock-timeout). assert!( stderr.contains("Error: another socket-patch process is operating in this directory"), "stderr should carry the lock_held error line, got:\n{stderr}" ); assert!( - stderr.contains("--break-lock") && stderr.contains("socket-patch unlock"), - "stderr should give the actionable unlock/break-lock hint, got:\n{stderr}" + stderr.contains("--lock-timeout"), + "stderr should give the actionable wait hint, got:\n{stderr}" + ); + assert!( + !stderr.contains("unlock") && !stderr.contains("--break-lock"), + "the unlock/break-lock hints were removed with those commands, got:\n{stderr}" ); } @@ -355,123 +360,36 @@ fn helper_lock_is_actually_exclusive() { ); } -/// `apply --break-lock` against a pre-staged lock file (no live -/// holder) reclaims the file and proceeds with the apply pass. The -/// JSON envelope must surface the `lock_broken` warning event so the -/// action is auditable. -/// -/// Setup mirrors the OS-level scenario: a previous run crashed and -/// left `apply.lock` behind, but the OS-level flock was released -/// (so a fresh acquire would succeed even without --break-lock). -/// The --break-lock path is the safe-by-design version of `rm` — -/// it never actually unlinks (that would defeat mutual exclusion), -/// it verifies no live holder and records the audit event. +/// `apply` against a pre-staged lock file (no live holder) reclaims +/// the file in place and proceeds with the apply pass — no flag +/// needed. Mirrors the OS-level scenario: a previous run crashed and +/// left `apply.lock` behind, but the kernel released the dead +/// holder's flock, so a fresh acquire sails through. This fact is +/// what made `--break-lock` (and the `unlock` subcommand) redundant. #[test] -fn break_lock_reclaims_stale_file_and_records_warning() { +fn stale_lock_file_does_not_block_apply() { let dir = tempfile::tempdir().unwrap(); let socket_dir = dir.path().join(".socket"); setup_socket_dir(&socket_dir); - // Pre-stage a lock file but DON'T hold an OS lock — simulates - // the post-crash scenario where the file lingers but flock was - // released. Without --break-lock the binary would still - // acquire fine (`acquire` re-opens the file); with --break-lock - // we additionally get the audit event. + // Pre-stage a lock file but DON'T hold an OS lock. std::fs::write(socket_dir.join("apply.lock"), b"").unwrap(); - let (code, stdout, stderr) = run(dir.path(), &["apply", "--json", "--break-lock"]); + let (code, stdout, stderr) = run(dir.path(), &["apply", "--json"]); let env = parse_json_envelope(&stdout); - // --break-lock breaks the stale file and then acquires cleanly, so - // the run must NOT itself be a lock_held failure. Prove the binary - // genuinely re-acquired the lock and drove the real apply pipeline - // to completion (partialFailure against the absent synthetic - // package, no top-level error) — not merely that the errorCode - // happened to differ from "lock_held". Without this, a regression - // that emitted the audit event but then bailed before acquiring - // (or with some other non-lock error) would slip through the - // `assert_ne!` + event-presence checks below. + // Prove the binary genuinely acquired the lock and drove the real + // apply pipeline to completion (partialFailure against the absent + // synthetic package, no top-level error). assert_lock_acquired(&env); - assert_ne!( - envelope_error_code(&env), - Some("lock_held"), - "--break-lock should acquire, not report lock_held.\nenvelope: {env}" - ); // Same exit contract as every other acquired-then-pipeline run in // this file: partialFailure against an absent package exits 1. assert_eq!( code, 1, - "break-lock apply that ran the pipeline to partialFailure must exit 1.\nstderr:\n{stderr}" - ); - let events = env["events"].as_array().expect("events array"); - // Exactly one lock_broken audit event, carrying the audit reason - // that names the action and the lock path. - let lock_broken: Vec<_> = events - .iter() - .filter(|e| { - e.get("action").and_then(|v| v.as_str()) == Some("skipped") - && e.get("errorCode").and_then(|v| v.as_str()) == Some("lock_broken") - }) - .collect(); - assert_eq!( - lock_broken.len(), - 1, - "apply --break-lock should emit exactly one lock_broken skipped event.\nstdout:\n{stdout}" - ); - let reason = lock_broken[0] - .get("reason") - .and_then(|v| v.as_str()) - .expect("lock_broken event must carry a reason"); - assert!( - reason.contains("--break-lock") && reason.contains("apply.lock"), - "lock_broken reason should name the action and the lock file, got: {reason}" - ); - // The break is also reflected in the skipped tally. - assert!( - env["summary"]["skipped"].as_u64().unwrap_or(0) >= 1, - "lock_broken should be counted in summary.skipped.\nenvelope: {env}" + "apply that ran the pipeline to partialFailure must exit 1.\nstderr:\n{stderr}" ); // The inode is kept for subsequent acquires. assert!( socket_dir.join("apply.lock").is_file(), - "apply.lock should still exist after --break-lock acquires" - ); -} - -/// Regression: when `--break-lock` itself is refused because a LIVE -/// holder owns the lock, the stderr hint must not advise rerunning -/// with `--break-lock` — the user just did exactly that, and the -/// probe refused precisely because a holder exists, so the advice -/// can only loop. (The plain-contention hint, with no --break-lock -/// passed, rightly keeps suggesting the flag — see -/// `lock_held_human_mode_mentions_other_process`.) -#[test] -fn break_lock_refusal_does_not_advise_break_lock_again() { - let dir = tempfile::tempdir().unwrap(); - let socket_dir = dir.path().join(".socket"); - setup_socket_dir(&socket_dir); - let _external = take_external_lock(&socket_dir); - - let (code, stdout, stderr) = run(dir.path(), &["apply", "--break-lock"]); - assert_eq!( - code, 1, - "--break-lock against a live holder must refuse with exit 1.\nstderr:\n{stderr}" - ); - assert!( - stdout.trim().is_empty(), - "human mode must not print a JSON envelope to stdout, got:\n{stdout}" - ); - assert!( - stderr.contains("Error: another socket-patch process is operating in this directory"), - "stderr should carry the lock_held error line, got:\n{stderr}" - ); - // Still actionable: the inspect path remains valid. - assert!( - stderr.contains("socket-patch unlock"), - "stderr should still point at `socket-patch unlock`, got:\n{stderr}" - ); - // The regression: no self-defeating "rerun with --break-lock". - assert!( - !stderr.contains("rerun with --break-lock"), - "a refused --break-lock must not advise rerunning with --break-lock, got:\n{stderr}" + "apply.lock should still exist after the run" ); } diff --git a/crates/socket-patch-cli/tests/e2e_safety_unlock.rs b/crates/socket-patch-cli/tests/e2e_safety_unlock.rs deleted file mode 100644 index dff7b6f1..00000000 --- a/crates/socket-patch-cli/tests/e2e_safety_unlock.rs +++ /dev/null @@ -1,730 +0,0 @@ -//! End-to-end: `socket-patch unlock` reports lock state and -//! optionally releases a free lock. -//! -//! Mirrors `e2e_safety_lock.rs`'s strategy: this test takes the lock -//! externally via `fs2` (same crate the binary uses, same path) and -//! verifies the `unlock` subcommand observes the OS-level lock the -//! same way the mutating subcommands do. -//! -//! Network: no. Toolchain: no. NOT `#[ignore]`. - -use std::fs::OpenOptions; -use std::path::Path; -use std::process::Command; - -use fs2::FileExt; - -#[path = "common/mod.rs"] -mod common; - -use common::{json_string, parse_json_envelope}; - -/// Every SOCKET_* env var that the global args / the `unlock` -/// subcommand consult. These have clap `env =` fallbacks, so an -/// ambient value silently overrides the flags the tests *don't* pass -/// — most dangerously `SOCKET_UNLOCK_RELEASE` (turns every plain -/// probe into a `--release`, subverting the no-release tests), -/// `SOCKET_CWD` (redirects the probe to a different tree, making the -/// staged `.socket/` irrelevant), and `SOCKET_JSON` / `SOCKET_SILENT` -/// (which would respectively force JSON on the human-mode tests or -/// blank out the stderr the human-mode tests assert on). The shared -/// `common::run` only scrubs `SOCKET_API_TOKEN`, so this suite owns a -/// fully-scrubbed runner of its own. -const SOCKET_ENV_VARS: &[&str] = &[ - "SOCKET_UNLOCK_RELEASE", - "SOCKET_CWD", - "SOCKET_MANIFEST_PATH", - "SOCKET_API_URL", - "SOCKET_API_TOKEN", - "SOCKET_ORG_SLUG", - "SOCKET_PROXY_URL", - "SOCKET_ECOSYSTEMS", - "SOCKET_DOWNLOAD_MODE", - "SOCKET_VENDOR_SOURCE", - "SOCKET_VENDOR_URL", - "SOCKET_PATCH_SERVER_URL", - "SOCKET_OFFLINE", - "SOCKET_STRICT", - "SOCKET_GLOBAL", - "SOCKET_GLOBAL_PREFIX", - "SOCKET_JSON", - "SOCKET_VERBOSE", - "SOCKET_SILENT", - "SOCKET_DRY_RUN", - "SOCKET_YES", - "SOCKET_LOCK_TIMEOUT", - "SOCKET_BREAK_LOCK", - "SOCKET_DEBUG", - "SOCKET_TELEMETRY_DISABLED", -]; - -/// Remove every scrub-listed SOCKET_* var from `cmd`'s environment. -/// Shared between [`run`] and the scrub-coverage regression test so the -/// test exercises the exact scrub the whole suite relies on. -fn scrub_socket_env(cmd: &mut Command) { - for var in SOCKET_ENV_VARS { - cmd.env_remove(var); - } -} - -/// Run the CLI with `args` in `cwd`, with the entire SOCKET_* env -/// surface scrubbed so the behavior under test is determined solely by -/// the CLI flags — not by whatever the developer/CI happens to export. -/// Returns `(exit_code, stdout, stderr)`. Local shadow of -/// `common::run`, which only removes `SOCKET_API_TOKEN`. -fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { - let mut cmd = Command::new(common::binary()); - cmd.args(args).current_dir(cwd); - scrub_socket_env(&mut cmd); - let out = cmd.output().expect("failed to execute socket-patch binary"); - let code = out.status.code().unwrap_or(-1); - let stdout = String::from_utf8_lossy(&out.stdout).to_string(); - let stderr = String::from_utf8_lossy(&out.stderr).to_string(); - (code, stdout, stderr) -} - -/// Take an exclusive flock on `.socket/apply.lock`. Returns the -/// open file whose Drop releases the lock — keep it bound for the -/// duration of the test. -fn take_external_lock(socket_dir: &Path) -> std::fs::File { - std::fs::create_dir_all(socket_dir).unwrap(); - let path = socket_dir.join("apply.lock"); - let file = OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(false) - .open(&path) - .expect("open lock file"); - file.try_lock_exclusive() - .expect("test could not take initial lock"); - file -} - -/// `unlock` against a fresh project (no `.socket/`) reports `free` -/// and exits 0. Generic "is the project locked?" probe that CI -/// tooling can call before deciding whether to fire a mutating -/// subcommand. -#[test] -fn unlock_reports_free_when_no_socket_dir() { - let dir = tempfile::tempdir().unwrap(); - let (code, stdout, stderr) = run(dir.path(), &["unlock", "--json"]); - assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); - let env = parse_json_envelope(&stdout); - assert_eq!(json_string(&env, "status"), Some("free")); - assert_eq!(json_string(&env, "command"), Some("unlock")); - // No `--release`, nothing existed: `released` must be present and false, - // not merely absent (an envelope that dropped the field entirely would - // otherwise read as a pass). - assert_eq!( - env.get("released").and_then(|v| v.as_bool()), - Some(false), - "free probe without --release must report released=false: {stdout}" - ); - // The reported lock path must be the real `.socket/apply.lock`, not some - // placeholder — this is the path the mutating subcommands actually flock. - // `ends_with("apply.lock")` was too loose: any `foo/apply.lock` would pass, - // including one outside `.socket/`. Pin the full `.socket/apply.lock` - // suffix (built via Path so the separator is correct on every platform). - let lock_field = json_string(&env, "lockFile").expect("lockFile field present"); - let expected_suffix = Path::new(".socket").join("apply.lock"); - let expected_suffix = expected_suffix.to_str().unwrap(); - assert!( - lock_field.ends_with(expected_suffix), - "lockFile should name the real .socket/apply.lock, got {lock_field}" - ); - // A pure probe must not materialize project state out of thin air. - assert!( - !dir.path().join(".socket").exists(), - "probing a fresh repo must not create .socket/" - ); -} - -/// `unlock` while another process holds the lock reports `held` -/// and exits 1. The JSON envelope's `error.code` is `lock_held` — -/// matches the contract emitted by the mutating subcommands so -/// downstream consumers don't need a separate `unlock`-specific -/// branch. -#[test] -fn unlock_reports_held_when_lock_actively_held() { - let dir = tempfile::tempdir().unwrap(); - let socket_dir = dir.path().join(".socket"); - let external = take_external_lock(&socket_dir); - - let (code, stdout, stderr) = run(dir.path(), &["unlock", "--json"]); - assert_eq!(code, 1, "stdout={stdout}\nstderr={stderr}"); - let env = parse_json_envelope(&stdout); - assert_eq!(json_string(&env, "status"), Some("error")); - // Must be tagged as an unlock failure, not some other subcommand's - // envelope leaking through. - assert_eq!(json_string(&env, "command"), Some("unlock")); - let code_field = env - .get("error") - .and_then(|e| e.get("code")) - .and_then(|c| c.as_str()); - assert_eq!(code_field, Some("lock_held")); - // The error must specifically be about a competing process AND name the - // `.socket` location it observed — guards against a generic/empty error - // message (or a hard-coded string with no real path context) masquerading - // as lock_held. - let msg = env - .get("error") - .and_then(|e| e.get("message")) - .and_then(|m| m.as_str()) - .unwrap_or(""); - assert!( - msg.contains("another socket-patch process"), - "lock_held message should name the competing process, got: {msg}" - ); - assert!( - msg.contains(".socket"), - "lock_held message should name the .socket location it probed, got: {msg}" - ); - // Probing a held lock must NOT disturb the file the external holder - // owns — the probe is read-only. - assert!( - socket_dir.join("apply.lock").is_file(), - "held-probe must leave the externally-locked file intact" - ); - - // Positive control: the only thing that distinguishes "held" from "free" - // must be the live OS lock, NOT the mere existence of the lock file. Drop - // the external lock (the file stays on disk, byte-for-byte identical) and - // re-probe: the verdict has to flip to `free`. If production reported - // `held` just because `apply.lock` exists, this second probe would still - // report held and the assertion below would fail — closing the - // file-existence-masquerading-as-a-lock loophole. - fs2::FileExt::unlock(&external).expect("release external lock"); - assert!( - socket_dir.join("apply.lock").is_file(), - "control precondition: the lock file must persist across the release" - ); - let (code2, stdout2, stderr2) = run(dir.path(), &["unlock", "--json"]); - assert_eq!( - code2, 0, - "free after release: stdout={stdout2}\nstderr={stderr2}" - ); - let env2 = parse_json_envelope(&stdout2); - assert_eq!( - json_string(&env2, "status"), - Some("free"), - "the same lock file with no live OS lock must read as free: {stdout2}" - ); - drop(external); -} - -/// `unlock --release` against a free lock with a leftover file -/// removes the file. This is the recovery path for the -/// post-crash leftover-file scenario. -#[test] -fn unlock_release_deletes_lock_file_when_free() { - let dir = tempfile::tempdir().unwrap(); - let socket_dir = dir.path().join(".socket"); - std::fs::create_dir_all(&socket_dir).unwrap(); - let lock_file = socket_dir.join("apply.lock"); - std::fs::write(&lock_file, b"").unwrap(); - assert!(lock_file.is_file(), "pre-stage failed"); - - let (code, stdout, stderr) = run(dir.path(), &["unlock", "--json", "--release"]); - assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); - let env = parse_json_envelope(&stdout); - assert_eq!(json_string(&env, "command"), Some("unlock")); - assert_eq!(json_string(&env, "status"), Some("free")); - assert_eq!( - env.get("released").and_then(|v| v.as_bool()), - Some(true), - "a pre-existing leftover file was removed, so released must be true: {stdout}" - ); - assert!( - !lock_file.exists(), - "--release should have deleted the lock file" - ); -} - -/// `unlock --release` against a `.socket/` directory that has no -/// lock file reports `released: false` — there was nothing to -/// release. Regression test: `acquire` creates the lock file on -/// demand, so a naive `remove_file().is_ok()` check would wrongly -/// claim it released a pre-existing leftover. The probe must not -/// leave a lock file behind either (clean slate). -#[test] -fn unlock_release_reports_not_released_when_no_lock_file() { - let dir = tempfile::tempdir().unwrap(); - let socket_dir = dir.path().join(".socket"); - std::fs::create_dir_all(&socket_dir).unwrap(); - let lock_file = socket_dir.join("apply.lock"); - assert!(!lock_file.exists(), "pre-stage: no lock file expected"); - - let (code, stdout, stderr) = run(dir.path(), &["unlock", "--json", "--release"]); - assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); - let env = parse_json_envelope(&stdout); - assert_eq!(json_string(&env, "command"), Some("unlock")); - assert_eq!(json_string(&env, "status"), Some("free")); - assert_eq!( - env.get("released").and_then(|v| v.as_bool()), - Some(false), - "nothing pre-existed, so released must be false: {stdout}" - ); - assert!( - !lock_file.exists(), - "--release should not leave a probe-created lock file behind" - ); -} - -/// `unlock --release` against a completely fresh project (no -/// `.socket/` at all) reports `released: false` and exits 0. -/// Mirrors the missing-dir branch's contract. -#[test] -fn unlock_release_reports_not_released_when_no_socket_dir() { - let dir = tempfile::tempdir().unwrap(); - let (code, stdout, stderr) = run(dir.path(), &["unlock", "--json", "--release"]); - assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); - let env = parse_json_envelope(&stdout); - assert_eq!(json_string(&env, "command"), Some("unlock")); - assert_eq!(json_string(&env, "status"), Some("free")); - assert_eq!( - env.get("released").and_then(|v| v.as_bool()), - Some(false), - "no .socket/ existed, so released must be false: {stdout}" - ); - // `--release` against a missing dir must stay a no-op: it must not - // create `.socket/` (and therefore no lock file) as a side-effect. - assert!( - !dir.path().join(".socket").exists(), - "--release on a fresh repo must not create .socket/" - ); -} - -/// `unlock --release` refuses when the lock is HELD — the file -/// must NOT be removed (otherwise we'd undermine the OS-level -/// exclusion). The user has to use `--break-lock` on the mutating -/// subcommand for that scenario. -#[test] -fn unlock_release_refuses_when_held() { - let dir = tempfile::tempdir().unwrap(); - let socket_dir = dir.path().join(".socket"); - let _external = take_external_lock(&socket_dir); - - let (code, _stdout, stderr) = run(dir.path(), &["unlock", "--release"]); - assert_eq!(code, 1, "stderr={stderr}"); - assert!( - socket_dir.join("apply.lock").is_file(), - "lock file must survive a refused --release" - ); - // Exit 1 + surviving file is not enough — a crash or an unrelated I/O - // error would also satisfy that. Confirm we hit the *held-refusal* - // branch specifically: the operator is told the release was refused and - // pointed at --break-lock. This is the distinctive `--release`+held - // message that no other failure path emits. - let lower = stderr.to_lowercase(); - assert!( - lower.contains("lock is held"), - "stderr should report the held lock, got:\n{stderr}" - ); - assert!( - lower.contains("refusing to release"), - "stderr should explicitly refuse to release a held lock, got:\n{stderr}" - ); - assert!( - lower.contains("break-lock"), - "stderr should point operator at --break-lock, got:\n{stderr}" - ); -} - -/// Human-mode (`unlock` without `--json`) plain probe of a free lock -/// prints the "Lock is free." line on stdout and exits 0. The JSON -/// suite covers the machine surface; this pins the human surface so a -/// regression in `emit_free`'s non-JSON branch (e.g. printing nothing, -/// or routing to stderr) is caught. -#[test] -fn unlock_human_mode_reports_free() { - let dir = tempfile::tempdir().unwrap(); - let socket_dir = dir.path().join(".socket"); - std::fs::create_dir_all(&socket_dir).unwrap(); - - let (code, stdout, stderr) = run(dir.path(), &["unlock"]); - assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); - assert!( - stdout.contains("Lock is free."), - "human-mode free probe should print the free line, got stdout:\n{stdout}" - ); - // A plain free probe must NOT claim it removed anything. - assert!( - !stdout.to_lowercase().contains("removed"), - "free probe without --release must not mention removal, got:\n{stdout}" - ); -} - -/// Human-mode `--release` against a free lock with a pre-existing -/// leftover file prints the "Removed …" confirmation naming the real -/// lock path. Regression guard for unlock.rs:186 — if the -/// `removed`/`release` message branches were swapped, a genuine removal -/// would silently report "no lock file to remove". -#[test] -fn unlock_human_mode_release_reports_removed_when_leftover() { - let dir = tempfile::tempdir().unwrap(); - let socket_dir = dir.path().join(".socket"); - std::fs::create_dir_all(&socket_dir).unwrap(); - let lock_file = socket_dir.join("apply.lock"); - std::fs::write(&lock_file, b"").unwrap(); - - let (code, stdout, stderr) = run(dir.path(), &["unlock", "--release"]); - assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); - let lower = stdout.to_lowercase(); - assert!( - lower.contains("removed"), - "human-mode --release of a leftover should confirm removal, got:\n{stdout}" - ); - // Names the actual file it removed, not a placeholder. - assert!( - stdout.contains("apply.lock"), - "removal message should name the lock file, got:\n{stdout}" - ); - // ...and it must not contradict itself by also saying there was - // nothing to remove. - assert!( - !lower.contains("no lock file to remove"), - "a real removal must not emit the no-op wording, got:\n{stdout}" - ); - assert!( - !lock_file.exists(), - "--release should have deleted the file" - ); -} - -/// Human-mode `--release` against a clean `.socket/` (no pre-existing -/// lock file) prints the "no lock file to remove" no-op wording — the -/// probe-created file doesn't count as a released leftover. Companion -/// to the "Removed" test: together they pin both arms of the -/// `release && removed` branch so neither can be silently swapped. -#[test] -fn unlock_human_mode_release_reports_noop_when_no_leftover() { - let dir = tempfile::tempdir().unwrap(); - let socket_dir = dir.path().join(".socket"); - std::fs::create_dir_all(&socket_dir).unwrap(); - let lock_file = socket_dir.join("apply.lock"); - assert!(!lock_file.exists(), "pre-stage: no lock file expected"); - - let (code, stdout, stderr) = run(dir.path(), &["unlock", "--release"]); - assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); - let lower = stdout.to_lowercase(); - assert!( - lower.contains("no lock file to remove"), - "human-mode --release with nothing to remove should say so, got:\n{stdout}" - ); - // Must NOT falsely claim a removal happened. - assert!( - !lower.contains("removed"), - "no-leftover --release must not claim a removal, got:\n{stdout}" - ); - // The probe-created file must not survive (clean slate). - assert!( - !lock_file.exists(), - "--release must not leave a probe-created lock file behind" - ); -} - -/// `--silent` ("Suppress non-error output") must blank the human-mode -/// free line. Regression guard: `emit_free` gated its human output on -/// `!json` alone — `unlock --silent` printed "Lock is free." to stdout -/// while the rest of the file (held branch, `emit_error`) honored the -/// flag. Same bug class previously fixed in `list`, `repair`, `get`, -/// `remove`, `scan`, and `setup`. -#[test] -fn unlock_silent_suppresses_free_output() { - let dir = tempfile::tempdir().unwrap(); - let socket_dir = dir.path().join(".socket"); - std::fs::create_dir_all(&socket_dir).unwrap(); - - let (code, stdout, stderr) = run(dir.path(), &["unlock", "--silent"]); - assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); - assert!( - stdout.trim().is_empty(), - "--silent must produce no stdout on a free probe, got:\n{stdout}" - ); - - // Control run: the same probe WITHOUT --silent must print the free - // line — otherwise the assertion above passes vacuously. - let (loud_code, loud_stdout, _) = run(dir.path(), &["unlock"]); - assert_eq!(loud_code, 0); - assert!( - loud_stdout.contains("Lock is free."), - "non-silent free probe must print the free line, got:\n{loud_stdout}" - ); -} - -/// `--silent --release` suppresses the output, not the mutation: the -/// leftover lock file must still be deleted, with nothing on stdout. -#[test] -fn unlock_silent_release_still_deletes_but_stays_quiet() { - let dir = tempfile::tempdir().unwrap(); - let socket_dir = dir.path().join(".socket"); - std::fs::create_dir_all(&socket_dir).unwrap(); - let lock_file = socket_dir.join("apply.lock"); - std::fs::write(&lock_file, b"").unwrap(); - - let (code, stdout, stderr) = run(dir.path(), &["unlock", "--silent", "--release"]); - assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); - assert!( - stdout.trim().is_empty(), - "--silent --release must produce no stdout, got:\n{stdout}" - ); - assert!( - !lock_file.exists(), - "--silent must not suppress the release itself" - ); -} - -/// `--silent` must NOT blank the JSON envelope — `--json --silent` is -/// the standard scripting combination and the machine output is the -/// whole point of it. -#[test] -fn unlock_silent_keeps_json_output() { - let dir = tempfile::tempdir().unwrap(); - let socket_dir = dir.path().join(".socket"); - std::fs::create_dir_all(&socket_dir).unwrap(); - - let (code, stdout, stderr) = run(dir.path(), &["unlock", "--json", "--silent"]); - assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); - let env = parse_json_envelope(&stdout); - assert_eq!( - json_string(&env, "status"), - Some("free"), - "--silent must not suppress the JSON envelope: {stdout}" - ); -} - -/// `unlock` must probe the SAME lock the mutating subcommands take. -/// Every mutating command derives the lock directory from -/// `--manifest-path` (`resolved_manifest_path().parent()`); `unlock` -/// hardcoded `/.socket` instead, so with a custom manifest path it -/// probed a directory nobody locks — reporting `free` (exit 0) while -/// `apply`/`remove` held their lock. For the CI-gating use case this -/// command exists for, that's the worst possible wrong answer. -#[test] -fn unlock_honors_manifest_path_when_probing() { - let dir = tempfile::tempdir().unwrap(); - let custom_dir = dir.path().join("custom"); - let _external = take_external_lock(&custom_dir); - - let (code, stdout, stderr) = run( - dir.path(), - &[ - "unlock", - "--json", - "--manifest-path", - "custom/manifest.json", - ], - ); - assert_eq!( - code, 1, - "a held custom-manifest-path lock must read as held; stdout={stdout}\nstderr={stderr}" - ); - let env = parse_json_envelope(&stdout); - let code_field = env - .get("error") - .and_then(|e| e.get("code")) - .and_then(|c| c.as_str()); - assert_eq!(code_field, Some("lock_held"), "envelope: {stdout}"); -} - -/// Companion free-side guard: `--release` with a custom -/// `--manifest-path` must remove the leftover next to THAT manifest, -/// not silently no-op because `/.socket` doesn't exist. -#[test] -fn unlock_release_honors_manifest_path() { - let dir = tempfile::tempdir().unwrap(); - let custom_dir = dir.path().join("custom"); - std::fs::create_dir_all(&custom_dir).unwrap(); - let lock_file = custom_dir.join("apply.lock"); - std::fs::write(&lock_file, b"crashed-run-leftover").unwrap(); - - let (code, stdout, stderr) = run( - dir.path(), - &[ - "unlock", - "--json", - "--release", - "--manifest-path", - "custom/manifest.json", - ], - ); - assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); - let env = parse_json_envelope(&stdout); - assert_eq!( - env.get("released").and_then(|v| v.as_bool()), - Some(true), - "the custom-path leftover was removed, so released must be true: {stdout}" - ); - assert!( - !lock_file.exists(), - "--release must delete the leftover next to the resolved manifest path" - ); -} - -/// `--release --dry-run` must preview, not mutate: the leftover lock -/// file survives, `released` stays false (nothing was deleted), and -/// the flat envelope reports the dry run (`dryRun: true`, -/// `wouldRelease: true`). Regression guard: `unlock` ignored the -/// global `--dry-run` flag ("Preview, no mutations") and deleted the -/// file anyway. -#[test] -fn unlock_release_dry_run_previews_without_deleting() { - let dir = tempfile::tempdir().unwrap(); - let socket_dir = dir.path().join(".socket"); - std::fs::create_dir_all(&socket_dir).unwrap(); - let lock_file = socket_dir.join("apply.lock"); - std::fs::write(&lock_file, b"crashed-run-leftover").unwrap(); - - let (code, stdout, stderr) = run(dir.path(), &["unlock", "--json", "--release", "--dry-run"]); - assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); - assert!( - lock_file.is_file(), - "--dry-run must not delete the lock file" - ); - let env = parse_json_envelope(&stdout); - assert_eq!(json_string(&env, "status"), Some("free")); - assert_eq!( - env.get("released").and_then(|v| v.as_bool()), - Some(false), - "a dry run deletes nothing, so released must be false: {stdout}" - ); - assert_eq!( - env.get("dryRun").and_then(|v| v.as_bool()), - Some(true), - "the envelope must report the dry run: {stdout}" - ); - assert_eq!( - env.get("wouldRelease").and_then(|v| v.as_bool()), - Some(true), - "a real run would have removed the leftover, so wouldRelease must be true: {stdout}" - ); -} - -/// Human-mode `--release --dry-run` with a leftover file announces the -/// would-be removal without claiming it happened, and leaves the file -/// on disk. -#[test] -fn unlock_human_mode_release_dry_run_previews_removal() { - let dir = tempfile::tempdir().unwrap(); - let socket_dir = dir.path().join(".socket"); - std::fs::create_dir_all(&socket_dir).unwrap(); - let lock_file = socket_dir.join("apply.lock"); - std::fs::write(&lock_file, b"crashed-run-leftover").unwrap(); - - let (code, stdout, stderr) = run(dir.path(), &["unlock", "--release", "--dry-run"]); - assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); - let lower = stdout.to_lowercase(); - assert!( - lower.contains("would remove"), - "dry-run --release should preview the removal, got:\n{stdout}" - ); - // Must not claim a removal actually happened. - assert!( - !lower.contains("removed"), - "dry-run --release must not claim a completed removal, got:\n{stdout}" - ); - assert!( - lock_file.is_file(), - "--dry-run must leave the leftover lock file on disk" - ); -} - -/// A held probe under `--dry-run` stamps the standard error envelope's -/// `dryRun` field truthfully. Regression guard: `unlock` hardcoded -/// `dry_run = false` into its `error_envelope` calls, so a -/// `--dry-run` invocation's failure envelope misreported itself as a -/// real run. -#[test] -fn unlock_dry_run_held_envelope_reports_dry_run() { - let dir = tempfile::tempdir().unwrap(); - let socket_dir = dir.path().join(".socket"); - let _external = take_external_lock(&socket_dir); - - let (code, stdout, stderr) = run(dir.path(), &["unlock", "--json", "--release", "--dry-run"]); - assert_eq!(code, 1, "stdout={stdout}\nstderr={stderr}"); - let env = parse_json_envelope(&stdout); - let code_field = env - .get("error") - .and_then(|e| e.get("code")) - .and_then(|c| c.as_str()); - assert_eq!(code_field, Some("lock_held"), "envelope: {stdout}"); - assert_eq!( - env.get("dryRun").and_then(|v| v.as_bool()), - Some(true), - "held-lock envelope must carry the invocation's dry-run flag: {stdout}" - ); -} - -/// Human-mode (`unlock` without `--json`) emits a stderr hint -/// pointing the user at `--break-lock` when the lock is held. -/// Pinned at the substring level so the helpful guidance survives -/// minor copy edits. -#[test] -fn unlock_human_mode_hints_at_break_lock_when_held() { - let dir = tempfile::tempdir().unwrap(); - let socket_dir = dir.path().join(".socket"); - let _external = take_external_lock(&socket_dir); - - let (code, _stdout, stderr) = run(dir.path(), &["unlock"]); - assert_eq!(code, 1, "stderr={stderr}"); - let lower = stderr.to_lowercase(); - assert!( - lower.contains("lock is held"), - "stderr should report the held lock, got:\n{stderr}" - ); - assert!( - lower.contains("break-lock"), - "stderr should point operator at --break-lock, got:\n{stderr}" - ); - // This is the *probe* (no --release) branch, distinct from the - // release-refusal branch — it must NOT claim it refused to release - // something the caller never asked to release. - assert!( - !lower.contains("refusing to release"), - "plain held probe must not emit the --release-refusal wording, got:\n{stderr}" - ); -} - -/// The suite's scrub must cover EVERY flag-bound `SOCKET_*` env var — -/// clap validates env-bound values even for flags the invocation never -/// passes, so a single ambient junk value (e.g. `SOCKET_STRICT=banana` -/// exported in a dev shell) aborts the parse and turns the entire suite -/// red. Seed-then-scrub (pattern from `e2e_golang_redirect.rs`): every -/// var the production parser binds is seeded with a value its parser -/// rejects, then run through the suite's own scrub. Any binding the -/// scrub misses reaches the child and fails the probe. Regression -/// guard: the hand-rolled scrub list drifted from `GlobalArgs`, missing -/// `SOCKET_STRICT`, `SOCKET_VENDOR_SOURCE`, `SOCKET_VENDOR_URL`, and -/// `SOCKET_PATCH_SERVER_URL`. -#[test] -fn run_scrubs_every_flag_bound_socket_env_var() { - use socket_patch_cli::args::{GLOBAL_ARG_ENV_VARS, LOCAL_ARG_ENV_VARS}; - - let dir = tempfile::tempdir().unwrap(); - let mut cmd = Command::new(common::binary()); - cmd.args(["unlock", "--json"]).current_dir(dir.path()); - // Hostile seed: rejected by every restrictive parser in play - // (parse_bool_flag, parse_vendor_source, the ecosystems validator, - // the integer flags), so any var that escapes the scrub aborts the - // command instead of silently parsing. - for var in GLOBAL_ARG_ENV_VARS.iter().chain(LOCAL_ARG_ENV_VARS) { - cmd.env(var, "hostile-junk-not-a-valid-value"); - } - scrub_socket_env(&mut cmd); - let out = cmd.output().expect("failed to execute socket-patch binary"); - let code = out.status.code().unwrap_or(-1); - let stdout = String::from_utf8_lossy(&out.stdout).to_string(); - let stderr = String::from_utf8_lossy(&out.stderr).to_string(); - assert_eq!( - code, 0, - "a fully-scrubbed probe must succeed despite hostile ambient \ - SOCKET_* values; stdout={stdout}\nstderr={stderr}" - ); - let env = parse_json_envelope(&stdout); - assert_eq!( - json_string(&env, "status"), - Some("free"), - "envelope: {stdout}" - ); -} diff --git a/crates/socket-patch-cli/tests/get_batch_paths_e2e.rs b/crates/socket-patch-cli/tests/get_batch_paths_e2e.rs index 9f1a9020..90233e89 100644 --- a/crates/socket-patch-cli/tests/get_batch_paths_e2e.rs +++ b/crates/socket-patch-cli/tests/get_batch_paths_e2e.rs @@ -33,9 +33,9 @@ const UUID_B: &str = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; /// (u64) and `SOCKET_VENDOR_SOURCE` (enum), turn every invocation into an /// exit-2 usage error before `get` even runs. A fixed allowlist here /// rotted as flags were added (it predated `SOCKET_LOCK_TIMEOUT`, -/// `SOCKET_STRICT`, `SOCKET_VENDOR_*`, `SOCKET_PATCH_SERVER_URL`, -/// `SOCKET_BREAK_LOCK` — ambient `SOCKET_LOCK_TIMEOUT=bogus` failed 6 of -/// these 7 tests); the prefix scrub can't rot. +/// `SOCKET_STRICT`, `SOCKET_VENDOR_*`, `SOCKET_PATCH_SERVER_URL` — +/// ambient `SOCKET_LOCK_TIMEOUT=bogus` failed 6 of these 7 tests); the +/// prefix scrub can't rot. fn scrub_socket_env(cmd: &mut Command) { for (key, _) in std::env::vars_os() { let name = key.to_string_lossy(); diff --git a/crates/socket-patch-cli/tests/in_process_get_manifest_path.rs b/crates/socket-patch-cli/tests/in_process_get_manifest_path.rs index b361b985..79404498 100644 --- a/crates/socket-patch-cli/tests/in_process_get_manifest_path.rs +++ b/crates/socket-patch-cli/tests/in_process_get_manifest_path.rs @@ -5,7 +5,7 @@ //! //! 1. `--manifest-path` / `SOCKET_MANIFEST_PATH` is a documented global //! flag ("Manifest location, resolved relative to `--cwd`") honored by -//! apply/list/remove/rollback/repair/vendor/unlock — and by scan's own +//! apply/list/remove/rollback/repair/vendor — and by scan's own //! discovery and GC — but `get`'s save paths hardcoded //! `/.socket/manifest.json` (and `/.socket/blobs`). A `get` //! under a custom manifest path saved the patch to a location every diff --git a/crates/socket-patch-cli/tests/remove_rollback_api_overrides.rs b/crates/socket-patch-cli/tests/remove_rollback_api_overrides.rs index cf19613d..b1090242 100644 --- a/crates/socket-patch-cli/tests/remove_rollback_api_overrides.rs +++ b/crates/socket-patch-cli/tests/remove_rollback_api_overrides.rs @@ -53,7 +53,6 @@ const SOCKET_ENV_VARS: &[&str] = &[ "SOCKET_DRY_RUN", "SOCKET_YES", "SOCKET_LOCK_TIMEOUT", - "SOCKET_BREAK_LOCK", "SOCKET_DEBUG", "SOCKET_TELEMETRY_DISABLED", "SOCKET_ONE_OFF", diff --git a/crates/socket-patch-cli/tests/repair_invariants.rs b/crates/socket-patch-cli/tests/repair_invariants.rs index 8487532b..89fcb4b6 100644 --- a/crates/socket-patch-cli/tests/repair_invariants.rs +++ b/crates/socket-patch-cli/tests/repair_invariants.rs @@ -121,6 +121,13 @@ fn repair_with_no_manifest_emits_manifest_not_found_envelope() { assert_eq!(v["command"], "repair"); assert_eq!(v["status"], "error"); assert_eq!(v["error"]["code"], "manifest_not_found"); + // The early return fires before lock acquisition and before the + // lock-file cleanup: repair must not conjure a `.socket/` directory + // into a project that never had one. + assert!( + !tmp.path().join(".socket").exists(), + "repair on a bare directory must not create .socket/" + ); } /// A project whose ONLY trace is the hosted-mode redirect ledger @@ -550,6 +557,121 @@ fn repair_cleanup_failure_is_reported_in_json_and_silent_modes() { .expect("restore blobs dir permissions"); } +// --------------------------------------------------------------------------- +// Advisory-lock cleanup — repair owns the old `unlock --release` behavior +// --------------------------------------------------------------------------- + +/// Take an exclusive flock on the binary's lock file path (the same +/// `fs2` primitive the binary uses). Returns the open file handle whose +/// drop releases the lock — keep it bound for the test's duration. +fn take_external_lock(socket_dir: &Path) -> std::fs::File { + use fs2::FileExt; + let path = socket_dir.join("apply.lock"); + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&path) + .expect("open lock file"); + file.try_lock_exclusive() + .expect("test could not take initial lock"); + file +} + +/// A leftover `apply.lock` from an earlier (or crashed) run is removed +/// by a successful repair — the fold-in of the old `unlock --release`. +#[test] +fn repair_deletes_leftover_lock_file_on_success() { + let tmp = tempfile::tempdir().expect("tempdir"); + let socket = make_socket_dir(tmp.path()); + write_blob(&socket, REFERENCED_HASH, b"patched content"); + std::fs::write(socket.join("apply.lock"), b"leftover").expect("stage stale lock"); + + let (code, stdout) = run_repair(tmp.path(), &[]); + assert_eq!(code, 0, "expected exit 0; stdout=\n{stdout}"); + assert!( + !socket.join("apply.lock").exists(), + "repair must delete the leftover apply.lock" + ); +} + +/// Even with no pre-existing lock file, the acquire creates one +/// (`create(true)`); repair must clean up after itself so a finished +/// run leaves no lock file either way. +#[test] +fn repair_deletes_probe_created_lock_file() { + let tmp = tempfile::tempdir().expect("tempdir"); + let socket = make_socket_dir(tmp.path()); + write_blob(&socket, REFERENCED_HASH, b"patched content"); + assert!(!socket.join("apply.lock").exists()); + + let (code, stdout) = run_repair(tmp.path(), &[]); + assert_eq!(code, 0, "expected exit 0; stdout=\n{stdout}"); + assert!( + !socket.join("apply.lock").exists(), + "repair must leave no apply.lock behind" + ); +} + +/// `--dry-run` mutates nothing — including the lock file. +#[test] +fn repair_dry_run_preserves_lock_file() { + let tmp = tempfile::tempdir().expect("tempdir"); + let socket = make_socket_dir(tmp.path()); + write_blob(&socket, REFERENCED_HASH, b"patched content"); + std::fs::write(socket.join("apply.lock"), b"leftover").expect("stage stale lock"); + + let (code, stdout) = run_repair(tmp.path(), &["--dry-run"]); + assert_eq!(code, 0, "expected exit 0; stdout=\n{stdout}"); + assert!( + socket.join("apply.lock").exists(), + "--dry-run must not delete apply.lock" + ); +} + +/// A LIVE holder makes repair refuse with `lock_held` (exit 1) and +/// keeps its lock file — repair resets leftover state, it never steals +/// a lock out from under a running process. +#[test] +fn repair_refuses_and_keeps_lock_when_live_holder() { + let tmp = tempfile::tempdir().expect("tempdir"); + let socket = make_socket_dir(tmp.path()); + let _external = take_external_lock(&socket); + + let (code, stdout) = run_repair(tmp.path(), &[]); + assert_eq!(code, 1, "expected lock_held exit 1; stdout=\n{stdout}"); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("envelope JSON"); + assert_eq!(v["command"], "repair"); + assert_eq!(v["status"], "error"); + assert_eq!(v["error"]["code"], "lock_held"); + assert!( + socket.join("apply.lock").exists(), + "a refused repair must leave the live holder's lock file alone" + ); +} + +/// The lock-file cleanup is housekeeping that runs on every completion +/// path, not a success reward: a repair that fails past the lock (here: +/// an unparseable manifest → `repair_failed`) still deletes the file. +#[test] +fn repair_deletes_lock_file_even_when_repair_fails() { + let tmp = tempfile::tempdir().expect("tempdir"); + let socket = tmp.path().join(".socket"); + std::fs::create_dir_all(&socket).unwrap(); + std::fs::write(socket.join("manifest.json"), "{ not valid json").unwrap(); + std::fs::write(socket.join("apply.lock"), b"leftover").expect("stage stale lock"); + + let (code, stdout) = run_repair(tmp.path(), &[]); + assert_eq!(code, 1, "expected repair_failed exit 1; stdout=\n{stdout}"); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("envelope JSON"); + assert_eq!(v["error"]["code"], "repair_failed"); + assert!( + !socket.join("apply.lock").exists(), + "the lock-file cleanup must run on the failure path too" + ); +} + // --------------------------------------------------------------------------- // gc alias parity // --------------------------------------------------------------------------- diff --git a/crates/socket-patch-core/src/patch/apply_lock.rs b/crates/socket-patch-core/src/patch/apply_lock.rs index 87e28ea2..5a7c3145 100644 --- a/crates/socket-patch-core/src/patch/apply_lock.rs +++ b/crates/socket-patch-core/src/patch/apply_lock.rs @@ -11,11 +11,21 @@ //! //! The lock file lives at `<.socket>/apply.lock`. It is created on //! demand (the parent `.socket/` directory must exist first; callers -//! get a clear error otherwise) and is **never deleted** — the file -//! handle drop releases the OS-level advisory lock, but the inode -//! sticks around for next time. That keeps the lock idempotent across -//! restarts and avoids a race where two callers create the lock file -//! at the same time. +//! get a clear error otherwise) and is retained by the mutating +//! commands across runs — the file handle drop releases the OS-level +//! advisory lock, but the inode sticks around for next time. That +//! keeps the lock idempotent across restarts and avoids a race where +//! two callers create the lock file at the same time. Callers must +//! never unlink a lock they hold (or one a live process might hold): +//! a competitor keeping or taking an advisory lock on the orphaned +//! inode while a fresh acquire locks its replacement defeats mutual +//! exclusion. The one sanctioned deletion is `socket-patch repair`, +//! which removes the leftover file as its final housekeeping step — +//! after releasing its own guard — so a finished repair leaves a +//! clean `.socket/` tree. A leftover file from a crashed run needs no +//! removal to unblock anything: the kernel released the dead +//! process's advisory lock with its file handle, so the next acquire +//! reclaims the file in place. //! //! Locking is advisory (`flock(2)` on Unix, `LockFileEx` on Windows //! via the `fs2` crate). Non-cooperating writers (a user shelling diff --git a/crates/socket-patch-core/src/utils/telemetry.rs b/crates/socket-patch-core/src/utils/telemetry.rs index afb7c079..7a26bde7 100644 --- a/crates/socket-patch-core/src/utils/telemetry.rs +++ b/crates/socket-patch-core/src/utils/telemetry.rs @@ -49,8 +49,6 @@ enum PatchTelemetryEventType { PatchRepaired, PatchRepairFailed, PatchSetup, - PatchUnlocked, - PatchUnlockFailed, // OpenVEX attestation (added in #81) VexGenerated, VexFailed, @@ -76,8 +74,6 @@ impl PatchTelemetryEventType { Self::PatchRepaired => "patch_repaired", Self::PatchRepairFailed => "patch_repair_failed", Self::PatchSetup => "patch_setup", - Self::PatchUnlocked => "patch_unlocked", - Self::PatchUnlockFailed => "patch_unlock_failed", Self::VexGenerated => "vex_generated", Self::VexFailed => "vex_failed", } @@ -598,7 +594,7 @@ pub async fn track_patch_fetch_failed( } // --------------------------------------------------------------------------- -// Inspection / housekeeping trackers: list / repair / setup / unlock +// Inspection / housekeeping trackers: list / repair / setup // --------------------------------------------------------------------------- /// Track a successful `list`. Reports the number of patches surfaced. @@ -672,43 +668,6 @@ pub async fn track_patch_setup(manager: &str, api_token: Option<&str>, org_slug: .await; } -/// Track a successful `unlock`. `was_held` indicates whether another -/// process was holding the lock at probe time; `released` is true when -/// `--release` actually removed the lock file (vs. the inspect-only case). -pub async fn track_patch_unlocked( - was_held: bool, - released: bool, - api_token: Option<&str>, - org_slug: Option<&str>, -) { - fire( - PatchTelemetryEventType::PatchUnlocked, - "unlock", - serde_json::json!({ "was_held": was_held, "released": released }), - None::<&str>, - api_token, - org_slug, - ) - .await; -} - -/// Track a failed `unlock`. -pub async fn track_patch_unlock_failed( - error: impl std::fmt::Display, - api_token: Option<&str>, - org_slug: Option<&str>, -) { - fire( - PatchTelemetryEventType::PatchUnlockFailed, - "unlock", - serde_json::Value::Null, - Some(error), - api_token, - org_slug, - ) - .await; -} - // --------------------------------------------------------------------------- // OpenVEX trackers // --------------------------------------------------------------------------- @@ -902,14 +861,6 @@ mod tests { "patch_repair_failed" ); assert_eq!(PatchTelemetryEventType::PatchSetup.as_str(), "patch_setup"); - assert_eq!( - PatchTelemetryEventType::PatchUnlocked.as_str(), - "patch_unlocked" - ); - assert_eq!( - PatchTelemetryEventType::PatchUnlockFailed.as_str(), - "patch_unlock_failed" - ); // OpenVEX assert_eq!( PatchTelemetryEventType::VexGenerated.as_str(), From d8b0a80f88f33414472fb6d2803db0b4aabf0789 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 22 Jul 2026 13:05:42 -0400 Subject: [PATCH 2/5] docs: README overhaul on the Divio model + docs/ecosystems.md split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README rewritten as pitch → install → five-minute tutorial → explanation → how-to recipes → full command reference. Ecosystem/mode support matrix, Maven/NuGet/Rush/Go/Cargo caveats, and platform table move to the new docs/ecosystems.md. 83 defects fixed across a 5-round adversarial review (stale claims, inverted --strict default, .socket/blobs/ path, unimplemented --one-off stubs labeled, live-tested flatted@3.3.1 tutorial). CLI_CONTRACT.md maven service-coverage passage synced to SERVICE_ECOSYSTEMS. Co-Authored-By: Claude Fable 5 --- README.md | 889 ++++++++++++++++-------- crates/socket-patch-cli/CLI_CONTRACT.md | 8 +- docs/ecosystems.md | 160 +++++ 3 files changed, 781 insertions(+), 276 deletions(-) create mode 100644 docs/ecosystems.md diff --git a/README.md b/README.md index fd7e55c3..269bf2ae 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,51 @@ # Socket Patch CLI -Apply security patches to your dependencies — npm, PyPI, Cargo, Go, Ruby gems, and more — without waiting for upstream fixes. - -## Choosing a patch mode - -socket-patch delivers the same patched bytes three different ways. The modes differ in *where the patch lives* and *what must happen at install time*; pick one per project (`scan --mode ` drives exactly one mode per run). - -| Mode | Mechanism | User-code changes | CI changes | Offline / airgap | Integrity story | Future GitHub-app default | -|------|-----------|-------------------|------------|------------------|-----------------|---------------------------| -| **hosted** — `scan --mode hosted` | Lockfiles / registry configs are rewritten so **only** the patched dependencies resolve to Socket-hosted, integrity-pinned packages on `patch.socket.dev` | Minimal: lockfile (+ small registry-config) edits; no artifact bytes land in the repo | **None** — your existing install commands pick up the patched packages | ❌ installs must be able to reach `patch.socket.dev` | The package manager's own lockfile verification (sha512 / sha256 / contentHash / CHECKSUMS) pins the hosted bytes. Maven has no lockfile, so it pins by serving the patch under a Socket-only version suffix — fail-closed by repository exclusivity, optionally reinforced with Trusted Checksums (see [Maven & NuGet caveats](#maven--nuget-caveats)) | ✅ planned default for GitHub-app patch PRs — keeps the PR diff small | -| **vendored** — `scan --mode vendored` (or the standalone [`vendor`](#vendor)) | Patched artifacts are committed under `.socket/vendor/` and the lockfile is rewired to consume them | Committed artifacts (the repo grows by the patched package sizes) | **None** | ✅ fully airgapped; no dependency on Socket infrastructure uptime | Committed bytes + recomputed lockfile hashes, verified by the package manager at install time | — | -| **agent** — `scan --mode agent` (or [`apply`](#apply)) | `.socket/manifest.json` + patch blobs are committed; the socket-patch CLI re-applies the patches in CI / postinstall | `.socket/` manifest + blobs committed | **Required** — wire install hooks with [`setup`](#setup) or run `socket-patch apply` in CI | ✅ once blobs are committed (`apply --offline`) | The CLI verifies per-file before/after git-sha256 hashes on every apply | Today's default (the original method) | - -**agent** is the original method and remains fully supported, but it is the only mode that requires CI / install-hook modification — **new projects should prefer hosted or vendored**. - -### Mode × ecosystem support - -| Ecosystem | agent (`--mode agent`) | vendored (`--mode vendored`) | hosted (`--mode hosted`) | -|-----------|------------------------|------------------------------|--------------------------| -| npm (pnpm / yarn / berry / bun) | ✅ any install layout; `setup` postinstall hook | ✅ five lockfile flavors: package-lock, yarn classic, yarn berry (node-modules linker; PnP refused), pnpm v9, bun `bun.lock` (binary `bun.lockb` refused with a `--save-text-lockfile` pointer) | ✅ package-lock / npm-shrinkwrap, pnpm-lock.yaml, yarn classic, **yarn berry** (`yarn.lock` entry only — cacheKey `10c0` / yarn 4, `.yarnrc.yml compressionLevel` must stay 0; node-modules linker is e2e-covered, PnP is untested for hosted — the lock rewrite fires but PnP's `.yarn/cache` resolution isn't exercised), **bun** (text `bun.lock` v1; a binary `bun.lockb` is auto-migrated to text first via your own `bun install --save-text-lockfile`) | -| pypi (uv / poetry / pdm / pipenv / pip) | ✅ `.pth` startup hook via `setup` | ✅ six flavors: uv, poetry, pdm, pipenv, requirements.txt (pip / `uv pip`) | ✅ requirements.txt + uv.lock. **poetry / pdm / pipenv locks are not rewritten** — use vendored | -| cargo | ✅ in-place + `.cargo-checksum.json` rewrite (shared registry-cache caveat — see [`setup`](#setup)) | ✅ `[patch.crates-io]` path entry | ✅ per-patch sparse registry (`[registries.socket-patch-]` + Cargo.lock source/checksum) | -| gem | ✅ Bundler plugin via `setup` | ✅ Gemfile + Gemfile.lock path pair | ✅ per-dep `source` block; the `CHECKSUMS` pin needs bundler ≥ 2.6 (older locks get a `redirect_gem_no_checksums_section` warning) | -| golang | ✅ `go.mod` `replace` → `.socket/go-patches/` | ✅ `replace` → the committed vendor tree | ❌ **not possible** — sumdb, module-path identity, and default-GOPROXY leakage each rule it out; see [docs/design/golang-hosted-no-go.md](docs/design/golang-hosted-no-go.md). **Use vendored** (`redirect_golang_unsupported` names the remedy) | -| maven | ⚠️ experimental — gated behind `SOCKET_EXPERIMENTAL_MAVEN=1` (in-place jar patching corrupts the `~/.m2` checksum sidecars); prefer vendored / hosted | ✅ **new** — committed maven2 `file://` repository. A root pom declaring `` (multi-module aggregator) is refused (`vendor_maven_multimodule_unsupported`), and a gradle-only project is refused (`vendor_gradle_unsupported`) | ✅ **pom projects only, fail-closed** — the patched jar is served under a Socket-only `-socket.` suffix, so the rewriter pins that version (rewrite the literal ``, or add a `` entry for a transitive) alongside the `` insert (`checksumPolicy=fail`). An outage or tamper on the Socket repo then hard-fails the build — the suffixed version exists nowhere else, so there is no silent fall-through to Central. Optionally emits Maven 3.9+ Trusted Checksums files pinning the jar + pom sha256. `${property}` versions are refused. Gradle builds get a paste-able `exclusiveContent` snippet (`redirect_gradle_manual_snippet`); no build script is edited. See [Maven & NuGet caveats](#maven--nuget-caveats) | -| nuget | ⚠️ experimental — gated behind `SOCKET_EXPERIMENTAL_NUGET=1` (in-place patching breaks the `.nupkg.sha512` tamper-evidence sidecar); prefer vendored / hosted | ✅ **new** — committed folder feed + `packageSourceMapping` + `packages.lock.json` contentHash pin | ✅ `nuget.config` source + source-mapping, `packages.lock.json` contentHash rewrite. See the locked-mode note in [Maven & NuGet caveats](#maven--nuget-caveats) | -| composer | ✅ post-install script events | ✅ `composer.lock` `dist: path` rewrite | ✅ `composer.lock` dist url + shasum rewrite | - -> **Maven / NuGet discovery gate**: discovering *installed* Maven and NuGet packages (the crawl behind `scan` / `apply` / `vendor`) currently requires the same `SOCKET_EXPERIMENTAL_MAVEN=1` / `SOCKET_EXPERIMENTAL_NUGET=1` opt-in in every mode. The vendored/hosted wiring itself is safe — the gate guards the agent-mode sidecar risk. - -> **Rush monorepos** (npm): a Rush repo has no root `package.json`/lockfile pair — its single pnpm source-of-truth lock lives at `common/config/rush/pnpm-lock.yaml` (plus one per subspace under `common/config/subspaces//`). **Hosted** ✅ — `scan --mode hosted` discovers and repoints those locks in place (subspaces included). **Agent** ✅ works through the generated project symlink farm. **Vendored** is refused (`vendor_rush_unsupported`): `rush install` copies the lock into `common/temp` and runs pnpm there, so vendor's relative `file:` specs can't survive the copy — the refusal routes you to hosted mode. Editing a Rush lock outside `rush update` desyncs the `pnpmShrinkwrapHash` in `common/config/rush/repo-state.json`, so when `preventManualShrinkwrapChanges` is enabled `rush install` fails until `rush update` refreshes it (a `redirect_rush_repo_state_stale` warning flags this; the redirect survives the refresh — pnpm keeps locked resolutions for unchanged specifiers). - -### Maven & NuGet caveats - -Honest limits of the maven and nuget flows — documented behavior, not bugs: - -* **Fail-closed by version suffixing (hosted maven).** Maven has no lockfile, so hosted mode pins the patch a different way: the Socket serve route exposes the patched jar under a globally-unique `-socket.` suffix that exists **only** on the injected `socket-patch-` repository. The rewriter pins that suffixed version explicitly — it rewrites the literal ``, or (for a transitive / managed dependency with no literal version in your pom) adds a `` entry — so a resolver that can't reach the Socket repo, or is handed different bytes, has nowhere to fall through to: the build **hard-fails** instead of silently resolving the unpatched upstream artifact. The ``'s `checksumPolicy=fail` still verifies the transport-level `.jar.sha1` sidecar on top. A `${property}` version is refused (`redirect_maven_dep_unpinned`) — a literal edit would break the property reference and a depMgmt pin could strand sibling artifacts sharing the property. A literal version that matches neither the base nor the suffixed value is skipped (`redirect_maven_dep_version_mismatch`). -* **Trusted Checksums reinforcement (hosted maven, Maven 3.9+).** When the serve route supplies both the jar and pom sha256, the rewriter also emits Maven [Trusted Checksums](https://maven.apache.org/resolver/expected-checksums.html) files — `.mvn/maven.config` resolver args plus `.mvn/checksums/checksums.sha256` entries pinning both artifacts under the suffixed version's local-repo path (merging into any pre-existing user config / checksum set; a conflicting value is never overridden and surfaces `redirect_maven_trusted_checksums_conflict`). This is an **independent client-side content pin** on top of the transport check. It requires **Maven 3.9+** (the resolver post-processor and the `${session.rootDirectory}` basedir expression the config uses); on older Maven the `.mvn/*` files are silently inert — the version-suffixing above is still fail-closed on its own. On Maven **3.9.0–3.9.8** a *mismatch* is enforced but reported unclearly; the readability fix landed in **3.9.9** ([MNG-8182](https://issues.apache.org/jira/browse/MNG-8182)). The args are `originAware=false` and `failIfMissing=false`, so one checksum matches the artifact from any repository and a dependency with no committed checksum still resolves — only a *mismatch* fails. -* **Warm `~/.m2` shadowing (vendored maven only).** Maven consults the *local repository* before any configured ``, so with vendored mode a warm `~/.m2` copy of the same GAV silently wins over the committed `file://` repository — the build succeeds with **unpatched** bytes. Purge it with: - `mvn dependency:purge-local-repository -DmanualInclude=:` - (the always-on `vendor_maven_local_cache_shadow` warning carries the same one-liner). Hosted mode is **not** affected: the patched jar lives at the suffixed version, which no warm `~/.m2` entry can hold. -* **`mirrorOf` mirrors (hosted maven).** A `settings.xml` `` with `*` (common in corporate environments) reroutes *all* repositories — including the injected `socket-patch-` repository — through the mirror. Because the patch resolves only at the suffixed version, the mirror (which does not carry it) can't serve it and the **build fails loudly** rather than silently going unpatched. Scope the mirror to exclude the Socket repos (e.g. `*,!socket-patch-*`) so the redirect resolves; the `originAware=false` Trusted Checksums act as a backstop when present. -* **Gradle (hosted maven).** Gradle build scripts are never edited. A present `build.gradle*` / `settings.gradle*` gets a paste-able `exclusiveContent { … }` snippet (a `redirect_gradle_manual_snippet` warning) that carries the **suffixed** version — and you must bump the `groupId:artifactId` dependency declaration to that suffixed version yourself. It is fail-closed by repository exclusivity: the `exclusiveContent` filter routes only the suffixed version to the Socket repo, which is the only place it exists. -* **NuGet locked mode (hosted + vendored).** With a `packages.lock.json` and `dotnet restore --locked-mode`, the rewritten `contentHash` pins the patched `.nupkg` — a tampered or wrong package fails restore with `NU1403`. Without a lockfile there is no client-side content pin (vendored surfaces this as a `vendor_nuget_no_lockfile` warning; the feed + source mapping still force the patched copy). +Fix known vulnerabilities in the dependencies you already have — without waiting for an +upstream release, and without a risky version bump. + +Socket's security team backports minimal fixes to the *exact versions* of packages you +have installed. The `socket-patch` CLI finds which of your dependencies have a patch +available and applies it, verifying every changed file by hash. It works across npm, +PyPI, Cargo, Go, RubyGems, Maven, Composer, NuGet, and Deno, and it can persist the patches +whichever way fits your workflow: re-applied by the CLI, committed to your repo, or +pinned in your lockfile. When you're done, it can emit an [OpenVEX +attestation](#openvex-attestations) so your vulnerability scanner stops flagging the +CVEs you've already fixed. + +**Contents:** [Installation](#installation) · [Five-minute tutorial](#five-minute-tutorial) +· [How it works](#how-socket-patch-works) · [Common tasks](#common-tasks) +· [Command reference](#command-reference) · [OpenVEX](#openvex-attestations) +· [Scripting & CI/CD](#scripting--cicd) · [Manifest format](#manifest-format) +· [Ecosystem support →](docs/ecosystems.md) ## Installation -### One-line install (recommended) +One-line install (macOS / Linux): ```bash curl -fsSL https://raw.githubusercontent.com/SocketDev/socket-patch/main/scripts/install.sh | sh ``` -Detects your platform (macOS/Linux, x64/ARM64), downloads the latest binary, and installs to `/usr/local/bin` or `~/.local/bin`. Use `sudo sh` instead of `sh` if `/usr/local/bin` requires root. +Detects your platform (macOS/Linux, x64/ARM64), downloads the latest binary, and installs +to `/usr/local/bin` or `~/.local/bin`. Use `sudo sh` instead of `sh` if `/usr/local/bin` +requires root. + +On Windows, install via npm (below) or grab a prebuilt `socket-patch-*-pc-windows-msvc.zip` +from the [latest release](https://github.com/SocketDev/socket-patch/releases/latest). + +Or install through your package manager: + +| Package manager | Command | +|-----------------|---------| +| npm | `npm install -g @socketsecurity/socket-patch` (or one-shot: `npx @socketsecurity/socket-patch`) | +| pip | `pip install socket-patch` | +| cargo | `cargo install socket-patch-cli` (builds from source with every ecosystem compiled in) | +| gem | `gem install socket-patch` | +| composer | `composer require socketsecurity/socket-patch` (run as `vendor/bin/socket-patch`) | + +The gem and composer packages are thin launchers: on first run they download the prebuilt +binary for your platform from the matching GitHub release, verify its SHA-256, cache it, +and exec it. Set `SOCKET_PATCH_BIN` to an existing binary to skip the download.
Manual download @@ -70,97 +63,310 @@ curl -fsSL https://github.com/SocketDev/socket-patch/releases/latest/download/so curl -fsSL https://github.com/SocketDev/socket-patch/releases/latest/download/socket-patch-x86_64-unknown-linux-musl.tar.gz | tar xz # Linux (ARM64) -curl -fsSL https://github.com/SocketDev/socket-patch/releases/latest/download/socket-patch-aarch64-unknown-linux-gnu.tar.gz | tar xz +curl -fsSL https://github.com/SocketDev/socket-patch/releases/latest/download/socket-patch-aarch64-unknown-linux-musl.tar.gz | tar xz ``` +The musl builds are fully static and run on any distro; glibc (`-gnu`) variants are also +on the releases page, alongside Windows (`socket-patch-x86_64-pc-windows-msvc.zip`) and +other targets. + Then move the binary onto your `PATH`: ```bash sudo mv socket-patch /usr/local/bin/ ``` +The full list of prebuilt targets (Windows, 32-bit ARM, i686, Android) is in +[docs/ecosystems.md](docs/ecosystems.md#supported-platforms). +
-### npm +## Five-minute tutorial + +No account or token is needed to follow along — without an API token the CLI talks to +Socket's public patch proxy. (An API token unlocks your organization's patch tier; see +[Global options](#global-options).) + +**1. Scan your project.** From your project root, ask Socket which of your installed +dependencies have patches available: ```bash -npx @socketsecurity/socket-patch +cd your-project +socket-patch scan ``` -Or install globally: +`scan` crawls the installed packages it finds (`node_modules/`, virtualenvs, the cargo +registry cache, and so on), queries the patch database, prints each available patch with +its package, severity, and CVE/GHSA identifiers, and asks whether to apply. Say yes and +the vulnerable files are rewritten in place — each file is hash-verified before and after +the edit. + +> If it prints `No patches available for installed packages.`, none of your installed +> dependency versions currently has a Socket patch — the good outcome, with nothing to +> apply. To walk the rest of the loop anyway, make a scratch project pinned to a version +> that has a free patch — at the time of writing, `flatted@3.3.1`: +> +> ```bash +> mkdir demo && cd demo && git init -q && npm init -y && npm install flatted@3.3.1 && socket-patch scan +> ``` +> +> (The patch catalog changes over time; if that finds nothing, pick another patched +> version.) + +**2. See what you have.** The applied patches are recorded in `.socket/manifest.json`: ```bash -npm install -g @socketsecurity/socket-patch +socket-patch list ``` -### pip +``` +Found 1 patch(es): + +Package: pkg:npm/flatted@3.3.1 + UUID: 5cac955f-eab1-4d29-8f4f-c408a6cc9647 + ... + Vulnerabilities (1): + - GHSA-25h7-pfq9-p65f (CVE-2026-32141) + Severity: HIGH +``` + +**3. Make it stick.** Patches applied in place don't survive a reinstall — the next +`npm install` (or `pip install`, `bundle install`, …) restores the vulnerable upstream +bytes. Commit the `.socket/` directory and wire an install hook so patches re-apply +automatically: ```bash -pip install socket-patch +socket-patch setup # e.g. adds a postinstall script for npm projects +echo '.socket/apply.lock' >> .gitignore # lock state, not part of the patch record +git add .gitignore .socket package.json # npm example — setup prints which files it changed +git commit -m "apply Socket security patches" ``` -### Cargo +From now on, every install — yours, your teammates', CI's — re-applies the patches. You +can also re-apply manually at any time with `socket-patch apply` (it's idempotent). + +**4. Undo, if you want.** Remove a patch completely (restores the original files and +deletes the manifest entry): ```bash -cargo install socket-patch-cli +socket-patch remove "pkg:npm/flatted@3.3.1" ``` -This builds with every supported ecosystem (npm, PyPI, Ruby gems, Cargo, -Go, Maven, Composer, NuGet, Deno) compiled in. +That's the whole loop: **scan → apply when prompted → setup → commit**. This tutorial used the default +*agent* mode, where the CLI re-applies patches after each install. There are two other +ways to persist patches — committing the patched packages themselves (*vendored*) or +pinning them in your lockfile (*hosted*) — and choosing between the three is the next +section. + +## How Socket Patch works + +**A patch** is a minimal fix — usually the upstream security fix, backported — for one +exact published version of a package. Socket distributes it as per-file edits: for each +touched file, the hash of the expected original (`beforeHash`), the hash of the patched +result (`afterHash`), and the replacement content. By default, a file whose current +content matches neither the expected original nor the patched result is overwritten with +the full verified patched content plus a stderr warning (`content_mismatch_overwritten`); +pass `--strict` (a [global option](#global-options)) to fail closed on mismatch instead, +or `apply --force` to skip pre-application hash verification entirely (see +[`apply`](#apply)). Either way the CLI verifies the result after writing. Patches are looked up by package URL +([PURL](https://github.com/package-url/purl-spec)) — e.g. `pkg:npm/lodash@4.17.20` — so +everything is keyed to exact versions. + +**Local state lives in `.socket/`** at your project root, and is designed to be +committed: + +| Path | Contents | +|------|----------| +| `.socket/manifest.json` | The record of downloaded patches: PURLs, file hashes, vulnerability metadata ([format](#manifest-format)) | +| `.socket/blobs/` | Patched file contents, named by git-sha256 hash | +| `.socket/vendor/` | Vendored package artifacts and the vendor/redirect ledgers (only in vendored/hosted modes) | + +> Mutating commands also leave a `.socket/apply.lock` file there between runs. It is +> lock state, not part of the patch record — add it to your `.gitignore` +> ([`repair`](#repair) deletes it). + +### Three patch modes + +The same patched bytes can reach your build three different ways. The modes differ in +*where the patch lives* and *what must happen at install time*; pick one per project +(`scan --mode ` drives exactly one mode per run). + +| Mode | Where the patch lives | Install-time requirement | Trade-off | +|------|----------------------|--------------------------|-----------| +| **agent** — `scan --mode agent` (or [`apply`](#apply)) | `.socket/` manifest + blobs, committed; the CLI re-applies after each install | The `socket-patch` CLI must run (install hook via [`setup`](#setup), or an `apply` step in CI) | Small repo footprint (per-file blobs, not whole packages); no lockfile edits; the only mode that needs CI / install-hook changes | +| **vendored** — `scan --mode vendored` (or [`vendor`](#vendor)) | Patched packages committed under `.socket/vendor/`; the lockfile is rewired to consume them | **None** — the package manager installs the committed bytes | Fully airgapped and hermetic, at the cost of repo size | +| **hosted** — `scan --mode hosted` | No patched bytes in your repo: the lockfile is rewritten so **only** the patched dependencies resolve to Socket-hosted, integrity-pinned packages on `patch.socket.dev`; the edits + patch records are ledgered in `.socket/vendor/redirect-state.json` (commit it — [`vex`](#vex) reads it, and it records the pre-redirect originals a future revert feature will need; hosted has no CLI revert yet, see [Undo things](#undo-things)) | Installs must be able to reach `patch.socket.dev` (no CLI, no install hook) | Smallest possible diff (lockfile + ledger); not for airgapped installs | + +Every mode pins the patched bytes: in agent mode the CLI verifies every file on each +apply; vendored and hosted modes lean on your package manager's own lockfile integrity +checks (sha512 / sha256 / contentHash / CHECKSUMS) where the ecosystem enforces them — +hosted Maven, which has no lockfile, gets a fail-closed version-suffixing scheme instead. +A few combinations have weaker install-time pins (vendored Maven, NuGet without a +lockfile, Go's directory replaces, pipenv's Pipfile.lock) — there the committed bytes +are the protection; see the [per-ecosystem caveats](docs/ecosystems.md). + +**Choosing:** *agent* is the original method and remains fully supported, but it is the +only mode that requires CI / install-hook modification — **new projects should prefer +hosted or vendored**. Pick *vendored* if your builds are airgapped or you don't want an +infrastructure dependency; pick *hosted* if you want the smallest diff and your installs +can reach `patch.socket.dev`. (Hosted is the planned default for GitHub-app patch PRs — +it keeps the PR diff small.) + +Mode support varies by ecosystem — e.g. Go can't do hosted, Rush monorepos can't do +vendored. See the full **[mode × ecosystem matrix](docs/ecosystems.md#mode--ecosystem-matrix)** +for details and per-ecosystem caveats. + +## Common tasks + +### Patch everything that can be patched + +```bash +socket-patch scan # interactive: prompts before applying +socket-patch scan --json --mode agent --yes # non-interactive (CI, scripts) +``` -### RubyGems +### Patch one specific CVE, advisory, or package ```bash -gem install socket-patch +socket-patch get CVE-2024-12345 +socket-patch get GHSA-xxxx-yyyy-zzzz +socket-patch get lodash # fuzzy-matches installed packages +socket-patch get "pkg:npm/lodash@4.17.20" ``` -A thin launcher gem: on first run it downloads the prebuilt binary for your -platform from the matching GitHub release, verifies its SHA-256, caches it, and -execs it. Set `SOCKET_PATCH_BIN` to an existing binary to skip the download. +`socket-patch ` with a bare patch UUID is a shortcut for `get `. -### Composer +### Keep patches applied across installs ```bash -composer require socketsecurity/socket-patch -vendor/bin/socket-patch --help +socket-patch setup # wire install hooks (npm postinstall, Python .pth, …) +socket-patch setup --check # CI gate: exit non-zero if hooks are missing or a patch drifted ``` -Same launcher model as the RubyGems package (download-on-first-run, cached, -`SOCKET_PATCH_BIN` to bypass). +See [`setup`](#setup) for what gets wired per ecosystem — and which ecosystems (Cargo, +Go, Maven, NuGet, Deno) have no hook and are patched on demand instead. -## Quick Start +### Persist patches with no CI or install-hook changes (vendored / hosted) -You can pass a patch UUID directly to `socket-patch` as a shortcut: +```bash +# Vendored: commit the patched packages themselves (airgap-friendly) +socket-patch scan --json --mode vendored --yes +echo '.socket/apply.lock' >> .gitignore +git add .gitignore .socket package-lock.json # your lockfile may differ + +# Hosted: smallest diff — patched deps resolve from patch.socket.dev +socket-patch scan --json --mode hosted --yes +git add .socket/vendor/redirect-state.json package-lock.json +``` + +No `setup` hook or CI `apply` step is needed — the package manager installs the patched +bytes. See [Three patch modes](#three-patch-modes) to choose, and the +[mode × ecosystem matrix](docs/ecosystems.md#mode--ecosystem-matrix) for what your +ecosystem supports. + +### Run an auto-update bot in CI + +One command discovers, applies, and garbage-collects in a single pass: ```bash -socket-patch 550e8400-e29b-41d4-a716-446655440000 -# equivalent to: socket-patch get 550e8400-e29b-41d4-a716-446655440000 +socket-patch scan --json --mode agent --prune --yes +``` + +The working-tree changes (the `.socket/` directory — plus lockfile edits if your bot +runs `--mode vendored` or `--mode hosted`) are what your PR tooling commits — e.g. +`peter-evans/create-pull-request` picks them up automatically; use the JSON summary for +the PR title/body. See [Scripting & CI/CD](#scripting--cicd). + +### Tell your vulnerability scanner about the patches + +```bash +socket-patch vex --output socket.vex.json +grype --vex socket.vex.json # or trivy image --vex ... ``` -## Global Options +The OpenVEX document marks each patched CVE `not_affected`, so scanners stop flagging +vulnerabilities you've already remediated. You can also emit it inline from `apply` / +`scan` / `vendor` with `--vex `. Details in [OpenVEX +attestations](#openvex-attestations). -These flags are accepted by **every** subcommand — they are flattened into each command's argument set, so `socket-patch --json --cwd ./app` works uniformly. A command silently ignores any global flag it doesn't use (e.g. `list --global` parses fine and the flag is a no-op). +### Work offline / airgapped -Each flag has a matching `SOCKET_*` environment variable. **Precedence is CLI arg > env var > default**, so a flag on the command line always wins over the environment. +Vendored mode needs no Socket infrastructure and no `socket-patch` binary at install +time — the patched packages install from the committed bytes (other, unvendored +dependencies still resolve from your registry or mirror as usual). Agent mode works +offline once the blobs are committed: + +```bash +socket-patch apply --offline # strict airgap: fails loudly if anything needs the network +``` + +`scan` and `get` inherently need the network and refuse to run with `--offline`. + +### Undo things + +Five commands clean up different layers — the first three undo, the last two reconcile +and repair; pick by what you want back: + +| Command | What it does | +|---------|--------------| +| [`rollback`](#rollback) | Restores the original file bytes but **keeps the manifest entry** — the next `apply` re-applies the patch | +| [`remove`](#remove) | Everything `rollback` does, **plus** it deletes the manifest entry and reverts any vendoring — **permanent**, the patch is fully gone in one command | +| [`vendor --revert`](#vendor) | **Un-vendors wholesale**: restores the recorded original lockfile fragments byte-for-byte and removes the `.socket/vendor/` artifacts — works without a manifest | +| [`scan --prune`](#scan) | **Reconciles, doesn't reverse**: drops manifest entries for packages that have left the project and garbage-collects orphan blob/diff/archive files — installed patches stay | +| [`repair`](#repair) (alias `gc`) | **Restores health, not originals**: re-downloads missing blobs, rebuilds missing/corrupt vendored artifacts, cleans up unused ones, and removes the leftover `apply.lock` file (housekeeping — mutating commands leave it behind after every run) | + +And `setup --remove` reverts the install hooks that `setup` added. + +> Hosted mode has no CLI revert yet: `scan --mode hosted` makes plain lockfile / +> registry-config edits, so undo them with your version control (e.g. +> `git checkout -- `) and delete the `.socket/vendor/redirect-state.json` +> ledger — once you've reverted by hand, its recorded original fragments are stale, and +> a leftover ledger would still let [`vex`](#vex) attest the removed redirects. + +## Command reference + +| Command | What it does | +|---------|--------------| +| [`scan`](#scan) | Scan installed packages for available security patches | +| [`apply`](#apply) | Apply security patches from the local manifest | +| [`vex`](#vex) | Generate an OpenVEX attestation for the applied patches | +| [`vendor`](#vendor) | Eject patched dependencies into committable `.socket/vendor/` | +| [`setup`](#setup) | Wire install hooks so patches re-apply automatically | +| [`rollback`](#rollback) | Restore original files (keeps the manifest) | +| [`get`](#get) | Fetch and apply a patch by UUID / CVE / GHSA / PURL / name (alias: `download`) | +| [`list`](#list) | List all patches in the local manifest | +| [`remove`](#remove) | Remove a patch: roll back files + delete the manifest entry | +| [`repair`](#repair) | Download missing blobs, clean up unused ones, tidy lock state (alias: `gc`) | + +### Global options + +These flags are accepted by **every** subcommand and go after the command name — +`socket-patch --json --cwd ./app` works uniformly (`socket-patch --json +` is a parse error). A command silently ignores any global flag it doesn't use +(e.g. `list --global` parses fine and the flag is a no-op). + +Each flag has a matching `SOCKET_*` environment variable, listed in the table; +command-specific flags list theirs in each command's own table. **Precedence is CLI arg +> env var > default.** | Flag | Env var | Description | |------|---------|-------------| | `--cwd ` | `SOCKET_CWD` | Working directory (default: `.`). The manifest path is resolved relative to this. | | `--manifest-path ` | `SOCKET_MANIFEST_PATH` | Path to the patch manifest, resolved relative to `--cwd` (default: `.socket/manifest.json`). | | `--api-url ` | `SOCKET_API_URL` | Socket API URL for the authenticated endpoint (default: `https://api.socket.dev`). | -| `--api-token ` | `SOCKET_API_TOKEN` | Socket API token. When omitted, the public patch proxy is used. | +| `--api-token ` | `SOCKET_API_TOKEN` | Socket API token — create one in the [Socket dashboard](https://socket.dev) under your organization's API tokens settings. Use the raw token (`sktsec_<...>_api`) shown at generation time, **not** the `sha512-...` display hash. When omitted, the public patch proxy is used. | | `-o, --org ` | `SOCKET_ORG_SLUG` | Organization slug. Auto-resolved when omitted and a token is set. | -| `--proxy-url ` | `SOCKET_PROXY_URL` | Public proxy URL used when no API token is set. | -| `-e, --ecosystems ` | `SOCKET_ECOSYSTEMS` | Restrict to specific ecosystems (comma-separated, e.g. `npm,pypi`). | +| `--proxy-url ` | `SOCKET_PROXY_URL` | Public proxy URL used when no API token is set (default: `https://patches-api.socket.dev`). | +| `-e, --ecosystems ` | `SOCKET_ECOSYSTEMS` | Restrict to specific ecosystems (comma-separated, e.g. `npm,pypi`). Unknown names are rejected. | | `--download-mode ` | `SOCKET_DOWNLOAD_MODE` | Artifact to fetch when local files are missing: `diff` (default, smallest delta), `package` (full per-package tarball), or `file` (legacy per-file blobs). | -| `--vendor-source ` | `SOCKET_VENDOR_SOURCE` | How `vendor` acquires the installable artifact: `auto` (default — download the prebuilt package from patch.socket.dev, fall back to a local build on any miss), `service` (require the service, fail-closed), or `build` (always build locally). Covers npm, pypi, cargo, golang, composer, gem, and nuget (maven attempts the prebuilt download under `auto` but is not covered by fail-closed `service`). | +| `--vendor-source ` | `SOCKET_VENDOR_SOURCE` | How `vendor` acquires the installable artifact: `auto` (default — download the prebuilt package from patch.socket.dev, fall back to a local build on any miss), `service` (require the service, fail-closed), or `build` (always build locally). Covers npm, pypi, cargo, golang, composer, gem, nuget, and maven. | | `--vendor-url ` | `SOCKET_VENDOR_URL` | Base host for the vendoring service's package-reference request (default: the active `--api-url`/`--proxy-url` base). Point at staging / local dev for testing. | | `--patch-server-url ` | `SOCKET_PATCH_SERVER_URL` | Override the host of the prebuilt-archive download URL the service returns (default: as returned). Mainly for local-dev / testing. | | `--offline` | `SOCKET_OFFLINE` | Strict airgap: never contact the network. Operations that need remote data fail loudly. | | `--strict` | `SOCKET_STRICT` | Fail-closed on before-hash mismatches instead of the default warn-and-overwrite: a file whose current content matches neither `beforeHash` nor `afterHash` aborts that package's apply. Overridden by `--force`. | | `-g, --global` | `SOCKET_GLOBAL` | Operate on globally-installed packages. | | `--global-prefix ` | `SOCKET_GLOBAL_PREFIX` | Override the path used to discover globally-installed packages. | -| `-j, --json` | `SOCKET_JSON` | Emit machine-readable JSON output. Every JSON response includes a `"status"` field — camelCase on the envelope commands (`"success"`, `"error"`, `"noManifest"`, `"partialFailure"`; apply/list/repair/remove/vendor), snake_case on the legacy shapes (`"partial_failure"`, `"not_found"`; get/scan/rollback/setup). See [CLI_CONTRACT.md](crates/socket-patch-cli/CLI_CONTRACT.md) for the exact shapes. | +| `-j, --json` | `SOCKET_JSON` | Emit machine-readable JSON output. Every JSON response includes a `"status"` field — camelCase on the envelope commands (`"success"`, `"error"`, `"noManifest"`, `"partialFailure"`, `"paidRequired"`, `"notFound"`; apply/list/repair/remove/vendor), snake_case on the legacy shapes (`"partial_failure"`, `"not_found"`; get/scan/rollback/setup). See [CLI_CONTRACT.md](crates/socket-patch-cli/CLI_CONTRACT.md) for the exact shapes. | | `-v, --verbose` | `SOCKET_VERBOSE` | Show extra detail in human-readable output. | | `-s, --silent` | `SOCKET_SILENT` | Suppress non-error output. | | `--dry-run` | `SOCKET_DRY_RUN` | Preview the operation without making any mutations. | @@ -169,33 +375,50 @@ Each flag has a matching `SOCKET_*` environment variable. **Precedence is CLI ar | `--debug` | `SOCKET_DEBUG` | Emit verbose debug logs to stderr. | | `--no-telemetry` | `SOCKET_TELEMETRY_DISABLED` | Disable anonymous usage telemetry. | -## Commands - -The tables below list only the **command-specific** flags. Every command also accepts the [Global Options](#global-options) above. +The sections below list only each command's **command-specific** flags. ### `scan` -Scan installed packages for available security patches. `scan --mode agent --prune` is the single command bots need for full auto-update: it discovers patches, applies them, and garbage-collects orphan blob files plus manifest entries for uninstalled packages — all in one invocation. +Scan installed packages for available security patches — and, with `--mode`, act on what +it finds. `scan` is the entry point for all three [patch modes](#three-patch-modes): + +- `--mode agent` downloads and applies the selected patches in place; +- `--mode vendored` discovers, downloads, and builds + wires the committable + `.socket/vendor/` artifacts in one pass (re-vendoring automatically when a newer patch + is selected); +- `--mode hosted` rewrites lockfiles / registry configs so only the patched dependencies + resolve to Socket-hosted packages. + +Without a mode, interactive `scan` prompts before applying, and `scan --json` is +read-only (discovery plus an `updates[]` array; no mutation). -`scan` is also the entry point for all three [patch modes](#choosing-a-patch-mode): `--mode agent` applies in place, `--mode vendored` commits artifacts, `--mode hosted` rewrites lockfiles to Socket-hosted packages. +`scan --mode agent --prune` is the single command bots need for full auto-update: it +discovers patches, applies them, and garbage-collects orphan blob files plus manifest +entries for uninstalled packages — all in one invocation. **Usage:** ```bash socket-patch scan [options] ``` -**Command-specific options** (plus all [Global Options](#global-options)): -| Flag | Description | -|------|-------------| -| `--mode ` | The selector for the three [patch modes](#choosing-a-patch-mode). `agent` downloads and applies selected patches in place (non-interactive; without it, `scan --json` is read-only); `vendored` discovers, downloads, and builds + wires the committable `.socket/vendor/` artifacts in one pass (re-vendors automatically when a newer patch is selected); `hosted` rewrites lockfiles / registry configs so **only** the patched dependencies resolve to Socket-hosted, integrity-pinned packages (no artifact bytes land in the repo; the recorded edits + patch records go to the `.socket/vendor/redirect-state.json` ledger so a post-install [`vex`](#vex) can attest them). Combining `--mode` with a legacy boolean flag of a *different* mode is an error (exit 2); the same mode spelled both ways is accepted. Legacy boolean spellings (`--apply` == agent, `--vendor` == vendored, `--sync` == agent + prune) remain supported for back-compat. | -| `--prune` | Garbage-collect after the scan: remove manifest entries for uninstalled packages and orphan blob/diff/package-archive files. Off by default. [Vendored](#vendor) packages are never pruned. Orthogonal to `--mode` — combines with any mode. | -| `--detached` | With `--mode vendored`: skip all `.socket/manifest.json` writes — the vendor ledger embeds the patch records instead. For projects that want the vendored patches *only* in the lockfile + `.socket/vendor/`. | -| `--batch-size ` | Packages per API request (default: `100`) | -| `--all-releases` | Store patches for every release/distribution variant, not just the installed one — makes the manifest portable across environments (e.g. cross-platform CI caches) | -| `--vex ` | On a successful scan, also write an OpenVEX 0.2.0 document to this path. See [Inline VEX generation](#inline-vex-on-apply--scan--vendor). (env: `SOCKET_VEX`) | -| `--vex-product`, `--vex-no-verify`, `--vex-doc-id`, `--vex-compact` | Passthrough to the embedded VEX builder; mirror the standalone [`vex`](#vex) knobs. Inert unless `--vex` is set. | - -> Use `--dry-run` to preview what a `--mode agent` / `--mode vendored` / `--mode hosted` run (with or without `--prune`) would do without mutating disk. +**Command-specific options** (plus all [Global options](#global-options)): +| Flag | Env var | Description | +|------|---------|-------------| +| `--mode ` | — | Selects one of the three [patch modes](#three-patch-modes), summarized above. Combining `--mode` with a legacy boolean flag of a *different* mode is an error (exit 2); the same mode spelled both ways is accepted. | +| `--prune` | — | Garbage-collect after the scan: remove manifest entries for packages no longer present in the crawl (installed trees + lockfiles — a wiped `node_modules` alone doesn't prune lockfile-listed entries) and delete orphan blob/diff/package-archive files. Off by default. [Vendored](#vendor) packages are exempt from the crawl-based prune (an absent installed copy is their normal state), but a vendored entry whose dependency has left the lockfile is reverted and its manifest entry dropped. Orthogonal to `--mode` — combines with any mode. | +| `--detached` | — | With `--mode vendored`: skip all `.socket/manifest.json` writes — the vendor ledger embeds the patch records instead. For projects that want the vendored patches *only* in the lockfile + `.socket/vendor/`. Detached patches are invisible to `apply`/`rollback`/`repair`; undo them with `remove ` or `vendor --revert`. | +| `--batch-size ` | `SOCKET_BATCH_SIZE` | Packages per API request (default: `100`). | +| `--all-releases` | `SOCKET_ALL_RELEASES` | Store patches for every release/distribution variant, not just the installed one — PyPI wheel/sdist, RubyGems platform, Maven classifier. Makes the manifest portable across environments (e.g. cross-platform CI caches). | +| `--vex ` | `SOCKET_VEX` | On a successful scan, also write an OpenVEX 0.2.0 document to this path. See [Inline VEX generation](#inline-vex-on-apply--scan--vendor). | +| `--vex-product`, `--vex-no-verify`, `--vex-doc-id`, `--vex-compact` | `SOCKET_VEX_*` | Passthrough to the embedded VEX builder; mirror the standalone [`vex`](#vex) knobs. Inert unless `--vex` is set. | + +> Deprecated boolean spellings of `--mode` remain supported for back-compat: `--apply` +> (== `--mode agent`) and `--vendor` (== `--mode vendored`); prefer `--mode`. `--sync` +> is not deprecated — it is convenience sugar for `--mode agent` + `--prune`, the +> single-flag bot invocation (`scan --json --sync --yes`). + +> Use `--dry-run` to preview what any moded run (with or without `--prune`) would do +> without mutating disk. **Examples:** ```bash @@ -208,7 +431,7 @@ socket-patch scan --json # Agent mode: discover + apply patches in place (non-interactive) socket-patch scan --json --mode agent --yes -# Bot mode: discover, apply, prune, sweep — all in one +# Auto-update bot: discover, apply, garbage-collect — all in one socket-patch scan --json --mode agent --prune --yes # Preview an agent-mode + prune run without mutating disk @@ -240,25 +463,27 @@ socket-patch scan --json --mode vendored --yes --dry-run socket-patch scan --json --mode hosted --yes ``` -> Already-vendored packages are **skipped by plain `--mode agent`** (the committed artifact -> is the patch); a newer available patch still appears in the JSON `updates[]` array — re-run -> `scan --mode vendored` to take it. +> Already-vendored packages are **skipped by plain `--mode agent`** (the committed +> artifact is the patch); a newer available patch still appears in the JSON `updates[]` +> array — re-run `scan --mode vendored` to take it. ### `apply` -Apply security patches from the local manifest. +Apply security patches from the local manifest. Idempotent — safe to run from install +hooks and CI on every build. **Usage:** ```bash socket-patch apply [options] ``` -**Command-specific options** (plus all [Global Options](#global-options)): -| Flag | Description | -|------|-------------| -| `-f, --force` | Skip pre-application hash verification (apply even if package version differs) | -| `--vex ` | On a successful apply, also write an OpenVEX 0.2.0 document to this path. See [Inline VEX generation](#inline-vex-on-apply--scan--vendor). (env: `SOCKET_VEX`) | -| `--vex-product`, `--vex-no-verify`, `--vex-doc-id`, `--vex-compact` | Passthrough to the embedded VEX builder; mirror the standalone [`vex`](#vex) knobs. Inert unless `--vex` is set. | +**Command-specific options** (plus all [Global options](#global-options)): +| Flag | Env var | Description | +|------|---------|-------------| +| `-f, --force` | `SOCKET_FORCE` | Skip pre-application hash verification (apply even if package version differs). | +| `--check` | — | Read-only audit that the committed **Go** `replace`-redirects match the manifest (for CI / GitHub-App auditing) — Go only, since cargo patches in place and has no redirect to audit. Lock-free, crawl-free, and offline-safe: exits 0 in sync, 1 on drift. Vendored modules are excluded from the audit. | +| `--vex ` | `SOCKET_VEX` | On a successful apply, also write an OpenVEX 0.2.0 document to this path. See [Inline VEX generation](#inline-vex-on-apply--scan--vendor). | +| `--vex-product`, `--vex-no-verify`, `--vex-doc-id`, `--vex-compact` | `SOCKET_VEX_*` | Passthrough to the embedded VEX builder; mirror the standalone [`vex`](#vex) knobs. Inert unless `--vex` is set. | **Examples:** ```bash @@ -282,26 +507,28 @@ socket-patch apply --vex socket.vex.json ``` > Packages managed by [`vendor`](#vendor) are skipped (`skipped`/`vendored` in JSON): the -> committed vendored artifact is the patch, so there is nothing for `apply` to do — even when -> the installed tree (e.g. `node_modules/`) is absent. +> committed vendored artifact is the patch, so there is nothing for `apply` to do — even +> when the installed tree (e.g. `node_modules/`) is absent. ### `vex` -Generate an [OpenVEX](https://github.com/openvex) 0.2.0 attestation describing the vulnerabilities that the applied patches have mitigated. See [OpenVEX attestations](#openvex-attestations) below for the full workflow. +Generate an [OpenVEX](https://github.com/openvex) 0.2.0 attestation describing the +vulnerabilities that the applied patches have mitigated. See [OpenVEX +attestations](#openvex-attestations) below for the full workflow. **Usage:** ```bash socket-patch vex [options] ``` -**Command-specific options** (plus all [Global Options](#global-options)): -| Flag | Description | -|------|-------------| -| `-O, --output ` | Write the VEX document to this path instead of stdout. Required when combined with `--json`. (env: `SOCKET_VEX_OUTPUT`) | -| `--product ` | Override the auto-detected top-level product PURL/identifier. (env: `SOCKET_VEX_PRODUCT`) | -| `--no-verify` | Skip the on-disk file-hash check and trust the manifest — useful on a build machine that doesn't have the patched files laid out. (env: `SOCKET_VEX_NO_VERIFY`) | -| `--doc-id ` | Override the document `@id`. Default is a random `urn:uuid:` regenerated each run; pin this for a reproducible identifier. (env: `SOCKET_VEX_DOC_ID`) | -| `--compact` | Emit compact JSON instead of pretty-printed. (env: `SOCKET_VEX_COMPACT`) | +**Command-specific options** (plus all [Global options](#global-options)): +| Flag | Env var | Description | +|------|---------|-------------| +| `-O, --output ` | `SOCKET_VEX_OUTPUT` | Write the VEX document to this path instead of stdout. Required when combined with `--json`. | +| `--product ` | `SOCKET_VEX_PRODUCT` | Override the auto-detected top-level product PURL/identifier. | +| `--no-verify` | `SOCKET_VEX_NO_VERIFY` | Skip the on-disk file-hash check and trust the manifest — useful on a build machine that doesn't have the patched files laid out. | +| `--doc-id ` | `SOCKET_VEX_DOC_ID` | Override the document `@id`. Default is a random `urn:uuid:` regenerated each run; pin this for a reproducible identifier. | +| `--compact` | `SOCKET_VEX_COMPACT` | Emit compact JSON instead of pretty-printed. | **Examples:** ```bash @@ -321,47 +548,47 @@ socket-patch vex --no-verify --output socket.vex.json ### `vendor` `apply`'s **committable** sibling — the standalone command behind -[vendored mode](#choosing-a-patch-mode) (`scan --mode vendored` runs discovery + this engine in -one pass). Instead of patching installed packages in place -(machine-local state), `vendor` ejects each patched package into -`.socket/vendor///…` and rewires your lockfile so the project consumes -the vendored copy. Commit `.socket/vendor/` plus the lockfile edits and **every fresh checkout -builds with the patched dependency** — no `socket-patch` binary, no Socket API access, no +[vendored mode](#three-patch-modes) (`scan --mode vendored` runs discovery + this engine +in one pass). Instead of patching installed packages in place (machine-local state), +`vendor` ejects each patched package into `.socket/vendor///…` and +rewires your lockfile so the project consumes the vendored copy. Commit `.socket/` — the +vendored artifacts plus the manifest that [`vex`](#vex), [`list`](#list), and +[`repair`](#repair) read — along with the lockfile edits, and **every fresh checkout +builds with the patched dependency**: no `socket-patch` binary, no Socket API access, no install hook required on the consuming machine. -Supported ecosystems: **npm** (package-lock / yarn classic / yarn berry / pnpm / bun), **PyPI** -(uv / poetry / pdm / pipenv / requirements.txt), **RubyGems**, **Cargo**, **Go**, -**Composer**, **Maven** (single-module pom projects — multi-module aggregators and gradle-only -projects are refused), and **NuGet**. Vendoring is per-patch: only dependencies with a Socket -patch are vendored. +Vendoring is per-patch: only dependencies with a Socket patch are vendored. For the +lockfile flavors each ecosystem supports, see the +[mode × ecosystem matrix](docs/ecosystems.md#mode--ecosystem-matrix). **Usage:** ```bash socket-patch vendor [options] ``` -**Command-specific options** (plus all [Global Options](#global-options)): -| Flag | Description | -|------|-------------| -| `-f, --force` | Skip pre-vendor hash verification (vendor even if the installed files differ from the patch's `beforeHash`) | -| `--revert` | Undo vendoring: restore the recorded original lockfile fragments byte-for-byte and remove the `.socket/vendor/` artifacts. Works without a manifest | -| `--vex ` | On a successful vendor, also write an OpenVEX 0.2.0 document to this path (env: `SOCKET_VEX`) | -| `--vex-product`, `--vex-no-verify`, `--vex-doc-id`, `--vex-compact` | Passthrough to the embedded VEX builder. Inert unless `--vex` is set. | +**Command-specific options** (plus all [Global options](#global-options)): +| Flag | Env var | Description | +|------|---------|-------------| +| `-f, --force` | `SOCKET_FORCE` | Tolerate *missing* patch-target files in the staged copy (skipped instead of failing the vendor) and bypass the variant probe for multi-release ecosystems. A plain before-hash mismatch doesn't need this: vendor staging always overwrites mismatched content with the verified patched bytes (surfaced as a `vendor_content_mismatch_overwritten` warning). | +| `--revert` | `SOCKET_VENDOR_REVERT` | Undo vendoring: restore the recorded original lockfile fragments byte-for-byte and remove the `.socket/vendor/` artifacts. Works without a manifest. | +| `--vex ` | `SOCKET_VEX` | On a successful vendor, also write an OpenVEX 0.2.0 document to this path. | +| `--vex-product`, `--vex-no-verify`, `--vex-doc-id`, `--vex-compact` | `SOCKET_VEX_*` | Passthrough to the embedded VEX builder. Inert unless `--vex` is set. | -**How it interacts with the rest of the CLI** — once a package is vendored, `vendor` owns it: +**How it interacts with the rest of the CLI** — once a package is vendored, `vendor` owns +it: -- [`apply`](#apply) and [`rollback`](#rollback) skip vendored packages (they never touch a - vendor-owned tree or lockfile entry). +- [`apply`](#apply) and [`rollback`](#rollback) skip vendored packages (they never touch + a vendor-owned tree or lockfile entry). - [`remove`](#remove) **reverts the vendoring** as part of removing the patch — lockfile restored, artifact deleted — so one command fully undoes it. -- [`scan`](#scan) skips downloading/applying patches for vendored packages and never prunes - their manifest entries; newer patches show up in `updates[]` as the signal to re-run - `scan --mode vendored`. +- [`scan`](#scan) skips downloading/applying patches for vendored packages, and + `--prune` exempts them from its crawl-based prune (though a vendored entry whose + dependency has left the lockfile is reverted and dropped); newer patches show up in + `updates[]` as the signal to re-run `scan --mode vendored`. - [`vex`](#vex) attests vendored patches by verifying the **committed artifact** (marked - `(vendored)` in the impact statement) — no `setup` install hook needed, because the lockfile - wiring *is* the persistence mechanism. -- Re-running `vendor` is idempotent; patches dropped from the manifest are auto-reverted on the - next run. + `(vendored)` in the impact statement) — no `setup` install hook needed. +- Re-running `vendor` is idempotent; patches dropped from the manifest are auto-reverted + on the next run. **Examples:** ```bash @@ -371,8 +598,9 @@ socket-patch vendor # Preview without writing anything socket-patch vendor --dry-run -# Then make it stick: commit the artifacts and the rewired lockfile -git add .socket/vendor package-lock.json && git commit -m "vendor Socket patches" +# Then make it stick: commit .socket/ (vendor artifacts + manifest) and the lockfile +# (gitignore .socket/apply.lock — see "How Socket Patch works") +git add .socket package-lock.json && git commit -m "vendor Socket patches" # Undo everything (restores the original lockfile byte-for-byte) socket-patch vendor --revert @@ -381,19 +609,60 @@ socket-patch vendor --revert socket-patch vendor --json ``` -> Prefer one command? [`scan --mode vendored`](#scan) discovers, downloads, *and* vendors in a single -> pass. +> Prefer one command? [`scan --mode vendored`](#scan) discovers, downloads, *and* vendors +> in a single pass. ### `setup` -Configure your project so patches are **re-applied automatically after install** — no manual `socket-patch apply` step in CI. `setup` is a one-time operation: run it, commit the change together with your `.socket/` patches, and every later install handles the rest. It is strictly **opt-in** — nothing is hooked unless you run `setup` and commit the result. - -- **npm / yarn / pnpm / bun** — writes a `postinstall` script into `package.json` so any install re-applies patches (pnpm: root package only). -- **Python (pip / uv / poetry / pdm / hatch)** — Python has no universal post-install hook, so `setup` instead commits a **`socket-patch[hook]`** dependency (for classic Poetry, the equivalent `socket-patch = { extras = ["hook"] }`). Installing it lays down a startup `.pth` (shipped by the small `socket-patch-hook` wheel) that re-applies your committed `.socket/` patches the next time the interpreter runs. It is package-manager-agnostic (it rides the interpreter, not any one installer) and **fail-open** — a hook error can never break interpreter startup. -- **Ruby gems (Bundler)** — adds a managed `plugin "socket-patch"` block to the `Gemfile` and commits an in-tree Bundler plugin under `.socket/bundler-plugin/`. It re-applies patches on every `bundle install` (cached *and* fresh). (Requires the `socket-patch` CLI on `PATH`.) -- **Composer (PHP)** — appends `socket-patch apply` to `composer.json`'s `post-install-cmd` / `post-update-cmd` script events, so patches re-apply on every `composer install` / `composer update`. (Requires the `socket-patch` CLI on `PATH`.) -- **Cargo & Go** — *apply-only, no `setup` hook.* A one-click auto-repatch-on-build isn't possible for these, so `setup` skips them. Patch with `socket-patch apply` directly: **cargo** patches the crate in place (in `vendor/` or the registry cache, rewriting `.cargo-checksum.json` so `cargo build` accepts it); **go** writes a project-local patched copy under `.socket/go-patches/` plus a `go.mod` `replace` directive (the module cache is `go.sum`-verified, so in-place patching can't build). Commit `go.mod` + `.socket/go-patches/` so a clone builds the patched bytes. Declare them in `setup.manual` for VEX attestation. -- **Apply-only ecosystems** (nuget · maven · deno) — no native install hook to wire, so `setup` reports `no_files`; patch them on demand with `socket-patch apply`. +Configure your project so patches are **re-applied automatically after install** — no +manual `socket-patch apply` step in CI. `setup` is a one-time operation: run it, commit +the change together with your `.socket/` patches, and every later install handles the +rest. It is strictly **opt-in** — nothing is hooked unless you run `setup` and commit the +result. + +What gets wired, per ecosystem: + +- **npm / yarn / pnpm / bun** — writes `postinstall` and `dependencies` scripts into + `package.json` so any install — including `npm install ` — re-applies patches + (pnpm: root package only). +- **Python (pip / uv / poetry / pdm / hatch)** — Python has no universal post-install + hook, so `setup` instead adds a **`socket-patch[hook]`** dependency to your manifest + (`pyproject.toml` / `requirements.txt`; for classic Poetry, the equivalent + `socket-patch = { extras = ["hook"] }`). Installing it lays down + a startup `.pth` (shipped by the small `socket-patch-hook` wheel) that re-applies your + committed `.socket/` patches the next time the interpreter runs. It is + package-manager-agnostic (it rides the interpreter, not any one installer) and + **fail-open** — a hook error can never break interpreter startup. Details below. +- **RubyGems (Bundler)** — adds a managed `plugin "socket-patch"` block to the `Gemfile` + and generates an in-tree Bundler plugin under `.socket/bundler-plugin/`. It re-applies + patches on every `bundle install` (cached *and* fresh). (Requires the `socket-patch` + CLI on `PATH`.) +- **Composer (PHP)** — appends `socket-patch apply` to `composer.json`'s + `post-install-cmd` / `post-update-cmd` script events, so patches re-apply on every + `composer install` / `composer update`. (Requires the `socket-patch` CLI on `PATH`.) +- **Cargo & Go** — *apply-only, no `setup` hook.* A one-click auto-repatch-on-build isn't + possible for these, so `setup` skips them. Patch with `socket-patch apply` directly: + **cargo** patches the crate in place (in `vendor/` or the registry cache, rewriting + `.cargo-checksum.json` so `cargo build` accepts it) — note that a non-vendored crate + patches the **shared** `$CARGO_HOME/registry` cache, which affects every project on + the machine and is silently reset by `cargo clean` or a cache prune; vendor the + dependency (`--mode vendored`) for a project-local, committable patch. **go** writes a + project-local patched copy under `.socket/go-patches/` plus a `go.mod` `replace` + directive (the module cache is `go.sum`-verified, so in-place patching can't build); + commit `go.mod` + `.socket/go-patches/` so a clone builds the patched bytes. To have + [`vex`](#vex) still attest these hand-applied patches, add a `setup.manual` array to + `.socket/manifest.json` by hand (there is no CLI flag for it yet): + `"setup": { "manual": ["cargo", "golang"] }`. +- **Maven / NuGet / Deno** — also apply-only: no native install hook exists to wire, so + `setup` reports `no_files`; patch them on demand with `socket-patch apply`, and declare + them in `setup.manual` (the same hand-edit as the Cargo & Go note above, e.g. + `"setup": { "manual": ["deno"] }`) so [`vex`](#vex) still attests the hand-applied + patches — this matters most for Deno, which has no vendored or hosted alternative. + For Maven + and NuGet, discovery of installed packages is experimental and off by default (opt in + with `SOCKET_EXPERIMENTAL_MAVEN=1` / `SOCKET_EXPERIMENTAL_NUGET=1`), and in-place + patching corrupts their cache checksum sidecars — prefer `--mode vendored` or + `--mode hosted`; see [ecosystems.md](docs/ecosystems.md#maven--nuget-caveats). **Usage:** ```bash @@ -402,28 +671,43 @@ socket-patch setup --check # verify configured; non-zero exit if not (CI gate socket-patch setup --remove # revert what setup added ``` -**Command-specific options** (plus all [Global Options](#global-options) — `--dry-run`, `--yes`, `--json`, `--cwd`): -| Flag | Description | -|------|-------------| -| `--check` | Read-only verification that every manifest is configured; exits non-zero if any still needs setup. Never writes (safe in CI). Conflicts with `--remove`. | -| `--remove` | Revert the install hooks `setup` added (npm `package.json` scripts, the Python `socket-patch[hook]` dependency, and the gem Bundler plugin wiring). | +**Command-specific options** (plus all [Global options](#global-options) — `--dry-run`, +`--yes`, `--json`, `--cwd` are the most relevant): +| Flag | Env var | Description | +|------|---------|-------------| +| `--check` | — | Read-only verification that every manifest is configured **and** every installed patch is still applied on disk (each file matches its recorded `afterHash`); exits non-zero if any manifest still needs setup or a patch has drifted. Never writes (safe in CI). Conflicts with `--remove`. | +| `--remove` | — | Revert every install hook `setup` added (npm `package.json` scripts, the Python `socket-patch[hook]` dependency, the gem Bundler plugin wiring, and the Composer `post-install-cmd`/`post-update-cmd` script entries). | +| `--exclude ` | `SOCKET_SETUP_EXCLUDE` | Workspace-member path(s) to exclude from setup (comma-separated, relative to the repo root). The exclusion is persisted in `.socket/manifest.json`, so `setup --check` and a fresh clone honor it without re-passing the flag. | #### Disabling / opting out (Python hook) The Python hook is designed to be easy to skip or remove: -- **Per interpreter / CI step:** set `SOCKET_PATCH_HOOK=off` (or `SOCKET_NO_HOOK=1`). This is checked *before any hook code runs*, so it fully bypasses the hook for that process. -- **Remove from a project:** `socket-patch setup --remove`, then `pip uninstall socket-patch-hook`. -- **Never opted in:** if you don't run `setup`, there is no hook — it is opt-in by design. +- **Per interpreter / CI step:** set `SOCKET_PATCH_HOOK=off` (or `SOCKET_NO_HOOK=1`). + This is checked *before any hook code runs*, so it fully bypasses the hook for that + process. +- **Remove from a project:** `socket-patch setup --remove`, then + `pip uninstall socket-patch-hook`. +- **Never opted in:** if you don't run `setup`, there is no hook — it is opt-in by + design. #### What the Python hook does, and its safety model -On interpreter startup, *only when the set of installed packages changed*, the hook runs `socket-patch apply --offline --ecosystems pypi` for the project that owns the current virtualenv, re-applying only the patches committed in that project's `.socket/`. Specifically: - -- It is **anchored to the virtualenv** it is installed in (not the working directory), so a `python` started from an unrelated directory cannot pull in a foreign `.socket/manifest.json`. -- It **verifies each file's hash before patching** and **never writes outside the installed package directory** (path-escaping manifest keys are refused). -- It resolves the `socket-patch` binary from the **installed `socket-patch` package** (not from `PATH`), so an unexpected binary on `PATH` is not executed. -- It runs **offline** (no network at startup) and is **fail-open** (any error is swallowed; it can never abort the interpreter). +On interpreter startup, *only when the set of installed packages changed*, the hook runs +`socket-patch apply --offline --ecosystems pypi` for the project that owns the current +virtualenv, re-applying only the patches committed in that project's `.socket/`. +Specifically: + +- It is **anchored to the virtualenv** it is installed in (not the working directory), so + a `python` started from an unrelated directory cannot pull in a foreign + `.socket/manifest.json`. +- It **verifies each file's hash before patching** and **never writes outside the + installed package directory** (path-escaping manifest keys are refused). +- It **prefers the binary shipped in the installed `socket-patch` package** over `PATH`, + so a binary planted earlier on `PATH` cannot shadow it; `PATH` is consulted only as a + fallback when that package isn't installed. +- It runs **offline** (no network at startup) and is **fail-open** (any error is + swallowed; it can never abort the interpreter). **Examples:** ```bash @@ -436,7 +720,7 @@ socket-patch setup -y # Preview changes socket-patch setup --dry-run -# Verify configuration in CI (exits non-zero if not set up) +# Verify configuration in CI (exits non-zero if not set up or a patch has drifted) socket-patch setup --check # JSON output for scripting @@ -445,17 +729,26 @@ socket-patch setup --json -y ### `rollback` -Rollback patches to restore original files. If no identifier is given, all patches are rolled back. Packages managed by [`vendor`](#vendor) are excluded — their patch lives in the committed artifact, not the installed tree — and are listed in the JSON output's `vendored` array (use `remove` or `vendor --revert` to undo them). +Roll back patches to restore the original files. If no identifier is given, all patches +are rolled back. The manifest entries are kept, so a later `apply` re-applies the patches +— use [`remove`](#remove) to delete a patch permanently. + +Packages managed by [`vendor`](#vendor) are excluded — their patch lives in the committed +artifact, not the installed tree — and are listed in the JSON output's `vendored` array +(use `remove` or `vendor --revert` to undo them). **Usage:** ```bash socket-patch rollback [identifier] [options] ``` -**Command-specific options** (plus all [Global Options](#global-options)): -| Flag | Description | -|------|-------------| -| `--one-off` | Rollback by fetching original (`beforeHash`) files from the API — no manifest required | +**Arguments:** +- `identifier` — package PURL or patch UUID to roll back. Omit to roll back all patches. + +**Command-specific options** (plus all [Global options](#global-options)): +| Flag | Env var | Description | +|------|---------|-------------| +| `--one-off` | `SOCKET_ONE_OFF` | Reserved: rollback by fetching original (`beforeHash`) files from the API, no manifest required. **Not yet implemented** — the command currently errors up front. | **Examples:** ```bash @@ -477,27 +770,36 @@ socket-patch rollback --json ### `get` -Get security patches from Socket API and apply them. Accepts a UUID, CVE ID, GHSA ID, PURL, or package name. The identifier type is auto-detected but can be forced with a flag. +Get a security patch from the Socket API and apply it. Accepts a UUID, CVE ID, GHSA ID, +PURL, or package name. The identifier type is auto-detected but can be forced with a +flag. -Alias: `download` +Alias: `download`. And as a shortcut, `socket-patch ` with a bare patch UUID is +rewritten to `socket-patch get `. **Usage:** ```bash socket-patch get [options] ``` -**Command-specific options** (plus all [Global Options](#global-options)): -| Flag | Description | -|------|-------------| -| `--id` | Force identifier to be treated as a UUID | -| `--cve` | Force identifier to be treated as a CVE ID | -| `--ghsa` | Force identifier to be treated as a GHSA ID | -| `-p, --package` | Force identifier to be treated as a package name | -| `--save-only` | Download patch without applying it (alias: `--no-apply`) | -| `--one-off` | Apply patch immediately without saving to the `.socket` folder | -| `--all-releases` | Download patches for every release/distribution variant of a matched package (PyPI wheel/sdist, RubyGems platform, Maven classifier), not just the installed one | +**Arguments:** +- `identifier` — patch UUID, CVE ID, GHSA ID, package PURL, or package name. Type is + auto-detected; force it with `--id` / `--cve` / `--ghsa` / `--package`. -> Authenticated lookups require an org: pass `--org ` (or set `SOCKET_ORG_SLUG`) when using `SOCKET_API_TOKEN`. +**Command-specific options** (plus all [Global options](#global-options)): +| Flag | Env var | Description | +|------|---------|-------------| +| `--id` | — | Force identifier to be treated as a UUID. | +| `--cve` | — | Force identifier to be treated as a CVE ID. | +| `--ghsa` | — | Force identifier to be treated as a GHSA ID. | +| `-p, --package` | — | Force identifier to be treated as a package name. | +| `--save-only` | `SOCKET_SAVE_ONLY` | Download the patch without applying it (alias: `--no-apply`). | +| `--one-off` | `SOCKET_ONE_OFF` | Reserved: apply the patch immediately without saving to the `.socket` folder. **Not yet implemented** — the command currently errors up front. | +| `--all-releases` | `SOCKET_ALL_RELEASES` | Download patches for every release/distribution variant of a matched package (PyPI wheel/sdist, RubyGems platform, Maven classifier), not just the installed one. | + +> Authenticated lookups run against an org. The slug is auto-resolved from your token +> when omitted; pass `--org ` (or set `SOCKET_ORG_SLUG`) to pick one explicitly — +> useful when the token belongs to multiple orgs. **Examples:** ```bash @@ -532,7 +834,8 @@ List all patches in the local manifest. socket-patch list [options] ``` -No command-specific options — see [Global Options](#global-options) (`--json`, `--manifest-path`, `--cwd` are the relevant ones). +No command-specific options — see [Global options](#global-options) (`--json`, +`--manifest-path`, `--cwd` are the relevant ones). **Examples:** ```bash @@ -543,25 +846,32 @@ socket-patch list socket-patch list --json ``` -**Sample Output:** +**Sample output:** ``` -Found 2 patch(es): +Found 1 patch(es): -Package: pkg:npm/lodash@4.17.20 - UUID: 550e8400-e29b-41d4-a716-446655440000 +Package: pkg:npm/flatted@3.3.1 + UUID: 5cac955f-eab1-4d29-8f4f-c408a6cc9647 Tier: free License: MIT + Exported: Wed, 18 Mar 2026 22:53:26 GMT Vulnerabilities (1): - - GHSA-xxxx-yyyy-zzzz (CVE-2024-12345) - Severity: high - Summary: Prototype pollution in lodash - Files patched (1): - - lodash.js + - GHSA-25h7-pfq9-p65f (CVE-2026-32141) + Severity: HIGH + Summary: flatted vulnerable to unbounded recursion DoS in parse() revive phase + Files patched (6): + - package/cjs/index.js + - package/es.js + ... ``` ### `remove` -Remove a patch from the manifest (rolls back files first by default). If the package is [vendored](#vendor), `remove` also **reverts the vendoring** — the lockfile is restored byte-for-byte and the `.socket/vendor/` artifact is deleted — so the patch is fully gone in one command. Detached-vendored patches (from `scan --mode vendored --detached`) are removable by PURL or UUID too, even though they have no manifest entry. +Remove a patch from the manifest (rolls back files first by default). If the package is +[vendored](#vendor), `remove` also **reverts the vendoring** — the lockfile is restored +byte-for-byte and the `.socket/vendor/` artifact is deleted — so the patch is fully gone +in one command. Detached-vendored patches (from `scan --mode vendored --detached`) are +removable by PURL or UUID too, even though they have no manifest entry. **Usage:** ```bash @@ -569,12 +879,12 @@ socket-patch remove [options] ``` **Arguments:** -- `identifier` - Package PURL (e.g., `pkg:npm/package@version`) or patch UUID +- `identifier` — package PURL (e.g. `pkg:npm/package@version`) or patch UUID. -**Command-specific options** (plus all [Global Options](#global-options)): -| Flag | Description | -|------|-------------| -| `--skip-rollback` | Only update manifest, do not restore original files (for vendored packages this also leaves the vendor wiring + artifact in place) | +**Command-specific options** (plus all [Global options](#global-options)): +| Flag | Env var | Description | +|------|---------|-------------| +| `--skip-rollback` | `SOCKET_SKIP_ROLLBACK` | Only update the manifest, do not restore original files (for vendored packages this also leaves the vendor wiring + artifact in place). | **Examples:** ```bash @@ -597,26 +907,35 @@ Download missing blobs, clean up unused blobs, and reset the advisory lock state Alias: `gc` -`repair` cleans up the `.socket/` directory without running a scan — useful when you've manually adjusted the manifest, recovered from a partial-failure state, or just want to free space. For the combined workflow (discover + apply + GC in one pass), use `scan --mode agent --prune --json --yes` instead. +`repair` cleans up the `.socket/` directory without running a scan — useful when you've +manually adjusted the manifest, recovered from a partial-failure state, or just want to +free space. It also rebuilds missing or corrupt vendored artifacts. For the combined +workflow (discover + apply + GC in one pass), use +`scan --json --mode agent --prune --yes` instead. -As its final step, `repair` removes the leftover `.socket/apply.lock` file that mutating commands retain between runs (skipped under `--dry-run`). A leftover file from a crashed run never blocks anything — the OS releases a dead process's lock automatically — so this is pure housekeeping. If another socket-patch process is actively running, `repair` refuses up front with `lock_held` (exit 1); it never steals a live lock — wait for the other process to finish, or budget a wait with `--lock-timeout`. +As its final step, `repair` removes the leftover `.socket/apply.lock` file that mutating +commands retain between runs (skipped under `--dry-run`). A leftover file from a crashed +run never blocks anything — the OS releases a dead process's lock automatically — so this +is pure housekeeping. If another `socket-patch` process is actively running, `repair` +refuses up front with `lock_held` (exit 1); it never steals a live lock — wait for the +other process to finish, or budget a wait with `--lock-timeout`. **Usage:** ```bash socket-patch repair [options] ``` -**Command-specific options** (plus all [Global Options](#global-options)): -| Flag | Description | -|------|-------------| -| `--download-only` | Only download missing artifacts, do not clean up (incompatible with `--offline`) | +**Command-specific options** (plus all [Global options](#global-options)): +| Flag | Env var | Description | +|------|---------|-------------| +| `--download-only` | `SOCKET_DOWNLOAD_ONLY` | Only download missing artifacts, do not clean up (incompatible with `--offline`). | **Examples:** ```bash # Full repair (download missing + clean up unused) socket-patch repair -# Cleanup only, no downloads +# Repair without network access (missing blobs fail per-entry instead of downloading) socket-patch repair --offline # Download missing blobs only @@ -626,35 +945,40 @@ socket-patch repair --download-only socket-patch repair --json ``` -### Undoing things - -Five commands undo different layers of socket-patch state — pick by what you want back: - -| Command | What it undoes | -|---------|----------------| -| [`rollback`](#rollback) | Restores the original file bytes but **keeps the manifest entry** — the next `apply` re-applies the patch | -| [`remove`](#remove) | Rollback **plus** deletes the manifest entry and reverts any vendoring — **permanent**, the patch is fully gone in one command | -| [`vendor --revert`](#vendor) | **Un-vendors wholesale**: restores the recorded original lockfile fragments byte-for-byte and removes the `.socket/vendor/` artifacts — works without a manifest | -| [`scan --prune`](#scan) | **Reconciles, doesn't reverse**: drops manifest entries for packages no longer installed and garbage-collects orphan blob/diff/archive files — installed patches stay | -| [`repair`](#repair) (alias `gc`) | **Restores health, not originals**: re-downloads missing blobs, rebuilds missing/corrupt vendored artifacts, cleans up unused ones, and removes the leftover `apply.lock` from a crashed run | - ## OpenVEX attestations -`socket-patch vex` turns your local manifest into a signed-off statement of *which known vulnerabilities no longer affect your build* because a Socket patch has been applied. This lets vulnerability scanners stop flagging CVEs that you've already remediated in place — without bumping the package version. +`socket-patch vex` turns your local manifest into a machine-readable statement of *which +known vulnerabilities no longer affect your build* because a Socket patch has been applied. +This lets vulnerability scanners stop flagging CVEs that you've already remediated in +place — without bumping the package version. **How it works** -1. Reads `.socket/manifest.json` and, unless `--no-verify` is passed, re-checks each patched file's hash on disk so the attestation only covers patches that are actually applied. [Vendored](#vendor) patches are verified against the **committed artifact** instead of the installed tree (their impact statement carries a `(vendored)` marker), and need no `setup` install hook to be attested — the lockfile wiring is the persistence mechanism. Detached-vendored patches (`scan --mode vendored --detached`) attest from the vendor ledger's embedded records, and [hosted-mode](#choosing-a-patch-mode) patches attest from the redirect ledger (`.socket/vendor/redirect-state.json`, marker `(redirected)` — hash-verified against the installed tree post-install), so `vex` works even with no manifest file at all. -2. Auto-detects the top-level **product** identifier (override with `--product`), probing in order: - - `.git/config` `[remote "origin"]` → `pkg:github//` (similar for GitLab/Bitbucket; raw URL otherwise) +1. Reads `.socket/manifest.json` and, unless `--no-verify` is passed, re-checks each + patched file's hash on disk so the attestation only covers patches that are actually + applied. [Vendored](#vendor) patches are verified against the **committed artifact** + instead of the installed tree (their impact statement carries a `(vendored)` marker), + and need no `setup` install hook to be attested. Detached-vendored patches + (`scan --mode vendored --detached`) + attest from the vendor ledger's embedded records, and + [hosted-mode](#three-patch-modes) patches attest from the redirect ledger + (`.socket/vendor/redirect-state.json`, marker `(redirected)` — hash-verified against + the installed tree post-install), so `vex` works even with no manifest file at all. +2. Auto-detects the top-level **product** identifier (override with `--product`), probing + in order: + - `.git/config` `[remote "origin"]` → `pkg:github//` (similar for + GitLab/Bitbucket; raw URL otherwise) - `package.json` → `pkg:npm/@` - `pyproject.toml` → `pkg:pypi/@` - `Cargo.toml` → `pkg:cargo/@` -3. Emits an OpenVEX 0.2.0 document whose statements mark each mitigated vulnerability as `not_affected` (justification: the patch is present), suitable for piping into `vexctl`, Grype, Trivy, and similar tools. +3. Emits an OpenVEX 0.2.0 document whose statements mark each mitigated vulnerability as + `not_affected` (justification: the patch is present), suitable for piping into + `vexctl`, Grype, Trivy, and similar tools. **Provenance markers** -Each statement's impact string records *how* the patch is persisted — one marker per [patch mode](#choosing-a-patch-mode): +Each statement's impact string records *how* the patch is persisted — one marker per +[patch mode](#three-patch-modes): | Impact statement | Mode | What the evidence is | What a consumer should do | |---|---|---|---| @@ -662,17 +986,20 @@ Each statement's impact string records *how* the patch is persisted — one mark | `Patched via Socket patch (vendored)` | vendored | The **committed** `.socket/vendor/` artifact was hash-verified — no install hook needed; the lockfile wiring is the persistence mechanism | Trust it on any checkout; the committed bytes are the patch | | `Patched via Socket patch (redirected)` | hosted | The lockfile's integrity pin points at the Socket-hosted patched package. When emitted in-run by `scan --mode hosted --vex`, the statement is attested **from the redirect ledger without hash verification** (the bytes are fetched at install time — the JSON `vex` summary carries `verified: false`) | Ensure installs still resolve from `patch.socket.dev` (the lockfile edit is intact), and run `socket-patch vex` **after installing** — it re-reads the ledger and hash-verifies the redirected patches against the installed tree | -The markers are stable strings (see `CLI_CONTRACT.md`); scanners and policy engines may match on them. +The markers are stable strings (see +[CLI_CONTRACT.md](crates/socket-patch-cli/CLI_CONTRACT.md)); scanners and policy engines +may match on them. **Output channels** -| Invocation | VEX document | stdout | -|------------|--------------|--------| -| _default_ (no `--output`, no `--json`) | stdout | human-readable status on stderr | -| `--output ` | the file | one-line summary | -| `--json --output ` | the file | machine-readable envelope (the CI shape) | +| Invocation | VEX document | Status / summary | +|------------|--------------|------------------| +| _default_ (no `--output`, no `--json`) | stdout | one-line summary (stderr) | +| `--output ` | the file | one-line summary (stdout) | +| `--json --output ` | the file | machine-readable envelope on stdout (the CI shape) | -`--json` requires `--output`, since the VEX document is itself JSON and would otherwise collide with the envelope on stdout. +`--json` requires `--output`, since the VEX document is itself JSON and would otherwise +collide with the envelope on stdout. **Using it with a scanner** @@ -687,66 +1014,75 @@ grype --vex socket.vex.json trivy image --vex socket.vex.json ``` -Run `socket-patch get` or `socket-patch scan --mode agent --prune` first — `vex` errors with `no_patches` when there is nothing to attest (an empty manifest and no detached-vendored patches). +Apply patches first (in any mode) — `vex` errors with `no_patches` when there is nothing +to attest (an empty manifest, no detached-vendored patches, and no hosted redirect +records). ### Inline VEX on `apply` / `scan` / `vendor` -You don't need a separate `vex` invocation: pass `--vex ` to `apply`, `scan`, or `vendor` and the same OpenVEX document is generated as a side-effect of a successful run. +You don't need a separate `vex` invocation: pass `--vex ` to `apply`, `scan`, or +`vendor` and the same OpenVEX document is generated as a side-effect of a successful run. ```bash # Patch and attest in one step socket-patch apply --vex socket.vex.json -# Discover, apply, prune, and attest — the full bot-mode pass +# Discover, apply, prune, and attest — the full auto-update-bot pass socket-patch scan --json --mode agent --prune --yes --vex socket.vex.json # Vendor and attest — works manifest-less with --detached too socket-patch scan --json --mode vendored --yes --vex socket.vex.json ``` -The `--vex-product`, `--vex-no-verify`, `--vex-doc-id`, and `--vex-compact` flags mirror the standalone command's `--product` / `--no-verify` / `--doc-id` / `--compact` knobs. +The `--vex-product`, `--vex-no-verify`, `--vex-doc-id`, and `--vex-compact` flags mirror +the standalone command's `--product` / `--no-verify` / `--doc-id` / `--compact` knobs. Contract: -- The document is **always written to the file** (never stdout), so it never collides with the command's own `--json` output. JSON mode adds a top-level `vex` summary — `{ path, statements, format }` — to the envelope (`apply`) / result (`scan`). -- It's built from the manifest **as it stands after the run** (including any `--mode agent` writes, with or without `--prune`) and verified against on-disk state unless `--vex-no-verify` is set. Generated for real applies, `--dry-run`, and read-only scans alike. -- **Fail-the-command:** if `--vex` was requested but generation fails (no detectable product, empty/missing manifest, nothing verified, unwritable path), the command exits non-zero **even when the apply/scan itself succeeded**, with a stable error code in the JSON output. +- The document is **always written to the file** (never stdout), so it never collides + with the command's own `--json` output. JSON mode adds a top-level `vex` summary — + `{ path, statements, format }` — to the envelope (`apply`) / result (`scan`). +- It's built from the manifest **as it stands after the run** (including any + `--mode agent` writes, with or without `--prune`) and verified against on-disk state + unless `--vex-no-verify` is set. Generated for real applies, `--dry-run`, and read-only + scans alike. +- **Fail-the-command:** if `--vex` was requested but generation fails (no detectable + product, empty/missing manifest, nothing verified, unwritable path), the command exits + non-zero **even when the apply/scan itself succeeded**, with a stable error code in the + JSON output. ## Scripting & CI/CD -All commands support `--json` for machine-readable output. JSON responses always include a `"status"` field for easy error detection: +All commands support `--json` for machine-readable output. JSON responses always include +a `"status"` field for easy error detection: ```bash # Check for available patches in CI (read-only) result=$(socket-patch scan --json --ecosystems npm) patches=$(echo "$result" | jq '.totalPatches') -# Auto-update bot mode: discover, apply, prune, sweep in one pass +# Auto-update bot: discover, apply, and garbage-collect in one pass socket-patch scan --json --mode agent --prune --yes | jq '{ - applied: [.apply.patches[] | select(.action == "added" or .action == "updated") | .purl], - pruned: .gc.prunedManifestEntries, - bytes_freed: .gc.bytesFreed + applied: [.apply.patches[]? | select(.action == "added" or .action == "updated") | .purl], + pruned: (.gc.prunedManifestEntries // []), + bytes_freed: (.gc.bytesFreed // 0) }' -# Pipe this into peter-evans/create-pull-request to open a PR with the changes. +# The PR action (e.g. peter-evans/create-pull-request) commits the working-tree +# changes; use this summary as the PR body. # Apply patches and check result socket-patch apply --json | jq '.status' # "success", "partialFailure", "noManifest", or "error" ``` -When stdin is not a TTY (e.g., in CI pipelines), interactive prompts auto-proceed instead of blocking. Progress indicators and ANSI colors are automatically suppressed when output is piped. - -## Environment Variables - -Every [Global Option](#global-options) has a matching `SOCKET_*` environment variable (listed in that table), and `vex`-specific flags map to `SOCKET_VEX_*`. The most commonly used variables are: +When stdin is not a TTY (e.g. in CI pipelines), interactive prompts auto-proceed instead +of blocking. Progress indicators and ANSI colors are automatically suppressed when output +is piped. -| Variable | Description | -|----------|-------------| -| `SOCKET_API_TOKEN` | API authentication token. Use the raw token (`sktsec_<...>_api`) shown when it was generated, **not** the SHA-512 hash (`sha512-...`) that the dashboard may also display for identification. | -| `SOCKET_ORG_SLUG` | Default organization slug | -| `SOCKET_API_URL` | API base URL (default: `https://api.socket.dev`) | +The exact JSON shapes, exit codes, and stability guarantees are specified in +[CLI_CONTRACT.md](crates/socket-patch-cli/CLI_CONTRACT.md). -## Manifest Format +## Manifest format Downloaded patches are stored in `.socket/manifest.json`: @@ -778,13 +1114,22 @@ Downloaded patches are stored in `.socket/manifest.json`: } ``` -Patched file contents are in `.socket/blob/` (named by git SHA256 hash). - -## Supported Platforms - -| Platform | Architecture | -|----------|-------------| -| macOS | ARM64 (Apple Silicon), x86_64 (Intel) | -| Linux | x86_64, ARM64, ARMv7, i686 | -| Windows | x86_64, ARM64, i686 | -| Android | ARM64 | +Patched file contents are in `.socket/blobs/` (named by git SHA256 hash). + +The manifest may also carry an optional top-level `"setup"` key persisting setup state — +`"setup": { "manual": ["cargo"], "exclude": ["packages/legacy"] }` — where `manual` +lists ecosystems you patch by hand so [`vex`](#vex) still attests them (see +[`setup`](#setup)), and `exclude` lists workspace members excluded from setup (written +by `setup --exclude`). + +## Further reading + +- **[Ecosystem & platform support](docs/ecosystems.md)** — the full mode × ecosystem + matrix, per-ecosystem caveats (Maven, NuGet, Rush monorepos, Go), and supported + platforms. +- **[CLI contract](crates/socket-patch-cli/CLI_CONTRACT.md)** — the machine-readable + surface: exact JSON shapes, exit codes, flag/env bindings, and the semver policy that + governs them. +- **[Design notes](docs/design/)** — e.g. [why hosted mode is impossible for + Go](docs/design/golang-hosted-no-go.md). +- **[Changelog](CHANGELOG.md)** diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index ff505ba1..89312a1c 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -387,10 +387,10 @@ per service outcome: Coverage today: **npm** (all lock flavors), **pypi** (wheel — sdist falls back / refuses), **cargo** (download + extract the `.crate`), **golang** (download + extract the module zip, verify the `h1:` dirhash, wire the `replace`), **composer** (download + extract the dist zip), **gem** (download + -extract the `.gem`, plus a `gem-stub-gemspec` SECOND artifact), and **nuget** (download the prebuilt -`.nupkg`). **maven** attempts the prebuilt `.jar` download under `auto` but is NOT in the fail-closed -`service` coverage list — `--vendor-source service` refuses maven purls with -`vendor_service_unsupported_ecosystem`. The Tier-B ecosystems +extract the `.gem`, plus a `gem-stub-gemspec` SECOND artifact), **nuget** (download the prebuilt +`.nupkg`), and **maven** (download the prebuilt `.jar` + the registry pom; in the fail-closed +`service` coverage list since the `service_mode_gate_admits_maven` fix — PR #117 shipped the backend +but left maven off `SERVICE_ECOSYSTEMS`). The Tier-B ecosystems (cargo/golang/composer/gem) download the patched archive and extract it into the vendor directory — the same source tree the local build commits — then run the existing path-dep wiring; their build-equivalence is exercised by the toolchain-backed e2e suites (which skip when the package diff --git a/docs/ecosystems.md b/docs/ecosystems.md new file mode 100644 index 00000000..f9e1599d --- /dev/null +++ b/docs/ecosystems.md @@ -0,0 +1,160 @@ +# Ecosystem & platform support + +This is the detailed support matrix for `socket-patch`: which package ecosystems work +with which [patch mode](../README.md#three-patch-modes), the per-ecosystem caveats, and +the platforms the binary ships for. + +For what the three modes *are* and how to choose between them, see +[How Socket Patch works](../README.md#how-socket-patch-works) in the README. + +## Mode × ecosystem matrix + +The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. +`--ecosystems npm,pypi,golang`). + +| Ecosystem | agent (`--mode agent`) | vendored (`--mode vendored`) | hosted (`--mode hosted`) | +|-----------|------------------------|------------------------------|--------------------------| +| npm (`npm`) — pnpm / yarn / berry / bun | ✅ any install layout; `setup` postinstall hook | ✅ five lockfile flavors: package-lock, yarn classic, yarn berry (node-modules linker; PnP refused), pnpm v9, bun `bun.lock` (binary `bun.lockb` refused with a `--save-text-lockfile` pointer). Rush monorepos refused (`vendor_rush_unsupported`) — see [Rush notes](#npm-rush-monorepos) | ✅ package-lock / npm-shrinkwrap, pnpm-lock.yaml, yarn classic, yarn berry, bun — berry and bun carry constraints, see [npm hosted-mode notes](#npm-hosted-mode-notes) | +| PyPI (`pypi`) — uv / poetry / pdm / pipenv / pip | ✅ `.pth` startup hook via `setup` | ✅ five lockfile flavors: uv, poetry, pdm, pipenv (lock rewired, but pipenv doesn't hash-check file entries — `vendor_integrity_unverified` warning; the committed wheel bytes are the protection), and requirements.txt (consumed by pip or `uv pip`) | ✅ requirements.txt + uv.lock. **poetry / pdm / pipenv locks are not rewritten** — use vendored | +| Cargo (`cargo`) | ✅ in-place + `.cargo-checksum.json` rewrite (shared registry-cache caveat — see [Cargo: shared registry cache](#cargo-shared-registry-cache)) | ✅ `[patch.crates-io]` path entry | ✅ per-patch sparse registry (`[registries.socket-patch-]` + Cargo.lock source/checksum) | +| RubyGems (`gem`) | ✅ Bundler plugin via `setup` | ✅ Gemfile + Gemfile.lock path pair | ✅ per-dep `source` block; the `CHECKSUMS` pin needs bundler ≥ 2.6 (older locks get a `redirect_gem_no_checksums_section` warning) | +| Go (`golang`) | ✅ `go.mod` `replace` → `.socket/go-patches/` — see [Go: directory replaces and go.sum](#go-directory-replaces-and-gosum) | ✅ `replace` → the committed vendor tree | ❌ **not possible** — sumdb, module-path identity, and default-GOPROXY leakage each rule it out; see [golang-hosted-no-go.md](design/golang-hosted-no-go.md). **Use vendored** (`redirect_golang_unsupported` names the remedy) | +| Maven (`maven`) | ⚠️ experimental, apply-only (no `setup` hook — reports `no_files`) — gated behind `SOCKET_EXPERIMENTAL_MAVEN=1` (in-place jar patching corrupts the `~/.m2` checksum sidecars); prefer vendored / hosted | ✅ committed maven2 `file://` repository. A root pom declaring `` (multi-module aggregator) is refused (`vendor_maven_multimodule_unsupported`), and a gradle-only project is refused (`vendor_gradle_unsupported`) | ✅ **pom projects only, fail-closed** — the patched jar is pinned at a Socket-only `-socket.` suffix; `${property}` versions are refused; Gradle gets a manual `exclusiveContent` snippet — see [Maven & NuGet caveats](#maven--nuget-caveats) | +| NuGet (`nuget`) | ⚠️ experimental, apply-only (no `setup` hook — reports `no_files`) — gated behind `SOCKET_EXPERIMENTAL_NUGET=1` (in-place patching breaks the `.nupkg.sha512` tamper-evidence sidecar); prefer vendored / hosted | ✅ committed folder feed + `packageSourceMapping` + `packages.lock.json` contentHash pin | ✅ `nuget.config` source + source-mapping, `packages.lock.json` contentHash rewrite. See the locked-mode note in [Maven & NuGet caveats](#maven--nuget-caveats) | +| Composer (`composer`) | ✅ post-install script events | ✅ `composer.lock` `dist: path` rewrite | ✅ `composer.lock` dist url + shasum rewrite | +| Deno (`deno`) | ✅ apply-only — no install hook (`setup` reports `no_files`); declare in `setup.manual` for VEX coverage | ❌ refused (`vendor_unsupported_ecosystem`) | ❌ not supported | + +> **Maven / NuGet discovery gate**: discovering *installed* Maven and NuGet packages (the +> crawl behind `scan` / `apply` / `vendor`) currently requires the same +> `SOCKET_EXPERIMENTAL_MAVEN=1` / `SOCKET_EXPERIMENTAL_NUGET=1` opt-in in every mode. The +> vendored/hosted wiring itself is safe — the gate guards the agent-mode sidecar risk. + +## npm hosted-mode notes + +- **yarn berry** — the redirect edits the `yarn.lock` entry only (cacheKey `10c0` / + yarn 4), and `.yarnrc.yml`'s `compressionLevel` must stay 0. The node-modules linker + is e2e-covered; PnP is untested for hosted — the lock rewrite fires, but PnP's + `.yarn/cache` resolution isn't exercised. +- **bun** — text `bun.lock` v1 only. A binary `bun.lockb` with no text lock beside it + is auto-migrated first: the CLI runs your installed `bun` + (`bun install --save-text-lockfile --frozen-lockfile --lockfile-only`) before reading + the lock — `redirect_bun_lockb_would_migrate` on `--dry-run`, + `redirect_bun_lockb_unsupported` when `bun` is unavailable. (Contrast vendored mode, + which refuses `bun.lockb` and leaves you to run the migration yourself.) + +## npm: Rush monorepos + +A Rush repo has no root `package.json`/lockfile pair — its pnpm source-of-truth locks +live at `common/config/rush/pnpm-lock.yaml` (plus one per subspace under +`common/config/subspaces//`). + +- **Hosted** ✅ — `scan --mode hosted` discovers and repoints those locks in place + (subspaces included). +- **Agent** ✅ — works through the generated project symlink farm. +- **Vendored** ❌ — refused (`vendor_rush_unsupported`): `rush install` copies the lock + into `common/temp` and runs pnpm there, so vendor's relative `file:` specs can't + survive the copy — the refusal routes you to hosted mode. + +Editing a Rush lock outside `rush update` desyncs the `pnpmShrinkwrapHash` in +`common/config/rush/repo-state.json`, so when `preventManualShrinkwrapChanges` is enabled +`rush install` fails until `rush update` refreshes it (a `redirect_rush_repo_state_stale` +warning flags this; the redirect survives the refresh — pnpm keeps locked resolutions for +unchanged specifiers). + +## Maven & NuGet caveats + +Honest limits of the Maven and NuGet flows — documented behavior, not bugs: + +* **Fail-closed by version suffixing (hosted Maven).** Maven has no lockfile, so hosted + mode pins the patch a different way: the Socket patch server (`patch.socket.dev`) + exposes the patched jar + under a globally-unique `-socket.` suffix that exists **only** on the + injected `socket-patch-` repository. The rewriter pins that suffixed version + explicitly — it rewrites the literal ``, or (for a transitive / managed + dependency with no literal version in your pom) adds a `` entry — + so a resolver that can't reach the Socket repo, or is handed different bytes, has + nowhere to fall through to: the build **hard-fails** instead of silently resolving the + unpatched upstream artifact. The ``'s `checksumPolicy=fail` still verifies + the transport-level `.jar.sha1` sidecar on top. A `${property}` version is refused + (`redirect_maven_dep_unpinned`) — a literal edit would break the property reference and + a depMgmt pin could strand sibling artifacts sharing the property. A literal version + that matches neither the base nor the suffixed value is skipped + (`redirect_maven_dep_version_mismatch`). +* **Trusted Checksums reinforcement (hosted Maven, 3.9+).** When the patch server + supplies both the jar and pom sha256, the rewriter also emits Maven + [Trusted Checksums](https://maven.apache.org/resolver/expected-checksums.html) files — + `.mvn/maven.config` resolver args plus `.mvn/checksums/checksums.sha256` entries + pinning both artifacts under the suffixed version's local-repo path (merging into any + pre-existing user config / checksum set; a conflicting value is never overridden and + surfaces `redirect_maven_trusted_checksums_conflict`). This is an **independent + client-side content pin** on top of the transport check. It requires **Maven 3.9+** + (the resolver post-processor and the `${session.rootDirectory}` basedir expression the + config uses); on older Maven the `.mvn/*` files are silently inert — the + version-suffixing above is still fail-closed on its own. On Maven **3.9.0–3.9.8** a + *mismatch* is enforced but reported unclearly; the readability fix landed in **3.9.9** + ([MNG-8182](https://issues.apache.org/jira/browse/MNG-8182)). The args are + `originAware=false` and `failIfMissing=false`, so one checksum matches the artifact + from any repository and a dependency with no committed checksum still resolves — only a + *mismatch* fails. +* **Warm `~/.m2` shadowing (vendored Maven only).** Maven consults the *local repository* + before any configured ``, so with vendored mode a warm `~/.m2` copy of the + same GAV silently wins over the committed `file://` repository — the build succeeds + with **unpatched** bytes. Purge it with: + `mvn dependency:purge-local-repository -DmanualInclude=:` + (the always-on `vendor_maven_local_cache_shadow` warning carries the same one-liner). + Hosted mode is **not** affected: the patched jar lives at the suffixed version, which + no warm `~/.m2` entry can hold. +* **`mirrorOf` mirrors (hosted Maven).** A `settings.xml` `` with + `*` (common in corporate environments) reroutes *all* repositories + — including the injected `socket-patch-` repository — through the mirror. Because + the patch resolves only at the suffixed version, the mirror (which does not carry it) + can't serve it and the **build fails loudly** rather than silently going unpatched. + Scope the mirror to exclude the Socket repos (e.g. + `*,!socket-patch-*`) so the redirect resolves; the + `originAware=false` Trusted Checksums act as a backstop when present. +* **Gradle (hosted Maven).** Gradle build scripts are never edited. A present + `build.gradle*` / `settings.gradle*` gets a paste-able `exclusiveContent { … }` snippet + (a `redirect_gradle_manual_snippet` warning) that carries the **suffixed** version — + and you must bump the `groupId:artifactId` dependency declaration to that suffixed + version yourself. It is fail-closed by repository exclusivity: the `exclusiveContent` + filter routes only the suffixed version to the Socket repo, which is the only place it + exists. +* **NuGet locked mode (hosted + vendored).** With a `packages.lock.json` and + `dotnet restore --locked-mode`, the rewritten `contentHash` pins the patched `.nupkg` — + a tampered or wrong package fails restore with `NU1403`. Without a lockfile there is no + client-side content pin (vendored surfaces this as a `vendor_nuget_no_lockfile` + warning; the feed + source mapping still force the patched copy). + +## Cargo: shared registry cache + +Agent mode patches the crate in place wherever the crawler finds it. For a non-vendored +crate that means the **shared** `$CARGO_HOME/registry` cache: the patch affects every +project on the machine, and is silently reset by `cargo clean` or a cache prune. Use +`--mode vendored` for a project-local, committable patch. + +## Go: directory replaces and go.sum + +Both Go modes work through a `go.mod` `replace` directive pointing at a committed +directory — `.socket/go-patches/@/` in agent mode, +`.socket/vendor/golang//@/` in vendored mode — because the module +cache is `go.sum`-verified, so patching it in place can't build. Go **never verifies a +directory `replace` target against `go.sum`** — that is by design (it's how local module +development works), and it means the committed patched tree itself is the protection: +commit it, and review it like any other vendored code. The wiring survives +`go mod tidy`, and `apply --check` gives CI a read-only audit that the committed +redirects still match the manifest. + +Hosted mode is a hard ❌ for Go — sumdb verification, module-path identity, and +default-GOPROXY leakage each independently rule it out; the full analysis is in +[golang-hosted-no-go.md](design/golang-hosted-no-go.md). + +## Supported platforms + +Prebuilt binaries are published for: + +| Platform | Architecture | +|----------|-------------| +| macOS | ARM64 (Apple Silicon), x86_64 (Intel) | +| Linux | x86_64, ARM64, 32-bit ARM hard-float (`arm-unknown-linux-gnueabihf` / `-musleabihf`), i686 | +| Windows | x86_64, ARM64, i686 | +| Android | ARM64 | From 635c95714971451b9940d10e8761b6cf0b6027cf Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 22 Jul 2026 13:24:17 -0400 Subject: [PATCH 3/5] docs: port PR #125 auth/config model into the rewritten README Configuration sources subsection under Global options (per-key precedence: flag > SOCKET_* env > SOCKET_CLI_* alias > socket-cli config.json > default, config-file locations, SOCKET_NO_API_TOKEN / SOCKET_NO_CONFIG toggles, read-only + no-.env pledge), socket-login-first --api-token row, anonymous public-proxy path kept prominent in the tutorial intro, configuration.md linked from Further reading. Co-Authored-By: Claude Fable 5 --- README.md | 43 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 269bf2ae..910904ce 100644 --- a/README.md +++ b/README.md @@ -84,8 +84,10 @@ The full list of prebuilt targets (Windows, 32-bit ARM, i686, Android) is in ## Five-minute tutorial No account or token is needed to follow along — without an API token the CLI talks to -Socket's public patch proxy. (An API token unlocks your organization's patch tier; see -[Global options](#global-options).) +Socket's public patch proxy, which serves the free tier of patches anonymously. (An API +token unlocks your organization's patch tier; if you've already run `socket login` with +the [Socket CLI](https://docs.socket.dev/docs/socket-cli), socket-patch picks it up +automatically — see [Configuration sources](#configuration-sources).) **1. Scan your project.** From your project root, ask Socket which of your installed dependencies have patches available: @@ -347,14 +349,43 @@ These flags are accepted by **every** subcommand and go after the command name Each flag has a matching `SOCKET_*` environment variable, listed in the table; command-specific flags list theirs in each command's own table. **Precedence is CLI arg -> env var > default.** +> env var > default** — with one extra fallback layer for the three authentication +settings, described next. + +### Configuration sources + +For the three authentication settings, the [Socket CLI](https://docs.socket.dev/docs/socket-cli)'s +persisted login sits between the env var and the built-in default — run `socket login` +(or `socket config set apiToken` / `defaultOrg`) once and socket-patch picks it up too. +Resolution is per key, and an empty value means "unset" at every layer: + +``` +--api-token / --org / --api-url + 1. CLI flag + 2. Env var SOCKET_API_TOKEN / SOCKET_ORG_SLUG / SOCKET_API_URL + 3. Peer alias env SOCKET_CLI_API_TOKEN / SOCKET_CLI_ORG_SLUG / SOCKET_CLI_API_BASE_URL + 4. socket-cli config /socket/settings/config.json — read-only + (Linux: ~/.local/share; macOS: ~/Library/Application Support + then legacy ~/.local/share, both after $XDG_DATA_HOME; + Windows: %LOCALAPPDATA%) + 5. Built-in default no token → public proxy; org → auto-resolve; https://api.socket.dev +``` + +Two env-only toggles adjust this: `SOCKET_NO_API_TOKEN=1` ignores ambient tokens (env + +config; an explicit `--api-token` still wins) — useful to force the anonymous public +proxy in CI or a test run — and `SOCKET_NO_CONFIG=1` disables the config-file layer +entirely. socket-patch never *writes* the config file, and a corrupt one only produces a +stderr warning — it never breaks a command or pollutes `--json` output. socket-patch +does **not** read `.env` files or any per-repository config for endpoints or +credentials: a cloned repo must never be able to redirect where patches come from or +spend your token. (Full rationale: [docs/design/configuration.md](docs/design/configuration.md).) | Flag | Env var | Description | |------|---------|-------------| | `--cwd ` | `SOCKET_CWD` | Working directory (default: `.`). The manifest path is resolved relative to this. | | `--manifest-path ` | `SOCKET_MANIFEST_PATH` | Path to the patch manifest, resolved relative to `--cwd` (default: `.socket/manifest.json`). | | `--api-url ` | `SOCKET_API_URL` | Socket API URL for the authenticated endpoint (default: `https://api.socket.dev`). | -| `--api-token ` | `SOCKET_API_TOKEN` | Socket API token — create one in the [Socket dashboard](https://socket.dev) under your organization's API tokens settings. Use the raw token (`sktsec_<...>_api`) shown at generation time, **not** the `sha512-...` display hash. When omitted, the public patch proxy is used. | +| `--api-token ` | `SOCKET_API_TOKEN` | Socket API token — optional. The easiest setup is `socket login` with the [Socket CLI](https://docs.socket.dev/docs/socket-cli) (see [Configuration sources](#configuration-sources)); to set it directly, create a token in the [Socket dashboard](https://socket.dev) under your organization's API tokens settings and use the raw token (`sktsec_<...>_api`) shown at generation time, **not** the `sha512-...` display hash. When no token resolves from any source, the anonymous public patch proxy is used (free patches). | | `-o, --org ` | `SOCKET_ORG_SLUG` | Organization slug. Auto-resolved when omitted and a token is set. | | `--proxy-url ` | `SOCKET_PROXY_URL` | Public proxy URL used when no API token is set (default: `https://patches-api.socket.dev`). | | `-e, --ecosystems ` | `SOCKET_ECOSYSTEMS` | Restrict to specific ecosystems (comma-separated, e.g. `npm,pypi`). Unknown names are rejected. | @@ -1130,6 +1161,6 @@ by `setup --exclude`). - **[CLI contract](crates/socket-patch-cli/CLI_CONTRACT.md)** — the machine-readable surface: exact JSON shapes, exit codes, flag/env bindings, and the semver policy that governs them. -- **[Design notes](docs/design/)** — e.g. [why hosted mode is impossible for - Go](docs/design/golang-hosted-no-go.md). +- **[Design notes](docs/design/)** — e.g. [the configuration model](docs/design/configuration.md) + and [why hosted mode is impossible for Go](docs/design/golang-hosted-no-go.md). - **[Changelog](CHANGELOG.md)** From 8af1f2d1a4988fa9abff9c53911f6699858adfae Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 22 Jul 2026 13:30:12 -0400 Subject: [PATCH 4/5] =?UTF-8?q?docs:=20round-6=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20auth=20section=20structure=20+=20CI=20auth=20guidan?= =?UTF-8?q?ce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-nest Configuration sources as an H4 inside Global options after the flags table (the insert had stranded the table inside the wrong section); fold the SOCKET_CLI_* aliases into the env-var rung so the ladder matches the contract's one-extra-layer model; XDG-first config paths on Linux+macOS; labeled url default; CI authentication paragraph (SOCKET_API_TOKEN secret, paidRequired fallback behavior, SOCKET_NO_API_TOKEN pin); slimmer --api-token row; Maven/NuGet experimental-discovery breadcrumb in the tutorial; socket-patch vs Socket CLI disambiguation and wrap fixes. Co-Authored-By: Claude Fable 5 --- README.md | 113 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 67 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 910904ce..c0821402 100644 --- a/README.md +++ b/README.md @@ -83,11 +83,12 @@ The full list of prebuilt targets (Windows, 32-bit ARM, i686, Android) is in ## Five-minute tutorial -No account or token is needed to follow along — without an API token the CLI talks to -Socket's public patch proxy, which serves the free tier of patches anonymously. (An API -token unlocks your organization's patch tier; if you've already run `socket login` with -the [Socket CLI](https://docs.socket.dev/docs/socket-cli), socket-patch picks it up -automatically — see [Configuration sources](#configuration-sources).) +No account or token is needed to follow along — without an API token `socket-patch` +talks to Socket's public patch proxy, which serves the free tier of patches anonymously. +(An API token unlocks your organization's patch tier; if you've already run +`socket login` with the separate [Socket CLI](https://docs.socket.dev/docs/socket-cli), +`socket-patch` picks it up automatically — see +[Configuration sources](#configuration-sources).) **1. Scan your project.** From your project root, ask Socket which of your installed dependencies have patches available: @@ -105,7 +106,11 @@ the edit. > If it prints `No patches available for installed packages.`, none of your installed > dependency versions currently has a Socket patch — the good outcome, with nothing to -> apply. To walk the rest of the loop anyway, make a scratch project pinned to a version +> apply. (One exception: Maven and NuGet installed-package discovery is experimental and +> off by default — `scan` silently skips them unless `SOCKET_EXPERIMENTAL_MAVEN=1` / +> `SOCKET_EXPERIMENTAL_NUGET=1` is set; see the +> [mode × ecosystem matrix](docs/ecosystems.md#mode--ecosystem-matrix).) +> To walk the rest of the loop anyway, make a scratch project pinned to a version > that has a free patch — at the time of writing, `flatted@3.3.1`: > > ```bash @@ -154,11 +159,11 @@ deletes the manifest entry): socket-patch remove "pkg:npm/flatted@3.3.1" ``` -That's the whole loop: **scan → apply when prompted → setup → commit**. This tutorial used the default -*agent* mode, where the CLI re-applies patches after each install. There are two other -ways to persist patches — committing the patched packages themselves (*vendored*) or -pinning them in your lockfile (*hosted*) — and choosing between the three is the next -section. +That's the whole loop: **scan → apply when prompted → setup → commit**. This tutorial +used the default *agent* mode, where the CLI re-applies patches after each install. +There are two other ways to persist patches — committing the patched packages themselves +(*vendored*) or pinning them in your lockfile (*hosted*) — and choosing between the +three is the next section. ## How Socket Patch works @@ -170,9 +175,9 @@ content matches neither the expected original nor the patched result is overwrit the full verified patched content plus a stderr warning (`content_mismatch_overwritten`); pass `--strict` (a [global option](#global-options)) to fail closed on mismatch instead, or `apply --force` to skip pre-application hash verification entirely (see -[`apply`](#apply)). Either way the CLI verifies the result after writing. Patches are looked up by package URL -([PURL](https://github.com/package-url/purl-spec)) — e.g. `pkg:npm/lodash@4.17.20` — so -everything is keyed to exact versions. +[`apply`](#apply)). Either way the CLI verifies the result after writing. Patches are +looked up by package URL ([PURL](https://github.com/package-url/purl-spec)) — e.g. +`pkg:npm/lodash@4.17.20` — so everything is keyed to exact versions. **Local state lives in `.socket/`** at your project root, and is designed to be committed: @@ -277,7 +282,8 @@ socket-patch scan --json --mode agent --prune --yes The working-tree changes (the `.socket/` directory — plus lockfile edits if your bot runs `--mode vendored` or `--mode hosted`) are what your PR tooling commits — e.g. `peter-evans/create-pull-request` picks them up automatically; use the JSON summary for -the PR title/body. See [Scripting & CI/CD](#scripting--cicd). +the PR title/body. See [Scripting & CI/CD](#scripting--cicd), including how to supply +`SOCKET_API_TOKEN` for org-tier patches. ### Tell your vulnerability scanner about the patches @@ -350,42 +356,14 @@ These flags are accepted by **every** subcommand and go after the command name Each flag has a matching `SOCKET_*` environment variable, listed in the table; command-specific flags list theirs in each command's own table. **Precedence is CLI arg > env var > default** — with one extra fallback layer for the three authentication -settings, described next. - -### Configuration sources - -For the three authentication settings, the [Socket CLI](https://docs.socket.dev/docs/socket-cli)'s -persisted login sits between the env var and the built-in default — run `socket login` -(or `socket config set apiToken` / `defaultOrg`) once and socket-patch picks it up too. -Resolution is per key, and an empty value means "unset" at every layer: - -``` ---api-token / --org / --api-url - 1. CLI flag - 2. Env var SOCKET_API_TOKEN / SOCKET_ORG_SLUG / SOCKET_API_URL - 3. Peer alias env SOCKET_CLI_API_TOKEN / SOCKET_CLI_ORG_SLUG / SOCKET_CLI_API_BASE_URL - 4. socket-cli config /socket/settings/config.json — read-only - (Linux: ~/.local/share; macOS: ~/Library/Application Support - then legacy ~/.local/share, both after $XDG_DATA_HOME; - Windows: %LOCALAPPDATA%) - 5. Built-in default no token → public proxy; org → auto-resolve; https://api.socket.dev -``` - -Two env-only toggles adjust this: `SOCKET_NO_API_TOKEN=1` ignores ambient tokens (env + -config; an explicit `--api-token` still wins) — useful to force the anonymous public -proxy in CI or a test run — and `SOCKET_NO_CONFIG=1` disables the config-file layer -entirely. socket-patch never *writes* the config file, and a corrupt one only produces a -stderr warning — it never breaks a command or pollutes `--json` output. socket-patch -does **not** read `.env` files or any per-repository config for endpoints or -credentials: a cloned repo must never be able to redirect where patches come from or -spend your token. (Full rationale: [docs/design/configuration.md](docs/design/configuration.md).) +settings, described in [Configuration sources](#configuration-sources) below. | Flag | Env var | Description | |------|---------|-------------| | `--cwd ` | `SOCKET_CWD` | Working directory (default: `.`). The manifest path is resolved relative to this. | | `--manifest-path ` | `SOCKET_MANIFEST_PATH` | Path to the patch manifest, resolved relative to `--cwd` (default: `.socket/manifest.json`). | | `--api-url ` | `SOCKET_API_URL` | Socket API URL for the authenticated endpoint (default: `https://api.socket.dev`). | -| `--api-token ` | `SOCKET_API_TOKEN` | Socket API token — optional. The easiest setup is `socket login` with the [Socket CLI](https://docs.socket.dev/docs/socket-cli) (see [Configuration sources](#configuration-sources)); to set it directly, create a token in the [Socket dashboard](https://socket.dev) under your organization's API tokens settings and use the raw token (`sktsec_<...>_api`) shown at generation time, **not** the `sha512-...` display hash. When no token resolves from any source, the anonymous public patch proxy is used (free patches). | +| `--api-token ` | `SOCKET_API_TOKEN` | Socket API token — optional. When no token resolves from any source, the anonymous public patch proxy is used (free patches). See [Configuration sources](#configuration-sources) for how to obtain and persist one. | | `-o, --org ` | `SOCKET_ORG_SLUG` | Organization slug. Auto-resolved when omitted and a token is set. | | `--proxy-url ` | `SOCKET_PROXY_URL` | Public proxy URL used when no API token is set (default: `https://patches-api.socket.dev`). | | `-e, --ecosystems ` | `SOCKET_ECOSYSTEMS` | Restrict to specific ecosystems (comma-separated, e.g. `npm,pypi`). Unknown names are rejected. | @@ -406,6 +384,42 @@ spend your token. (Full rationale: [docs/design/configuration.md](docs/design/co | `--debug` | `SOCKET_DEBUG` | Emit verbose debug logs to stderr. | | `--no-telemetry` | `SOCKET_TELEMETRY_DISABLED` | Disable anonymous usage telemetry. | +#### Configuration sources + +For the three authentication settings, the [Socket CLI](https://docs.socket.dev/docs/socket-cli)'s +persisted login sits between the env var and the built-in default — run `socket login` +(or `socket config set apiToken` / `defaultOrg`) once and `socket-patch` picks it up +too. The `SOCKET_CLI_*` env vars the JS CLI reads are honored as peer aliases as well, +so one export configures both tools. To set a token directly instead, create one in the +[Socket dashboard](https://socket.dev) under your organization's API tokens settings and +use the raw token (`sktsec_<...>_api`) shown at generation time, **not** the +`sha512-...` display hash. Resolution is per key, and an empty value means "unset" at +every layer: + +``` +--api-token / --org / --api-url + 1. CLI flag + 2. Env var SOCKET_API_TOKEN / SOCKET_ORG_SLUG / SOCKET_API_URL — or the + SOCKET_CLI_* peer aliases (SOCKET_CLI_API_TOKEN / + SOCKET_CLI_ORG_SLUG / SOCKET_CLI_API_BASE_URL); the + canonical name wins when both are set + 3. socket-cli config /socket/settings/config.json — read-only + (Linux: $XDG_DATA_HOME, else ~/.local/share; + macOS: $XDG_DATA_HOME, else ~/Library/Application Support, + then legacy ~/.local/share; Windows: %LOCALAPPDATA%) + 4. Built-in default no token → public proxy; org → auto-resolve; + url → https://api.socket.dev +``` + +Two env-only toggles adjust this. `SOCKET_NO_API_TOKEN=1` ignores ambient tokens (env + +config; an explicit `--api-token` still wins) — useful to force the anonymous public +proxy in CI or a test run. `SOCKET_NO_CONFIG=1` disables the config-file layer entirely. +`socket-patch` never *writes* the config file, and a corrupt one only produces a stderr +warning — it never breaks a command or pollutes `--json` output. `socket-patch` does +**not** read `.env` files or any per-repository config for endpoints or credentials: a +cloned repo must never be able to redirect where patches come from or spend your token. +(Full rationale: [docs/design/configuration.md](docs/design/configuration.md).) + The sections below list only each command's **command-specific** flags. ### `scan` @@ -1085,7 +1099,14 @@ Contract: ## Scripting & CI/CD All commands support `--json` for machine-readable output. JSON responses always include -a `"status"` field for easy error detection: +a `"status"` field for easy error detection. + +**Authentication in CI:** a runner has no `socket login` state — if your organization +has org-tier patches, provide the token as a CI secret via `SOCKET_API_TOKEN` (without +it, runs silently fall back to the anonymous public proxy and see free patches only, and +paid-tier blob downloads report `paidRequired`). To deliberately pin a run to the +anonymous free tier, set `SOCKET_NO_API_TOKEN=1`. See +[Configuration sources](#configuration-sources). ```bash # Check for available patches in CI (read-only) From 5f8b30f0e850695ef912d5cab61590e356e28eff Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 22 Jul 2026 13:52:34 -0400 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20fix=20repair=20--offline=20example?= =?UTF-8?q?=20comment=20=E2=80=94=20warn-and-skip,=20not=20per-entry=20fai?= =?UTF-8?q?lure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified against repair.rs: offline mode prints a summary warning for missing artifacts, records no download events, and exits success. Co-Authored-By: Claude Fable 5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c0821402..ff925176 100644 --- a/README.md +++ b/README.md @@ -980,7 +980,7 @@ socket-patch repair [options] # Full repair (download missing + clean up unused) socket-patch repair -# Repair without network access (missing blobs fail per-entry instead of downloading) +# Cleanup only — missing blobs are warned about and skipped, never downloaded socket-patch repair --offline # Download missing blobs only