diff --git a/.cargo/config.toml b/.cargo/config.toml index fb6aa41c4..e0acd1cd4 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,6 +1,6 @@ # Windows main threads get a 1 MiB stack reserve by default (Unix mains get # 8 MiB). The CLI's async command futures poll deeply nested state machines -# — scan → download → in-process apply, or scan --vendor → the vendor engine +# — scan → download → in-process apply, or a vendored scan → the vendor engine # — and in debug builds (no stack-slot reuse) the summed poll frames exceed # 1 MiB, aborting with "thread 'main' has overflowed its stack" on Windows # only. Raise the PE stack reserve to the Unix default; spawned threads are diff --git a/.gitattributes b/.gitattributes index 4b3f63f07..e27b01a69 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,6 +6,13 @@ crates/socket-patch-core/tests/fixtures/redirect/** -text crates/socket-patch-core/tests/fixtures/pdm-native/*.lock -text +# Poetry and Pipenv locks are real `poetry lock` / `pipenv lock` output: the +# upstream restore and VEX tests round-trip them byte for byte and derive +# their CRLF variants from the LF bytes themselves. +crates/socket-patch-core/tests/fixtures/poetry/** -text +crates/socket-patch-core/tests/fixtures/pipenv/** -text +crates/socket-patch-core/tests/fixtures/pipenv-shapes/** -text + # The captured pnpm 1-12 locks are byte-real: the hosted/vendored rewriters # refuse CRLF by design (vendor_lockfile_crlf_unsupported), and the tests # derive their CRLF variants from the LF bytes themselves. @@ -22,3 +29,7 @@ crates/socket-patch-core/tests/fixtures/vendor/** -text # compares the result byte for byte, so a CRLF checkout would change both # the replayed wiring files and the expected revert. crates/socket-patch-cli/tests/fixtures/legacy-ledgers/** -text + +# The owned Gradle settings script is embedded with include_str! and +# written into user repos byte for byte; a CRLF checkout would change it. +crates/socket-patch-core/src/vendor/jvm/socket-patch.settings.gradle -text diff --git a/.github/actions/pin-socket-hosts/action.yml b/.github/actions/pin-socket-hosts/action.yml new file mode 100644 index 000000000..c7807042e --- /dev/null +++ b/.github/actions/pin-socket-hosts/action.yml @@ -0,0 +1,42 @@ +name: Pin Socket patch hosts +description: >- + On macOS runners, resolve the production patch hosts once (system resolver, + then DNS-over-HTTPS by IP literal), TLS-verify every address for its host, + and pin them in /etc/hosts for the rest of the job +inputs: + hosts: + description: Space-separated hostnames to pin + default: patch.socket.dev patches-api.socket.dev +runs: + using: composite + steps: + # GitHub's hosted macOS runners intermittently answer patch.socket.dev + # with EAI_NONAME ("[Errno 8] nodename nor servname provided", bun's + # `FailedToOpenSocket`) for minutes at a time — at job start or mid-job — + # while the service is up: the ubuntu / windows legs of the same run pass + # and the same macOS cells pass before and after the window. A pre-flight + # wait cannot cover a mid-job window and the failing processes are the + # real package managers, not the CLI, so the job takes the runner's + # resolver out of the path instead. Every request still goes to the + # production service over TLS verified for the hostname. + # scripts/pin-socket-hosts.py documents the resolution and verification. + - name: Pin hosts + if: runner.os == 'macOS' + shell: bash + env: + PIN_HOSTS: ${{ inputs.hosts }} + run: | + set -euo pipefail + # shellcheck disable=SC2086 # PIN_HOSTS is a space-separated list + lines=$(python3 "$GITHUB_WORKSPACE/scripts/pin-socket-hosts.py" $PIN_HOSTS) + printf '%s\n' "$lines" + printf '\n# pinned by .github/actions/pin-socket-hosts\n%s\n' "$lines" | sudo tee -a /etc/hosts >/dev/null + sudo dscacheutil -flushcache + sudo killall -HUP mDNSResponder || true + for host in $PIN_HOSTS; do + got=$(python3 -c 'import socket, sys; print(" ".join(sorted({i[4][0] for i in socket.getaddrinfo(sys.argv[1], 443)})))' "$host" || true) + echo "$host now resolves to: ${got:-nothing}" + if [ -z "$got" ] || ! grep -qE "^(${got// /|}) $host\$" <<<"$lines"; then + echo "::warning::$host does not resolve to its pinned address after pinning (got: ${got:-nothing})" + fi + done diff --git a/.github/workflows/bun-compatibility.yml b/.github/workflows/bun-compatibility.yml index 94ee2941c..9894fe3b4 100644 --- a/.github/workflows/bun-compatibility.yml +++ b/.github/workflows/bun-compatibility.yml @@ -17,6 +17,8 @@ on: pull_request: paths: - '.github/actions/upload-artifact/**' + - '.github/actions/pin-socket-hosts/**' + - 'scripts/pin-socket-hosts.py' - '.github/workflows/bun-compatibility.yml' - 'scripts/backtest-bun*.py' - 'scripts/probe-bun-historical-linux.py' @@ -43,20 +45,22 @@ on: - 'crates/socket-patch-cli/src/commands/scan/**' - 'crates/socket-patch-cli/src/commands/rollback.rs' - 'crates/socket-patch-cli/src/commands/vendor.rs' - - 'crates/socket-patch-cli/src/commands/repair_vendor.rs' + - 'crates/socket-patch-cli/src/commands/vendored_backend/**' - 'crates/socket-patch-cli/src/commands/remove.rs' # Main runs are the only rust-cache writers (save-if below), so a # path-filtered push trigger is what seeds the cache the PR builds restore # (rust-cache keys on Cargo.lock, so Cargo.lock belongs here) and re-runs # the matrix post-merge on the code paths it exercises: the vendored engine # (`vendor/**` — bun_lock.rs, bun_lock_text.rs's shared version gate, - # npm_flavor.rs, lock_inventory.rs), the hosted rewriter + unwinds, and the + # npm_flavor.rs, lock_inventory/), the hosted rewriter + unwinds, and the # CLI drivers (`scan/**` — hosted.rs, vendor_flow.rs, mod.rs — plus the # vendor / repair / remove commands the matrix runs). push: branches: [main] paths: - '.github/workflows/bun-compatibility.yml' + - '.github/actions/pin-socket-hosts/**' + - 'scripts/pin-socket-hosts.py' - 'scripts/backtest-bun*.py' - 'scripts/probe-bun-historical-linux.py' - 'scripts/bun-historical-shas.json' @@ -75,7 +79,7 @@ on: - 'crates/socket-patch-cli/src/commands/scan/**' - 'crates/socket-patch-cli/src/commands/rollback.rs' - 'crates/socket-patch-cli/src/commands/vendor.rs' - - 'crates/socket-patch-cli/src/commands/repair_vendor.rs' + - 'crates/socket-patch-cli/src/commands/vendored_backend/**' - 'crates/socket-patch-cli/src/commands/remove.rs' workflow_dispatch: inputs: @@ -95,11 +99,11 @@ on: permissions: contents: read -# Supersede stale PR runs. The `main` guard is load-bearing: main runs are the -# ONLY rust-cache writers (save-if), so they must never be cancelled mid-save. +# Supersede stale PR runs only: main runs are the ONLY rust-cache writers +# (save-if), so push, dispatch and schedule runs are never cancelled mid-save. concurrency: group: bun-patch-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: CARGO_PROFILE_DEV_DEBUG: '0' @@ -187,6 +191,11 @@ jobs: with: python-version: '3.12' + - name: Pin the production patch hosts (macOS) + # The hosted macOS resolver intermittently loses patch.socket.dev for + # minutes (EAI_NONAME) while the service is up; see the action. + uses: ./.github/actions/pin-socket-hosts + - name: Download Bun ${{ matrix.bun }} id: bun # Pre-populate the exact directory layout the script's install_tool() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b55cec7df..d1c1f16a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,8 +2,14 @@ name: CI on: push: - branches: [main] + # The v5 integration branch too: PRs into it skip the full tier, and + # `schedule` only fires on main, so each landing runs the full tier. + branches: [main, release/v5-prerelease] pull_request: + schedule: + # Nightly on main: the `full` tier (e2e-full, cargo-vex-matrix-full, + # yarn-berry-full) and e2e-docker, which pull_request runs skip. + - cron: '41 5 * * *' workflow_dispatch: inputs: hosted_e2e: @@ -17,12 +23,15 @@ on: permissions: contents: read -# Supersede stale runs on force-push / rapid PR updates. The `main` guard is -# load-bearing: main runs are the ONLY rust-cache writers (save-if), so they -# must never be cancelled mid-save. +# A newer push to the same PR supersedes its older run; nothing else is +# cancelled. Push, dispatch and schedule runs always finish: main runs are +# the ONLY rust-cache writers (save-if) and must not die mid-save, and a +# dispatched base-branch run is the base's only CI verdict. The nightly +# gets its own group: a group holds one pending run, so sharing main's would +# let a queued push and the nightly cancel each other. concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + group: ci-${{ github.event.pull_request.number || github.ref }}${{ github.event_name == 'schedule' && '-nightly' || '' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: clippy: @@ -45,34 +54,15 @@ jobs: # Swatinem/rust-cache instead of a raw actions/cache of the whole # target/ dir: it prunes the cache to dependency artifacts (~5-10x # smaller), which keeps this repo's total cache footprint inside - # GitHub's 10 GiB budget — previously a single main run produced - # ~8 GiB of caches and every PR save evicted them (cold e2e legs - # recompiled the full ~275-crate graph each run). save-if restricts - # writes to main so PR branches restore without churning the budget. + # GitHub's 10 GiB budget (a raw target/ cache let every PR save evict + # main's caches). save-if restricts writes to main so PR branches + # restore without churning the budget. uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: save-if: ${{ github.ref == 'refs/heads/main' }} - name: Run clippy run: cargo clippy --workspace --all-features -- -D warnings - # Moved-module aliases (patch::vendor → vendor, patch::go_mod_edit → - # vendor::go_mod_edit, patch::go_redirect → patch::redirect::golang_local) - # exist only for external consumers of the published core crate. - # #[deprecated] on a `pub use` re-export emits no warnings - # (rust-lang/rust#30827), so the compiler cannot pressure internal code - # off the old paths — this grep is the guard instead. - - name: Reject internal uses of moved-module alias paths - run: | - if grep -rn --include='*.rs' \ - -e 'patch::vendor' -e 'patch::go_mod_edit' \ - -e 'patch::go_redirect' -e 'patch::bun_lock_text' \ - -e 'utils::telemetry' -e 'utils::cleanup_blobs' \ - -e 'utils::date' -e 'utils::fuzzy_match' \ - -e 'gem_setup::' -e 'composer_setup::' -e 'pth_hook::' \ - crates; then - echo '::error::use the canonical module paths (crate::vendor, patch::redirect::golang_local, crate::telemetry, manifest::cleanup_blobs, api::date, crawlers::fuzzy_match); the old-path aliases exist only for external consumers' - exit 1 - fi # The napi addon is only ever loaded by Node, so cargo's own tests never # exercise its JS loader or the engine/provider boundary. @@ -106,9 +96,8 @@ jobs: - name: Smoke-test addon run: node --test crates/socket-patch-node/npm/test/smoke.mjs - # Lint the out-of-workspace packaging artifacts: the RubyGems CLI launcher - # gem + the Bundler plugin gem (Ruby), and the curl|sh installer. Ruby is - # pre-installed on the ubuntu-latest runner. + # Check the standalone installer, release scripts, and native installer + # test harnesses. lint-ecosystems: runs-on: ubuntu-latest timeout-minutes: 10 @@ -118,21 +107,11 @@ jobs: with: persist-credentials: false - - name: Ruby — syntax-check + build the launcher gem and Bundler plugin - run: | - ( cd gem/socket-patch && ruby -c lib/socket_patch/launcher.rb && ruby -c exe/socket-patch && gem build socket-patch.gemspec ) - ( cd gem/socket-patch-bundler && ruby -c plugins.rb && gem build socket-patch-bundler.gemspec ) - # The generated-plugin templates are pure Ruby — keep them parseable. - ruby -c crates/socket-patch-core/src/setup/gem/templates/plugins.rb.tmpl - ruby -c crates/socket-patch-core/src/setup/gem/templates/gemspec.tmpl - - name: Python — test native installer harnesses run: python3 -B -m unittest discover -s scripts/tests -v - name: Shell — shellcheck the curl|sh installer - # install.sh is the other distribution artifact this job lints; it - # had no coverage anywhere before the self-update work touched the - # same surface. shellcheck is pre-installed on ubuntu-latest. + # shellcheck is pre-installed on ubuntu-latest. run: shellcheck --shell=sh scripts/install.sh - name: Shell — run the installer end to end @@ -253,19 +232,14 @@ jobs: run: rustup show - name: Cache cargo - # Swatinem/rust-cache instead of a raw actions/cache of the whole - # target/ dir: it prunes the cache to dependency artifacts (~5-10x - # smaller), which keeps this repo's total cache footprint inside - # GitHub's 10 GiB budget — previously a single main run produced - # ~8 GiB of caches and every PR save evicted them (cold e2e legs - # recompiled the full ~275-crate graph each run). save-if restricts - # writes to main so PR branches restore without churning the budget. + # Swatinem/rust-cache, main-only saves: see the first `Cache cargo` + # step in this file. uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: save-if: ${{ github.ref == 'refs/heads/main' }} - name: Build - run: cargo build --workspace --all-features + run: cargo build --workspace - name: Install Go (for vexctl) # The `vex` subcommand emits OpenVEX documents; tests/e2e_vex.rs @@ -292,13 +266,12 @@ jobs: # Retried: the install compiles sigstore/cosign, whose module # verification reads dozens of sum.golang.org checksum tiles, and # transient INTERNAL_ERROR stream resets there have failed this - # step on otherwise-green runs (2026-08-20: two attempts ~18 min - # apart, different modules each time — an upstream incident, not - # one bad tile). The backoff rides out short resets; a persistent - # outage still fails loudly on the last attempt. Follow-up option - # if this recurs: install the pinned release BINARY (sha256-pinned) - # instead of compiling, which sidesteps module verification and - # drops ~100s of compile time per leg. + # step on otherwise-green runs. The backoff rides out short + # resets; a persistent outage still fails loudly on the last + # attempt. Follow-up option if this recurs: install the pinned + # release BINARY (sha256-pinned) instead of compiling, which + # sidesteps module verification and drops ~100s of compile time per + # leg. shell: bash run: | for attempt in 1 2 3 4 5; do @@ -315,19 +288,16 @@ jobs: echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" - name: Run tests - # `--all-features` would also RUN the docker-e2e / setup-e2e suites, - # which soft-skip as "ok" in this job (no images are built here, and - # macOS/Windows have no Docker at all) — dozens of fake greens per OS - # that would hide a broken skip-guard behind a passing checkmark. - # Build them with --all-features (compile rot is real coverage), but - # run only the default-feature suites; the dedicated e2e-docker and - # setup-matrix jobs run the gated suites for real. + # Default features only: `--all-features` would also RUN the + # docker-e2e suites, which soft-skip as "ok" here (no images, and + # macOS/Windows have no Docker) — fake greens hiding a broken + # skip-guard. Their compile rot is caught on every OS by e2e-build, + # which compiles every CLI test target with --all-features; one + # feature set here means one compile. # # `--no-fail-fast`: without it cargo stops at the first failing test # BINARY, so one bad file hides every later binary's result on that - # OS (2026-09-03: two Windows-only fixture failures in - # covgap_commands_get masked ~180 binaries that had never run on - # Windows at all). Run them all and fail at the end instead. + # OS. Run them all and fail at the end instead. shell: bash env: # The real-go hosted/vendored suites (`#![cfg(unix)]`) ride the Go @@ -336,18 +306,13 @@ jobs: SOCKET_PATCH_GO_E2E_REQUIRED: '1' SOCKET_PATCH_GO_E2E_VERSION: '1.24' run: | - set -euo pipefail - cargo test --workspace --all-features --no-run cargo test --workspace --no-fail-fast test-release: runs-on: ubuntu-latest - # Every tests/ target is its own optimized link, and the two cargo - # invocations below build the graph twice (--all-features, then the - # default features): ~25m on main with ~240 test binaries, so 30m left - # no headroom as suites grow. The manifest-less VEX suites share two - # multi-module binaries (tests/e2e_vex_lockfile/, tests/e2e_vex_build/) - # to keep the count down; the extra 10m covers the rest. + # Every tests/ target is its own optimized link (~240 test binaries). + # The manifest-less VEX suites share two multi-module binaries + # (tests/e2e_vex_lockfile/, tests/e2e_vex_build/) to keep the count down. timeout-minutes: 40 steps: - name: Checkout @@ -363,13 +328,8 @@ jobs: run: rustup show - name: Cache cargo - # Swatinem/rust-cache instead of a raw actions/cache of the whole - # target/ dir: it prunes the cache to dependency artifacts (~5-10x - # smaller), which keeps this repo's total cache footprint inside - # GitHub's 10 GiB budget — previously a single main run produced - # ~8 GiB of caches and every PR save evicted them (cold e2e legs - # recompiled the full ~275-crate graph each run). save-if restricts - # writes to main so PR branches restore without churning the budget. + # Swatinem/rust-cache, main-only saves: see the first `Cache cargo` + # step in this file. uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: save-if: ${{ github.ref == 'refs/heads/main' }} @@ -378,13 +338,9 @@ jobs: # `ci-release` = [profile.release] minus the full-LTO link (see the # profile's comment in Cargo.toml). Same opt-level/debug-assertion # semantics this job exists to validate; ~23m of LTO relinking gone. - # Build/run split for the same reason as the `test` job: the gated - # docker-e2e / setup-e2e suites only soft-skip here — compile them, - # don't count their skips as passes. - run: | - set -euo pipefail - cargo test --workspace --all-features --profile ci-release --no-run - cargo test --workspace --profile ci-release + # Default features for the same reason as the `test` job; the + # feature-gated suites' compile rot is e2e-build's. + run: cargo test --workspace --profile ci-release coverage: # Code coverage via cargo-llvm-cov (LLVM source-based instrumentation). @@ -418,13 +374,8 @@ jobs: tool: cargo-llvm-cov@0.8.7 - name: Cache cargo - # Swatinem/rust-cache instead of a raw actions/cache of the whole - # target/ dir: it prunes the cache to dependency artifacts (~5-10x - # smaller), which keeps this repo's total cache footprint inside - # GitHub's 10 GiB budget — previously a single main run produced - # ~8 GiB of caches and every PR save evicted them (cold e2e legs - # recompiled the full ~275-crate graph each run). save-if restricts - # writes to main so PR branches restore without churning the budget. + # Swatinem/rust-cache, main-only saves: see the first `Cache cargo` + # step in this file. uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: save-if: ${{ github.ref == 'refs/heads/main' }} @@ -471,6 +422,45 @@ jobs: if-no-files-found: error retention-days: 30 + # Dockerfile.base compiles the full-LTO release binary inside Docker, with + # no cache. Build it once per run and hand the image to every docker leg. + docker-base: + runs-on: ubuntu-22.04 + timeout-minutes: 30 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up Docker Buildx + # `driver: docker`: see coverage-docker's matching step. + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + with: + driver: docker + + - name: Build base image + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + with: + context: . + file: tests/docker/Dockerfile.base + tags: socket-patch-test-base:latest + load: true + + - name: Save base image + run: | + mkdir -p docker-base + docker save --output docker-base/socket-patch-test-base.tar socket-patch-test-base:latest + + - uses: ./.github/actions/upload-artifact + with: + name: docker-base-image + path: docker-base/socket-patch-test-base.tar + if-no-files-found: error + retention-days: 3 + coverage-docker: # Per-ecosystem coverage for the Docker-driven e2e suite. Mirrors # the e2e-docker matrix but builds an instrumented socket-patch @@ -480,7 +470,9 @@ jobs: # # Hooks: docker_e2e_.rs reads SOCKET_PATCH_COV_BIN + # SOCKET_PATCH_COV_PROFRAW_DIR. Both unset is the no-op default - # (used by the e2e-docker matrix above). + # (used by the e2e-docker matrix below). Every per-push docker_e2e + # suite runs here; e2e-docker is the nightly run of the same suites + # against the base image's full-LTO release binary. # # Pin to ubuntu-22.04 (glibc 2.35) instead of ubuntu-latest # (currently 24.04, glibc 2.39). The instrumented binary built @@ -488,6 +480,7 @@ jobs: # (glibc 2.36); a binary linked against a newer glibc than the # container ships fails to load. ubuntu-22.04's older glibc is # the highest base that's forward-compatible with debian:12. + needs: docker-base runs-on: ubuntu-22.04 timeout-minutes: 30 permissions: @@ -533,13 +526,15 @@ jobs: # (a PR-poisoned cargo cache could compromise the instrumented # binary we mount into the container). - - name: Build base image - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + - name: Download base image + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: - context: . - file: tests/docker/Dockerfile.base - tags: socket-patch-test-base:latest - load: true + pattern: docker-base-image* + merge-multiple: true + path: docker-base + + - name: Load base image + run: docker load --input docker-base/socket-patch-test-base.tar - name: Build ${{ matrix.ecosystem }} image uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 @@ -683,36 +678,68 @@ jobs: - name: Run npm dispatch tests run: node --test npm/socket-patch/bin/socket-patch.test.mjs - - name: Setup Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + # Compiles the CLI and every CLI test target once per OS (--all-features, + # so this is also the feature-gated suites' compile-rot check) and uploads + # the binaries the e2e, e2e-full and cargo-vex legs run. The legs run the + # test binaries directly from the same checkout path, so `CARGO_BIN_EXE_*` + # and `CARGO_MANIFEST_DIR` resolve as they did under `cargo test`. + e2e-build: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + # Windows compiles the same ~240 targets ~1.6x slower (see `test`). + timeout-minutes: ${{ matrix.os == 'windows-latest' && 60 || 45 }} + env: + CARGO_PROFILE_DEV_DEBUG: '0' + CARGO_INCREMENTAL: '0' + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - python-version: '3.12.x' + persist-credentials: false + + - name: Install Rust + run: rustup show + + - name: Cache cargo + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + key: e2e-build + save-if: ${{ github.ref == 'refs/heads/main' }} + + - name: Compile the CLI and every CLI test target + shell: bash + run: | + set -euo pipefail + cargo test --locked -p socket-patch-cli --all-features --tests --no-run --message-format=json-render-diagnostics > target-build.json - - name: Run pypi dispatch tests - run: python pypi/socket-patch/test_dispatch.py + - name: Bundle the binaries the legs run + shell: bash + env: + BUNDLE_OS: ${{ matrix.os }} + run: python3 scripts/ci-e2e-bundle.py --os "$BUNDLE_OS" --cargo-json target-build.json --dest target/e2e-bin + + - uses: ./.github/actions/upload-artifact + with: + name: e2e-bin-${{ matrix.os }} + path: target/e2e-bin/ + if-no-files-found: error + retention-days: 3 e2e: - needs: test + # These jobs consume e2e-build's binaries and can run alongside unit tests. + needs: [e2e-build] strategy: fail-fast: false matrix: include: - - os: ubuntu-latest - suite: e2e_cargo - - os: ubuntu-latest - suite: e2e_golang - - os: ubuntu-latest - suite: e2e_maven - - os: ubuntu-latest - suite: e2e_composer - # composer is a shipped ecosystem, so e2e_composer's tests are - # NOT `#[ignore]`-gated the way the experimental-ecosystem suites - # (maven, nuget) are — the matrix default `--ignored` filter - # selected zero tests here and the leg passed vacuously. - # `--include-ignored` runs them, plus any capstone added later. - test_filter: --include-ignored - - os: ubuntu-latest - suite: e2e_nuget + # (No e2e_cargo / e2e_golang rows: neither suite has an + # `#[ignore]`-gated test or needs a real toolchain, so the `test` + # job already runs all of them. No e2e_maven / e2e_nuget / + # e2e_composer rows either: their tests are hermetic and not + # `#[ignore]`d, so `test` runs them on every OS.) # Host build-proof capstones: fresh-checkout install + revert # against the REAL composer/bundler toolchains, each ending in the # manifest-less VEX matrix. `#[ignore]`-gated (the unpinned `test` @@ -736,20 +763,17 @@ jobs: - {os: ubuntu-latest, suite: e2e_redirect_composer_build, composer: '2'} - {os: ubuntu-latest, suite: e2e_redirect_composer_build, composer: '2.2'} - {os: ubuntu-latest, suite: e2e_redirect_composer_build, composer: '1'} - # setup-e2e host guards run in no other job (test job = default - # features; setup-matrix job = shell script). - - {os: ubuntu-latest, suite: setup_matrix_composer, test_filter: host_guard} # Real-bundler gem capstones, one leg per bundler era. Boundaries: # 1.17/2.1 merged GEM section, 2.2 separate sections, 2.5 last # pre-CHECKSUMS, 2.6 CHECKSUMS, 4.0.15/4.0.21 before/after the # strict frozen check (rubygems#9750). bundler <= 2.2 needs - # Ruby <= 3.3 and 1.17-2.1 need Ruby <= 3.1 (`untaint`). + # Ruby <= 3.3 and 1.17-2.1 need Ruby <= 3.1 (`untaint`). 2.7.2 + # (no boundary of its own) is in e2e-full. - {os: ubuntu-latest, suite: e2e_redirect_gem_build, ruby: '3.1', bundler: '1.17.3'} - {os: ubuntu-latest, suite: e2e_redirect_gem_build, ruby: '3.1', bundler: '2.1.4'} - {os: ubuntu-latest, suite: e2e_redirect_gem_build, ruby: '3.1', bundler: '2.2.33'} - {os: ubuntu-latest, suite: e2e_redirect_gem_build, ruby: '3.3', bundler: '2.5.23'} - {os: ubuntu-latest, suite: e2e_redirect_gem_build, ruby: '3.3', bundler: '2.6.9'} - - {os: ubuntu-latest, suite: e2e_redirect_gem_build, ruby: '3.3', bundler: '2.7.2'} - {os: ubuntu-latest, suite: e2e_redirect_gem_build, ruby: '3.4', bundler: '4.0.15'} - {os: ubuntu-latest, suite: e2e_redirect_gem_build, ruby: '3.4', bundler: '4.0.21'} - {os: ubuntu-latest, suite: e2e_vendor_gem_build, ruby: '3.1', bundler: '1.17.3'} @@ -757,11 +781,8 @@ jobs: - {os: ubuntu-latest, suite: e2e_vendor_gem_build, ruby: '3.1', bundler: '2.2.33'} - {os: ubuntu-latest, suite: e2e_vendor_gem_build, ruby: '3.3', bundler: '2.5.23'} - {os: ubuntu-latest, suite: e2e_vendor_gem_build, ruby: '3.3', bundler: '2.6.9'} - - {os: ubuntu-latest, suite: e2e_vendor_gem_build, ruby: '3.3', bundler: '2.7.2'} - {os: ubuntu-latest, suite: e2e_vendor_gem_build, ruby: '3.4', bundler: '4.0.15'} - {os: ubuntu-latest, suite: e2e_vendor_gem_build, ruby: '3.4', bundler: '4.0.21'} - # not #[ignore]-gated -> --include-ignored is mandatory - - {os: ubuntu-latest, suite: setup_matrix_gem, ruby: '3.3', bundler: '2.7.2', test_filter: --include-ignored} # The live-API smoke suites (e2e_npm, e2e_pypi, e2e_gem, # e2e_scan) are intentionally NOT in the PR matrix — their # `#[ignore]`-gated tests hit the real public proxy at @@ -787,14 +808,9 @@ jobs: # Safety-hardening e2e suites. The fast non-ignored ones # (e2e_safety_lock, e2e_safety_yarn_pnp) run via the # standard `test` job above on all three platforms, so no - # matrix entry is needed for them. The two below need real - # toolchains and are #[ignore]-gated. - - os: ubuntu-latest - suite: e2e_safety_cargo_build - - os: macos-latest - suite: e2e_safety_cargo_build - - os: windows-latest - suite: e2e_safety_cargo_build + # matrix entry is needed for them. e2e_safety_pnpm below needs a + # real pnpm and is #[ignore]-gated; e2e_safety_cargo_build runs in + # every cargo-vex-matrix leg (ubuntu, macOS and Windows). - os: ubuntu-latest suite: e2e_safety_pnpm - os: macos-latest @@ -976,47 +992,26 @@ jobs: - {os: ubuntu-latest, suite: e2e_vlt, vlt: '1.0.7', test_filter: --include-ignored vlt_pinned_matrix} - {os: windows-latest, suite: e2e_vlt, vlt: '1.0.0-rc.14', test_filter: --include-ignored vlt_pinned_matrix} # The named corepack pnpm hosted legs (pnpm 7-11, get-uuid, - # zero-touch, --trust-lockfile). `#[ignore]`d and previously run in - # no job; the pinned matrix inside the same suite runs in - # pnpm-compatibility.yml, hence the skip. Node 24 (step below): the + # zero-touch, --trust-lockfile). `#[ignore]`d; the pinned matrix + # inside the same suite runs in pnpm-compatibility.yml, hence the + # skip. Node 24 (step below): the # corepack pnpm@10/11 legs require it. - {os: ubuntu-latest, suite: e2e_redirect_pnpm_build, test_filter: '--ignored --skip pnpm_pinned_matrix'} - {os: macos-latest, suite: e2e_redirect_pnpm_build, test_filter: '--ignored --skip pnpm_pinned_matrix'} - {os: windows-latest, suite: e2e_redirect_pnpm_build, test_filter: '--ignored --skip pnpm_pinned_matrix'} # Real-uv hosted/vendored capstones ending in manifest-less VEX: - # one leg per uv 0.N line + the 0.5.x boundary (0.5.4 still + # the oldest and newest line + the 0.5.x boundary (0.5.4 still # re-resolves a transitive override / rejects a repointed - # constraint under --locked; 0.5.5 keeps both). + # constraint under --locked; 0.5.5 keeps both). The other 0.N + # lines and 0.5.3/0.5.6 are in e2e-full. - {os: ubuntu-latest, suite: e2e_redirect_uv_build, uv: '0.1.45'} - - {os: ubuntu-latest, suite: e2e_redirect_uv_build, uv: '0.2.37'} - - {os: ubuntu-latest, suite: e2e_redirect_uv_build, uv: '0.3.5'} - - {os: ubuntu-latest, suite: e2e_redirect_uv_build, uv: '0.4.30'} - - {os: ubuntu-latest, suite: e2e_redirect_uv_build, uv: '0.5.3'} - {os: ubuntu-latest, suite: e2e_redirect_uv_build, uv: '0.5.4'} - {os: ubuntu-latest, suite: e2e_redirect_uv_build, uv: '0.5.5'} - - {os: ubuntu-latest, suite: e2e_redirect_uv_build, uv: '0.5.6'} - - {os: ubuntu-latest, suite: e2e_redirect_uv_build, uv: '0.6.17'} - - {os: ubuntu-latest, suite: e2e_redirect_uv_build, uv: '0.7.22'} - - {os: ubuntu-latest, suite: e2e_redirect_uv_build, uv: '0.8.24'} - - {os: ubuntu-latest, suite: e2e_redirect_uv_build, uv: '0.9.30'} - - {os: ubuntu-latest, suite: e2e_redirect_uv_build, uv: '0.10.12'} - - {os: ubuntu-latest, suite: e2e_redirect_uv_build, uv: '0.11.33'} - {os: ubuntu-latest, suite: e2e_redirect_uv_build, uv: '0.12.17'} - {os: macos-latest, suite: e2e_redirect_uv_build, uv: '0.12.17'} - {os: ubuntu-latest, suite: e2e_vendor_pypi_build, uv: '0.1.45', test_filter: --include-ignored} - - {os: ubuntu-latest, suite: e2e_vendor_pypi_build, uv: '0.2.37', test_filter: --include-ignored} - - {os: ubuntu-latest, suite: e2e_vendor_pypi_build, uv: '0.3.5', test_filter: --include-ignored} - - {os: ubuntu-latest, suite: e2e_vendor_pypi_build, uv: '0.4.30', test_filter: --include-ignored} - - {os: ubuntu-latest, suite: e2e_vendor_pypi_build, uv: '0.5.3', test_filter: --include-ignored} - {os: ubuntu-latest, suite: e2e_vendor_pypi_build, uv: '0.5.4', test_filter: --include-ignored} - {os: ubuntu-latest, suite: e2e_vendor_pypi_build, uv: '0.5.5', test_filter: --include-ignored} - - {os: ubuntu-latest, suite: e2e_vendor_pypi_build, uv: '0.5.6', test_filter: --include-ignored} - - {os: ubuntu-latest, suite: e2e_vendor_pypi_build, uv: '0.6.17', test_filter: --include-ignored} - - {os: ubuntu-latest, suite: e2e_vendor_pypi_build, uv: '0.7.22', test_filter: --include-ignored} - - {os: ubuntu-latest, suite: e2e_vendor_pypi_build, uv: '0.8.24', test_filter: --include-ignored} - - {os: ubuntu-latest, suite: e2e_vendor_pypi_build, uv: '0.9.30', test_filter: --include-ignored} - - {os: ubuntu-latest, suite: e2e_vendor_pypi_build, uv: '0.10.12', test_filter: --include-ignored} - - {os: ubuntu-latest, suite: e2e_vendor_pypi_build, uv: '0.11.33', test_filter: --include-ignored} - {os: ubuntu-latest, suite: e2e_vendor_pypi_build, uv: '0.12.17', test_filter: --include-ignored} - {os: macos-latest, suite: e2e_vendor_pypi_build, uv: '0.12.17', test_filter: --include-ignored} # The Poetry / PDM / Hatch / Pipenv / pip / deno manifest-less VEX @@ -1026,7 +1021,8 @@ jobs: # `--ignored`. # Real-Poetry hosted + vendored capstones (#[ignore]-gated, # unix-only). One leg per major / lock format: 1.0 (lock 1.0), - # 1.1 (lock 1.1), 1.8 (lock 2.0), 2.x (lock 2.1). + # 1.1 (lock 1.1), 1.8 (lock 2.0), 2.0 (first lock 2.1 writer), + # current. - {os: ubuntu-latest, suite: e2e_vex_build, test_filter: 'poetry:: --ignored', poetry: '1.0.10'} - {os: ubuntu-latest, suite: e2e_vex_build, test_filter: 'poetry:: --ignored', poetry: '1.1.15'} - {os: ubuntu-latest, suite: e2e_vex_build, test_filter: 'poetry:: --ignored', poetry: '1.8.5'} @@ -1054,11 +1050,9 @@ jobs: - {os: macos-latest, suite: e2e_vex_build, test_filter: 'hatch:: --ignored', hatch: '1.18.1'} # Real Pipenv / pip capstones (mock patch server; the tools are # bootstrapped from PyPI). `pipenv:` / `pip:` hold one or more - # space-separated releases the suite loops over. + # space-separated releases the suite loops over. The middle + # Pipenv releases are in e2e-full. - {os: ubuntu-latest, suite: e2e_vex_build, test_filter: 'pipenv:: --ignored', pipenv: '2022.12.19'} - - {os: ubuntu-latest, suite: e2e_vex_build, test_filter: 'pipenv:: --ignored', pipenv: '2023.12.1'} - - {os: ubuntu-latest, suite: e2e_vex_build, test_filter: 'pipenv:: --ignored', pipenv: '2024.4.1'} - - {os: ubuntu-latest, suite: e2e_vex_build, test_filter: 'pipenv:: --ignored', pipenv: '2025.1.3'} - {os: ubuntu-latest, suite: e2e_vex_build, test_filter: 'pipenv:: --ignored', pipenv: '2026.8.0'} - {os: macos-latest, suite: e2e_vex_build, test_filter: 'pipenv:: --ignored', pipenv: '2022.12.19 2026.8.0'} - {os: ubuntu-latest, suite: e2e_vex_build, test_filter: 'pip:: --ignored', pip: '22 23 24 25 26'} @@ -1076,12 +1070,22 @@ jobs: - {os: ubuntu-latest, suite: e2e_vendor_maven_build, maven: '3.9.16'} - {os: ubuntu-latest, suite: e2e_vendor_maven_build, maven: '4.0.0-rc-6'} - {os: macos-latest, suite: e2e_vendor_maven_build, maven: '3.9.16'} + - {os: ubuntu-latest, suite: e2e_vendor_jvm_build, maven: '3.6.3', test_filter: '--ignored maven_reactor'} + - {os: ubuntu-latest, suite: e2e_vendor_jvm_build, maven: '3.8.9', test_filter: '--ignored maven_reactor'} + - {os: ubuntu-latest, suite: e2e_vendor_jvm_build, maven: '3.9.2', test_filter: '--ignored maven_reactor'} + - {os: ubuntu-latest, suite: e2e_vendor_jvm_build, maven: '3.9.16', test_filter: '--ignored maven_reactor'} + - {os: ubuntu-latest, suite: e2e_vendor_jvm_build, maven: '4.0.0-rc-6', test_filter: '--ignored maven_reactor'} + - {os: macos-latest, suite: e2e_vendor_jvm_build, maven: '3.9.16', test_filter: '--ignored maven_reactor'} + - {os: windows-latest, suite: e2e_vendor_jvm_build, maven: '3.9.16', test_filter: '--ignored maven_reactor'} + - {os: ubuntu-latest, suite: e2e_vendor_jvm_build, gradle: '6.9.4', java: '11', test_filter: '--ignored gradle_multi_project'} + - {os: ubuntu-latest, suite: e2e_vendor_jvm_build, gradle: '7.6.4', java: '17', test_filter: '--ignored gradle_multi_project'} + - {os: ubuntu-latest, suite: e2e_vendor_jvm_build, gradle: '8.14.3', java: '17', test_filter: '--ignored gradle_multi_project'} + - {os: ubuntu-latest, suite: e2e_vendor_jvm_build, gradle: '9.8.0', java: '17', test_filter: '--ignored gradle_multi_project'} + - {os: windows-latest, suite: e2e_vendor_jvm_build, gradle: '8.14.3', java: '17', test_filter: '--ignored gradle_multi_project'} # Real .NET SDK capstones: hosted + vendored nuget, one leg per SDK - # major (the suite pins the major through a sandbox global.json). + # major (the suite pins the major through a sandbox global.json): + # the oldest and newest here, 7-9 on ubuntu in e2e-full. - {os: ubuntu-latest, suite: e2e_nuget_dotnet_build, dotnet: '6'} - - {os: ubuntu-latest, suite: e2e_nuget_dotnet_build, dotnet: '7'} - - {os: ubuntu-latest, suite: e2e_nuget_dotnet_build, dotnet: '8'} - - {os: ubuntu-latest, suite: e2e_nuget_dotnet_build, dotnet: '9'} - {os: ubuntu-latest, suite: e2e_nuget_dotnet_build, dotnet: '10'} - {os: macos-latest, suite: e2e_nuget_dotnet_build, dotnet: '8'} # Real deno negative capstone (no hosted/vendored wiring exists for @@ -1093,37 +1097,36 @@ jobs: # pipenv) or bootstrap a tool from PyPI before the suite (poetry, pdm, # hatch), hence more than the 25 minutes the older legs needed. timeout-minutes: 40 - steps: + # What .cargo/config.toml's [env] gives processes cargo launches; these + # legs launch the test binaries themselves. + env: + SOCKET_NO_CONFIG: '1' + SOCKET_NO_UPDATE_CHECK: '1' + # e2e-full runs these same steps over its rows. + steps: &e2e-steps - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - name: Install Rust - # rustup is pre-installed on GitHub-hosted runners. `rustup show` - # reads rust-toolchain.toml in the repo root, then installs the - # pinned channel + listed components if missing. No third-party - # action dependency needed for toolchain setup. - run: rustup show - - - name: Cache cargo - # Swatinem/rust-cache instead of a raw actions/cache of the whole - # target/ dir: it prunes the cache to dependency artifacts (~5-10x - # smaller), which keeps this repo's total cache footprint inside - # GitHub's 10 GiB budget — previously a single main run produced - # ~8 GiB of caches and every PR save evicted them (cold e2e legs - # recompiled the full ~275-crate graph each run). save-if restricts - # writes to main so PR branches restore without churning the budget. - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - name: Download the e2e binaries + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: - # Matrix suites otherwise collide on one key: only one of the ~9 - # same-OS legs wins the cache reserve and the rest fail to save. - # Several suites run one leg per pinned toolchain release (bun, uv, - # poetry, pdm, hatch, pipenv, pip, bundler, composer, maven, dotnet, - # deno, vlt), so the release is part of the key too, plus the vlt - # store linker of the two ubuntu e2e_safety_vlt legs. - key: ${{ matrix.suite }}-${{ matrix.vlt || matrix.bun || matrix.uv || matrix.poetry || matrix.pdm || matrix.hatch || matrix.pipenv || matrix.pip || matrix.bundler || matrix.composer || matrix.maven || matrix.dotnet || matrix.deno || 'default' }}${{ matrix.vlt_store_linker && format('-{0}', matrix.vlt_store_linker) || '' }} - save-if: ${{ github.ref == 'refs/heads/main' }} + pattern: e2e-bin-${{ matrix.os }}* + merge-multiple: true + path: target/e2e-bin + + - name: Stage the CLI where the test binaries expect it + # `CARGO_BIN_EXE_socket-patch` was baked in at compile time as + # /target/debug/socket-patch; CARGO_TARGET_TMPDIR likewise. + shell: bash + run: | + set -euo pipefail + exe='' + if [ "$RUNNER_OS" = Windows ]; then exe=.exe; fi + chmod +x target/e2e-bin/* || true + mkdir -p target/debug target/tmp + cp "target/e2e-bin/socket-patch$exe" "target/debug/socket-patch$exe" - name: Setup Node.js if: matrix.suite == 'e2e_npm' || matrix.suite == 'e2e_scan' || matrix.suite == 'e2e_safety_pnpm' @@ -1248,28 +1251,46 @@ jobs: php-version: '8.2' tools: composer:${{ matrix.composer }} - - name: Setup Java (Maven legs) - if: matrix.maven != '' + - name: Setup Java (Maven and Gradle legs) + if: matrix.maven != '' || matrix.gradle != '' uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin - java-version: '17' + java-version: ${{ matrix.java || '17' }} - - name: Install Maven ${{ matrix.maven }} - if: matrix.maven != '' + - name: Install Maven ${{ matrix.maven || '3.9.16' }} + if: matrix.maven != '' || matrix.gradle != '' # Straight from the Apache archive (sha512-verified), so a leg gets # exactly the release it names rather than the runner's Maven. shell: bash env: - MAVEN_VERSION: ${{ matrix.maven }} + MAVEN_VERSION: ${{ matrix.maven || '3.9.16' }} run: | major="${MAVEN_VERSION%%.*}" url="https://archive.apache.org/dist/maven/maven-${major}/${MAVEN_VERSION}/binaries/apache-maven-${MAVEN_VERSION}-bin.tar.gz" curl -fsSL --retry 3 "$url" -o "$RUNNER_TEMP/maven.tgz" - sum="$(curl -fsSL --retry 3 "$url.sha512" | cut -d' ' -f1)" - echo "$sum $RUNNER_TEMP/maven.tgz" | shasum -a 512 -c - - tar -xzf "$RUNNER_TEMP/maven.tgz" -C "$RUNNER_TEMP" - echo "SOCKET_PATCH_MAVEN_E2E_MVN=$RUNNER_TEMP/apache-maven-${MAVEN_VERSION}/bin/mvn" >> "$GITHUB_ENV" + curl -fsSL --retry 3 "$url.sha512" -o "$RUNNER_TEMP/maven.sha512" + python -c 'import hashlib, pathlib, os; p=pathlib.Path(os.environ["RUNNER_TEMP"]); assert hashlib.sha512((p/"maven.tgz").read_bytes()).hexdigest() == (p/"maven.sha512").read_text().split()[0]' + # Python accepts native Windows paths for both archive and destination. + python -m tarfile -e "$RUNNER_TEMP/maven.tgz" "$RUNNER_TEMP" + launcher="$RUNNER_TEMP/apache-maven-${MAVEN_VERSION}/bin/mvn" + if [ "$RUNNER_OS" = Windows ]; then launcher="${launcher}.cmd"; fi + echo "SOCKET_PATCH_MAVEN_E2E_MVN=$launcher" >> "$GITHUB_ENV" + + - name: Install Gradle ${{ matrix.gradle }} + if: matrix.gradle != '' + shell: bash + env: + GRADLE_VERSION: ${{ matrix.gradle }} + run: | + url="https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip" + curl -fsSL --retry 3 "$url" -o "$RUNNER_TEMP/gradle.zip" + curl -fsSL --retry 3 "$url.sha256" -o "$RUNNER_TEMP/gradle.sha256" + python -c 'import hashlib, pathlib, os; p=pathlib.Path(os.environ["RUNNER_TEMP"]); assert hashlib.sha256((p/"gradle.zip").read_bytes()).hexdigest() == (p/"gradle.sha256").read_text().strip()' + unzip -q "$RUNNER_TEMP/gradle.zip" -d "$RUNNER_TEMP" + launcher="$RUNNER_TEMP/gradle-${GRADLE_VERSION}/bin/gradle" + if [ "$RUNNER_OS" = Windows ]; then launcher="${launcher}.bat"; fi + echo "SOCKET_PATCH_GRADLE_E2E_GRADLE=$launcher" >> "$GITHUB_ENV" - name: Setup .NET SDK if: matrix.dotnet != '' @@ -1373,13 +1394,26 @@ jobs: SOCKET_PATCH_BUNDLER_E2E_VERSION: ${{ matrix.bundler }} SOCKET_PATCH_COMPOSER_E2E_REQUIRED: ${{ matrix.composer != '' && '1' || '' }} SOCKET_PATCH_COMPOSER_E2E_VERSION: ${{ matrix.composer }} - SOCKET_PATCH_MAVEN_E2E_REQUIRED: ${{ matrix.maven != '' && '1' || '' }} - SOCKET_PATCH_MAVEN_E2E_VERSION: ${{ matrix.maven }} + SOCKET_PATCH_MAVEN_E2E_REQUIRED: ${{ (matrix.maven != '' || matrix.gradle != '') && '1' || '' }} + SOCKET_PATCH_MAVEN_E2E_VERSION: ${{ matrix.maven || (matrix.gradle != '' && '3.9.16') || '' }} + SOCKET_PATCH_GRADLE_E2E_REQUIRED: ${{ matrix.gradle != '' && '1' || '' }} + SOCKET_PATCH_GRADLE_E2E_VERSION: ${{ matrix.gradle }} SOCKET_PATCH_DOTNET_E2E_REQUIRED: ${{ matrix.dotnet != '' && '1' || '' }} SOCKET_PATCH_DOTNET_E2E_VERSION: ${{ matrix.dotnet }} SOCKET_PATCH_DENO_E2E_REQUIRED: ${{ matrix.deno != '' && '1' || '' }} SOCKET_PATCH_DENO_E2E_VERSION: ${{ matrix.deno }} - run: cargo test -p socket-patch-cli --all-features --test ${{ matrix.suite }} -- ${{ matrix.test_filter || '--ignored' }} + E2E_SUITE: ${{ matrix.suite }} + E2E_TEST_FILTER: ${{ matrix.test_filter || '--ignored' }} + shell: bash + # Runs from the package root with CARGO_MANIFEST_DIR set, as + # `cargo test` would. + run: | + exe='' + if [ "$RUNNER_OS" = Windows ]; then exe=.exe; fi + cd crates/socket-patch-cli + export CARGO_MANIFEST_DIR="$PWD" + # shellcheck disable=SC2086 # the filter is several libtest arguments + "../../target/e2e-bin/$E2E_SUITE$exe" $E2E_TEST_FILTER - name: Run vlt e2e tests if: matrix.vlt != '' @@ -1394,12 +1428,70 @@ jobs: run: | set -uo pipefail status=0 - # shellcheck disable=SC2086 # the filter is several libtest arguments - cargo test -p socket-patch-cli --all-features --test "$VLT_SUITE" -- $VLT_TEST_FILTER 2>&1 | tee vlt-leg.log || status=1 + exe='' + if [ "$RUNNER_OS" = Windows ]; then exe=.exe; fi + ( + cd crates/socket-patch-cli + export CARGO_MANIFEST_DIR="$PWD" + # shellcheck disable=SC2086 # the filter is several libtest arguments + "../../target/e2e-bin/$VLT_SUITE$exe" $VLT_TEST_FILTER + ) 2>&1 | tee vlt-leg.log || status=1 py=$(command -v python3 || command -v python) - "$py" scripts/check-vlt-legs.py --manifest crates/socket-patch-cli/tests/vlt-leg-manifest.json vlt-leg.log || status=1 + # No cargo `Running` line when the binary runs directly: name it. + "$py" scripts/check-vlt-legs.py --binary "$VLT_SUITE" --manifest crates/socket-patch-cli/tests/vlt-leg-manifest.json vlt-leg.log || status=1 exit "$status" + # The PM-version legs with no boundary of their own: the middle uv lines, + # Pipenv releases and .NET SDK majors, and bundler 2.7. Skipped + # on pull_request (the `e2e` rows keep every named boundary, the oldest + # and newest release of each tool and every vlt era there); main pushes, + # the nightly schedule and dispatch run them with the `e2e` steps. + e2e-full: + if: github.event_name != 'pull_request' + needs: [test, e2e-build] + strategy: + fail-fast: false + matrix: + include: + - {os: ubuntu-latest, suite: e2e_redirect_gem_build, ruby: '3.3', bundler: '2.7.2'} + - {os: ubuntu-latest, suite: e2e_vendor_gem_build, ruby: '3.3', bundler: '2.7.2'} + - {os: ubuntu-latest, suite: e2e_redirect_uv_build, uv: '0.2.37'} + - {os: ubuntu-latest, suite: e2e_redirect_uv_build, uv: '0.3.5'} + - {os: ubuntu-latest, suite: e2e_redirect_uv_build, uv: '0.4.30'} + - {os: ubuntu-latest, suite: e2e_redirect_uv_build, uv: '0.5.3'} + - {os: ubuntu-latest, suite: e2e_redirect_uv_build, uv: '0.5.6'} + - {os: ubuntu-latest, suite: e2e_redirect_uv_build, uv: '0.6.17'} + - {os: ubuntu-latest, suite: e2e_redirect_uv_build, uv: '0.7.22'} + - {os: ubuntu-latest, suite: e2e_redirect_uv_build, uv: '0.8.24'} + - {os: ubuntu-latest, suite: e2e_redirect_uv_build, uv: '0.9.30'} + - {os: ubuntu-latest, suite: e2e_redirect_uv_build, uv: '0.10.12'} + - {os: ubuntu-latest, suite: e2e_redirect_uv_build, uv: '0.11.33'} + - {os: ubuntu-latest, suite: e2e_vendor_pypi_build, uv: '0.2.37', test_filter: --include-ignored} + - {os: ubuntu-latest, suite: e2e_vendor_pypi_build, uv: '0.3.5', test_filter: --include-ignored} + - {os: ubuntu-latest, suite: e2e_vendor_pypi_build, uv: '0.4.30', test_filter: --include-ignored} + - {os: ubuntu-latest, suite: e2e_vendor_pypi_build, uv: '0.5.3', test_filter: --include-ignored} + - {os: ubuntu-latest, suite: e2e_vendor_pypi_build, uv: '0.5.6', test_filter: --include-ignored} + - {os: ubuntu-latest, suite: e2e_vendor_pypi_build, uv: '0.6.17', test_filter: --include-ignored} + - {os: ubuntu-latest, suite: e2e_vendor_pypi_build, uv: '0.7.22', test_filter: --include-ignored} + - {os: ubuntu-latest, suite: e2e_vendor_pypi_build, uv: '0.8.24', test_filter: --include-ignored} + - {os: ubuntu-latest, suite: e2e_vendor_pypi_build, uv: '0.9.30', test_filter: --include-ignored} + - {os: ubuntu-latest, suite: e2e_vendor_pypi_build, uv: '0.10.12', test_filter: --include-ignored} + - {os: ubuntu-latest, suite: e2e_vendor_pypi_build, uv: '0.11.33', test_filter: --include-ignored} + - {os: ubuntu-latest, suite: e2e_vex_build, test_filter: 'pipenv:: --ignored', pipenv: '2023.12.1'} + - {os: ubuntu-latest, suite: e2e_vex_build, test_filter: 'pipenv:: --ignored', pipenv: '2024.4.1'} + - {os: ubuntu-latest, suite: e2e_vex_build, test_filter: 'pipenv:: --ignored', pipenv: '2025.1.3'} + - {os: ubuntu-latest, suite: e2e_nuget_dotnet_build, dotnet: '7'} + - {os: ubuntu-latest, suite: e2e_nuget_dotnet_build, dotnet: '8'} + - {os: ubuntu-latest, suite: e2e_nuget_dotnet_build, dotnet: '9'} + runs-on: ${{ matrix.os }} + timeout-minutes: 40 + # What .cargo/config.toml's [env] gives processes cargo launches; these + # legs launch the test binaries themselves. + env: + SOCKET_NO_CONFIG: '1' + SOCKET_NO_UPDATE_CHECK: '1' + steps: *e2e-steps + # ---------------------------------------------------------------------- # Docker-driven real-package e2e suite. # @@ -1410,10 +1502,17 @@ jobs: # managers and run socket-patch against a wiremock-served fixture — # no real Socket API contact. Hermetic, reproducible. # - # Triggered on every PR. The existing `e2e` job above stays for - # `--ignored` real-API smoke runs (manual / scheduled). + # Nightly, on dispatch and on v5 pushes only: coverage-docker runs these + # suites on each push (with an instrumented binary mounted in); this job + # is the one run of them against the base image's full-LTO release + # binary. The `e2e` job above runs the `#[ignore]`-gated real-toolchain + # capstones; the live-API smoke suites are run by hand (see the note in + # its matrix). # ---------------------------------------------------------------------- e2e-docker: + # The v5 branch has no nightly of its own, so its pushes run it too. + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/release/v5-prerelease' + needs: docker-base runs-on: ubuntu-latest timeout-minutes: 35 permissions: @@ -1443,13 +1542,15 @@ jobs: # No `actions/cache` here intentionally. This job builds Docker # images and would be flagged by zizmor's cache-poisoning audit. - - name: Build base image - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + - name: Download base image + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: - context: . - file: tests/docker/Dockerfile.base - tags: socket-patch-test-base:latest - load: true + pattern: docker-base-image* + merge-multiple: true + path: docker-base + + - name: Load base image + run: docker load --input docker-base/socket-patch-test-base.tar - name: Build ${{ matrix.ecosystem }} image uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 @@ -1462,9 +1563,10 @@ jobs: - name: Run ${{ matrix.ecosystem }} Docker e2e test # Every ecosystem is unconditionally compiled in; only the # `docker-e2e` feature is needed to compile the suite itself. - # The vendored build-proof capstones (each ending in the - # manifest-less VEX stage) ride the same image as their ecosystem's - # main suite, as in coverage-docker. + # The composer/nuget/pypi vendored build-proof capstones (each + # ending in the manifest-less VEX stage) ride the same image as + # their ecosystem's main suite; the gem and maven vendor capstones + # run only in coverage-docker. run: | EXTRA="" case "${{ matrix.ecosystem }}" in @@ -1523,15 +1625,17 @@ jobs: fail-fast: false matrix: # 4.0.2 bare-hex checksum writer (4.0.0-4.0.2), 4.1.0 first - # `10c0/` writer, then a spread up to current. - os: [ubuntu-latest] - yarn: ['4.0.2', '4.1.0', '4.6.0', '4.12.0', '4.18.0'] + # `10c0/` writer, current, and 4.12.0 on macOS / Windows. The rest + # of the spread (4.6.0, 4.12.0 on ubuntu) is yarn-berry-full. include: + - {os: ubuntu-latest, yarn: '4.0.2'} + - {os: ubuntu-latest, yarn: '4.1.0'} + - {os: ubuntu-latest, yarn: '4.18.0'} - {os: macos-latest, yarn: '4.12.0'} - {os: windows-latest, yarn: '4.12.0'} runs-on: ${{ matrix.os }} timeout-minutes: 30 - steps: + steps: &yarn-berry-steps - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -1553,28 +1657,63 @@ jobs: YARN_BERRY_RELEASE: ${{ matrix.yarn }} run: scripts/yarn-berry-vex-matrix.sh "$YARN_BERRY_RELEASE" + # Skipped on pull_request, like e2e-full. + yarn-berry-full: + name: yarn-berry ${{ matrix.yarn }} (${{ matrix.os }}) + if: github.event_name != 'pull_request' + needs: test + strategy: + fail-fast: false + matrix: + include: + - {os: ubuntu-latest, yarn: '4.6.0'} + - {os: ubuntu-latest, yarn: '4.12.0'} + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + steps: *yarn-berry-steps + + # Every toolchain and every lock re-encoding at least once on PRs (the + # toolchain's own lock is re-encoded as v1-v4 before socket-patch touches + # it, the committed-lockfile case; '' keeps the toolchain's own format), + # plus macOS and Windows; 1.93.1 with its own lock (on every OS) is the + # pinned rust-toolchain.toml cell `cargo test` used to give + # e2e_safety_cargo_build. cargo-vex-matrix-full runs the rest of the + # toolchain x lock cross off pull_request. Each leg also runs + # e2e_safety_cargo_build (its headline test honours the knobs). cargo-vex-matrix: name: cargo ${{ matrix.toolchain }} lock-v${{ matrix.lock || 'own' }} (${{ matrix.os }}) - needs: test + needs: [test, e2e-build] runs-on: ${{ matrix.os }} timeout-minutes: 40 + # What .cargo/config.toml's [env] gives processes cargo launches; these + # legs launch the test binaries themselves. + env: + SOCKET_NO_CONFIG: '1' + SOCKET_NO_UPDATE_CHECK: '1' strategy: fail-fast: false matrix: - # The toolchain's own lock is re-encoded as v1-v4 before - # socket-patch touches it (the committed-lockfile case); '' keeps the - # toolchain's own format. - os: [ubuntu-latest] - toolchain: ['1.82.0', '1.93.1', 'stable'] - lock: ['', '1', '2', '3', '4'] include: + - {os: ubuntu-latest, toolchain: '1.93.1', lock: ''} + - {os: ubuntu-latest, toolchain: '1.93.1', lock: '1'} + - {os: ubuntu-latest, toolchain: stable, lock: '2'} + - {os: ubuntu-latest, toolchain: '1.82.0', lock: '3'} + - {os: ubuntu-latest, toolchain: '1.93.1', lock: '4'} - {os: macos-latest, toolchain: stable, lock: '1'} - {os: windows-latest, toolchain: stable, lock: '1'} - steps: + - {os: macos-latest, toolchain: '1.93.1', lock: ''} + - {os: windows-latest, toolchain: '1.93.1', lock: ''} + steps: &cargo-vex-steps - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false + - name: Download the e2e binaries + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: e2e-bin-${{ matrix.os }}* + merge-multiple: true + path: target/e2e-bin - name: Install Rust run: rustup show - name: Install the cargo under test @@ -1585,12 +1724,9 @@ jobs: env: CARGO_TEST_TOOLCHAIN: ${{ matrix.toolchain }} run: rustup toolchain install "$CARGO_TEST_TOOLCHAIN" --profile minimal - - name: Cache cargo - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - with: - key: cargo-vex-${{ matrix.toolchain }} - save-if: ${{ github.ref == 'refs/heads/main' }} - name: Real-cargo hosted/vendored + manifest-less VEX + # The e2e-build binaries, run from the package root as `cargo test` + # would (see the e2e job's staging step). shell: bash env: SOCKET_PATCH_CARGO_E2E_REQUIRED: '1' @@ -1598,8 +1734,48 @@ jobs: SOCKET_PATCH_CARGO_E2E_LOCK_VERSION: ${{ matrix.lock }} run: | set -euo pipefail - cargo test -p socket-patch-cli --test e2e_redirect_cargo_build --test e2e_redirect_cargo_shapes --test e2e_vendor_cargo_build --test mode_migration_cargo - cargo test -p socket-patch-cli --test e2e_safety_cargo_build -- --ignored + exe='' + if [ "$RUNNER_OS" = Windows ]; then exe=.exe; fi + chmod +x target/e2e-bin/* || true + mkdir -p target/debug target/tmp + cp "target/e2e-bin/socket-patch$exe" "target/debug/socket-patch$exe" + cd crates/socket-patch-cli + export CARGO_MANIFEST_DIR="$PWD" + status=0 + for suite in e2e_redirect_cargo_build e2e_redirect_cargo_shapes e2e_vendor_cargo_build mode_migration_cargo; do + echo "::group::$suite" + "../../target/e2e-bin/$suite$exe" || status=1 + echo "::endgroup::" + done + "../../target/e2e-bin/e2e_safety_cargo_build$exe" --ignored || status=1 + exit "$status" + + cargo-vex-matrix-full: + name: cargo ${{ matrix.toolchain }} lock-v${{ matrix.lock || 'own' }} (${{ matrix.os }}) + if: github.event_name != 'pull_request' + needs: [test, e2e-build] + runs-on: ${{ matrix.os }} + timeout-minutes: 40 + # What .cargo/config.toml's [env] gives processes cargo launches; these + # legs launch the test binaries themselves. + env: + SOCKET_NO_CONFIG: '1' + SOCKET_NO_UPDATE_CHECK: '1' + strategy: + fail-fast: false + matrix: + include: + - {os: ubuntu-latest, toolchain: '1.82.0', lock: '1'} + - {os: ubuntu-latest, toolchain: '1.82.0', lock: '2'} + - {os: ubuntu-latest, toolchain: '1.82.0', lock: '4'} + - {os: ubuntu-latest, toolchain: '1.82.0', lock: ''} + - {os: ubuntu-latest, toolchain: '1.93.1', lock: '2'} + - {os: ubuntu-latest, toolchain: '1.93.1', lock: '3'} + - {os: ubuntu-latest, toolchain: stable, lock: ''} + - {os: ubuntu-latest, toolchain: stable, lock: '1'} + - {os: ubuntu-latest, toolchain: stable, lock: '3'} + - {os: ubuntu-latest, toolchain: stable, lock: '4'} + steps: *cargo-vex-steps # Manifest `[patch]` + the tagged detached lock (the v5 vendored cargo # wiring) on cargo 1.41 and 1.56 — below / at the 1.56 floor of @@ -1637,78 +1813,6 @@ jobs: set -euo pipefail cargo test -p socket-patch-cli --test e2e_vendor_cargo_build -- old_toolchain --nocapture - # ---------------------------------------------------------------------- - # Experimental `setup`-flow matrix (NON-BLOCKING). - # - # For each ecosystem/package manager, drives the full intended flow — - # prepare deps + a committed patch set, run `socket-patch setup`, run - # the native install, check whether the patch was applied — plus the - # negative controls (no setup, empty/wrong/alt patch sets). See - # tests/setup_matrix/ and scripts/setup-matrix.sh. - # - # This is EXPERIMENTAL and intentionally not required to pass yet: - # `setup` only configures npm-family install hooks today, so most - # non-npm `baseline_with_setup` cases are EXPECTED to fail (a baseline - # of what `setup` must eventually support). `continue-on-error: true` - # means this job never blocks a PR — it must ALSO be left OUT of the - # repo's required status checks (configured in the branch-protection - # UI, not in this file). The orchestrator exits non-zero only on a - # *regression* vs the recorded baseline; the full per-case result set - # is uploaded as a JSON artifact for inspection. - # ---------------------------------------------------------------------- - setup-matrix: - runs-on: ubuntu-latest - timeout-minutes: 45 - continue-on-error: true - permissions: - contents: read - strategy: - fail-fast: false - matrix: - ecosystem: [npm, pypi, cargo, gem, golang, maven, composer, nuget, deno] - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Set up Docker Buildx - # `driver: docker` — the per-ecosystem image's `FROM - # socket-patch-test-base:latest` only resolves when buildx talks - # directly to the host docker daemon (see e2e-docker above). - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - with: - driver: docker - - - name: Install Rust - run: rustup show - - - name: Build base image - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 - with: - context: . - file: tests/docker/Dockerfile.base - tags: socket-patch-test-base:latest - load: true - - - name: Build ${{ matrix.ecosystem }} image - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 - with: - context: . - file: tests/docker/Dockerfile.${{ matrix.ecosystem }} - tags: socket-patch-test-${{ matrix.ecosystem }}:latest - load: true - - - name: Run ${{ matrix.ecosystem }} setup-matrix - run: scripts/setup-matrix.sh run --ecosystem ${{ matrix.ecosystem }} --out "report-${{ matrix.ecosystem }}.json" - - - name: Upload ${{ matrix.ecosystem }} setup-matrix report - if: always() - uses: ./.github/actions/upload-artifact - with: - name: setup-matrix-${{ matrix.ecosystem }} - path: report-${{ matrix.ecosystem }}.json - # ---------------------------------------------------------------------- # Hosted-mode production e2e — REQUIRED status check, with a kill switch. # diff --git a/.github/workflows/composer-compatibility.yml b/.github/workflows/composer-compatibility.yml index 61dd72d3d..1b5d7a287 100644 --- a/.github/workflows/composer-compatibility.yml +++ b/.github/workflows/composer-compatibility.yml @@ -29,6 +29,13 @@ on: - 'crates/*/Cargo.toml' - 'crates/socket-patch-core/src/vendor/**' - 'crates/socket-patch-core/src/patch/**' + - 'crates/socket-patch-core/src/formats/composer/**' + - 'crates/socket-patch-core/src/formats/registry.rs' + - 'crates/socket-patch-core/src/hosted/**' + - 'crates/socket-patch-core/src/ledgers.rs' + - 'crates/socket-patch-core/src/policy/**' + - 'crates/socket-patch-core/src/rollout.rs' + - 'crates/socket-patch-core/src/rollout/**' - 'crates/socket-patch-core/src/manifest/**' - 'crates/socket-patch-core/tests/fixtures/redirect/composer/**' - 'crates/socket-patch-core/tests/fixtures/composer-version-vectors.json' @@ -40,6 +47,7 @@ on: - 'crates/socket-patch-core/src/utils/durability.rs' - 'crates/socket-patch-core/src/vex/**' - 'crates/socket-patch-cli/src/commands/vendor*' + - 'crates/socket-patch-cli/src/commands/vendored_backend/**' - 'crates/socket-patch-cli/src/commands/get*.rs' - 'crates/socket-patch-cli/src/commands/apply.rs' - 'crates/socket-patch-cli/src/commands/rollback.rs' diff --git a/.github/workflows/go-compatibility.yml b/.github/workflows/go-compatibility.yml index 18149fad3..3f0e6b9b7 100644 --- a/.github/workflows/go-compatibility.yml +++ b/.github/workflows/go-compatibility.yml @@ -30,7 +30,7 @@ permissions: concurrency: group: go-compat-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: SOCKET_NO_CONFIG: '1' diff --git a/.github/workflows/npm-compatibility.yml b/.github/workflows/npm-compatibility.yml index 45ad47596..4aa62e0ff 100644 --- a/.github/workflows/npm-compatibility.yml +++ b/.github/workflows/npm-compatibility.yml @@ -7,7 +7,28 @@ name: npm hosted/vendored compatibility # installs one pinned npm and runs them. See docs/testing/npm-compatibility.md. on: + # PRs: any crate source, but only the test files these capstones + # compile (a later `!` pattern excludes, a later plain one re-includes). + # Main pushes stay unfiltered. pull_request: + paths: + - '.github/actions/upload-artifact/**' + - 'Cargo.lock' + - 'Cargo.toml' + - 'rust-toolchain.toml' + - '.cargo/config.toml' + - '.github/workflows/npm-compatibility.yml' + - 'docs/testing/npm-compatibility.md' + - 'crates/**' + - '!crates/**/*.md' + - '!crates/socket-patch-node/**' + - '!crates/socket-patch-core/tests/**' + - '!crates/socket-patch-cli/tests/**' + - 'crates/socket-patch-cli/tests/vex_e2e_common/**' + - 'crates/socket-patch-cli/tests/e2e_redirect_npm_build.rs' + - 'crates/socket-patch-cli/tests/e2e_vendor_npm_build.rs' + - 'crates/socket-patch-cli/tests/npm_e2e_common/**' + - 'crates/socket-patch-cli/tests/common/cache_env.rs' push: branches: [main] workflow_dispatch: diff --git a/.github/workflows/pdm-compatibility.yml b/.github/workflows/pdm-compatibility.yml index dc7ecc141..6af960cea 100644 --- a/.github/workflows/pdm-compatibility.yml +++ b/.github/workflows/pdm-compatibility.yml @@ -1,10 +1,11 @@ name: PDM patch compatibility -# Native PDM installer matrix: builds the CLI once per OS, bootstraps pinned -# PDM releases with uv, and runs `scripts/backtest-pdm.py` — hosted, vendored -# and agent mode against the public urllib3 free patch, verifying the -# INSTALLED bytes, lock/manifest stability, hash rejection and rollback. No -# Socket API token is needed. See docs/testing/pdm-compatibility.md. +# Native PDM installer matrix: builds the CLI and the capstone test binary +# once per OS, bootstraps pinned PDM releases with uv, and runs +# `scripts/backtest-pdm.py` — hosted, vendored and agent mode against the +# public urllib3 free patch, verifying the INSTALLED bytes, lock/manifest +# stability, hash rejection and rollback. No Socket API token is needed. See +# docs/testing/pdm-compatibility.md. on: pull_request: @@ -16,7 +17,7 @@ on: - 'crates/socket-patch-core/src/patch/redirect/**' - 'crates/socket-patch-core/src/vendor/pypi*.rs' - 'crates/socket-patch-core/src/vendor/common.rs' - - 'crates/socket-patch-core/src/vendor/lock_inventory.rs' + - 'crates/socket-patch-core/src/vendor/lock_inventory/**' - 'crates/socket-patch-cli/src/commands/scan/**' - 'crates/socket-patch-cli/src/commands/rollback.rs' - 'crates/socket-patch-core/tests/fixtures/pdm-native/**' @@ -25,6 +26,8 @@ on: - 'crates/socket-patch-cli/tests/e2e_vex_build/main.rs' - 'crates/socket-patch-cli/tests/e2e_vex_build/pdm.rs' - 'crates/socket-patch-cli/tests/vex_pypi_real_common/**' + # The capstone skips the cells ci.yml's e2e rows run. + - '.github/workflows/ci.yml' push: branches: [main] paths: @@ -48,7 +51,7 @@ permissions: concurrency: group: pdm-compat-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: SOCKET_NO_CONFIG: '1' @@ -60,7 +63,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-latest] + os: [ubuntu-latest, macos-latest] runs-on: ${{ matrix.os }} timeout-minutes: 30 steps: @@ -70,13 +73,26 @@ jobs: - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: key: pdm-compat - - run: cargo build --locked -p socket-patch-cli + save-if: ${{ github.ref == 'refs/heads/main' }} + - name: Compile the CLI and the capstone once + run: | + set -euo pipefail + cargo test --locked -p socket-patch-cli --test e2e_vex_build --no-run --message-format=json-render-diagnostics > target-build.json + python3 - <<'PY' + import json, pathlib, shutil + dest = pathlib.Path('target/pdm-e2e') + dest.mkdir(parents=True, exist_ok=True) + shutil.copy2('target/debug/socket-patch', dest / 'socket-patch') + for line in pathlib.Path('target-build.json').read_text().splitlines(): + item = json.loads(line) + if item.get('target', {}).get('name') == 'e2e_vex_build' and item.get('executable'): + shutil.copy2(item['executable'], dest / 'e2e_vex_build') + assert (dest / 'e2e_vex_build').is_file() + PY - uses: ./.github/actions/upload-artifact with: name: pdm-cli-${{ matrix.os }} - path: | - target/debug/socket-patch - target/debug/socket-patch.exe + path: target/pdm-e2e/ if-no-files-found: error retention-days: 7 @@ -86,8 +102,10 @@ jobs: fail-fast: false matrix: # Every stable PDM major family (0.x, 1.x, 2.x) and each 2.x lock-format - # boundary, on Linux and Windows; macOS samples the ends of the range. - os: [ubuntu-latest, windows-latest] + # boundary on Linux; macOS samples the ends of the range. No Windows: + # the harness bootstraps PDM through a POSIX venv layout (bin/pdm), so + # every Windows cell skipped; backtest-pdm.py now fails such a cell. + os: [ubuntu-latest] pdm: ['0.12.3', '1.15.5', '2.0.3', '2.1.5', '2.3.4', '2.6.1', '2.7.4', '2.8.2', '2.9.3', '2.10.4', '2.11.2', '2.17.3', '2.20.1', '2.22.4', '2.25.9', '2.29.2'] include: - { os: macos-latest, pdm: '0.12.3' } @@ -152,25 +170,37 @@ jobs: # The hermetic Rust capstone (wiremock Socket API that also serves the # hosted wheel) over every PDM release the backtest covers: real hosted + # vendored flows ending in the manifest-less VEX matrix; refused lock - # formats (3.1, 4.0-4.2) must attest nothing. + # formats (3.1, 4.0-4.2) must attest nothing. The cells ci.yml's `e2e` + # job runs on every PR and main push are excluded here + # (scripts/tests/test_ci_e2e_tiers.py keeps the two lists in step). capstone: + needs: build strategy: fail-fast: false matrix: os: [ubuntu-latest, macos-latest] pdm: ['0.12.3', '1.0.0', '1.4.5', '1.8.5', '1.15.5', '2.0.3', '2.7.4', '2.8.2', '2.10.4', '2.11.2', '2.17.3', '2.20.1', '2.22.4', '2.25.9', '2.29.2'] + exclude: + - {os: ubuntu-latest, pdm: '1.4.5'} + - {os: ubuntu-latest, pdm: '1.15.5'} + - {os: ubuntu-latest, pdm: '2.7.4'} + - {os: ubuntu-latest, pdm: '2.8.2'} + - {os: ubuntu-latest, pdm: '2.25.9'} + - {os: ubuntu-latest, pdm: '2.29.2'} + - {os: macos-latest, pdm: '2.29.2'} runs-on: ${{ matrix.os }} timeout-minutes: 30 steps: + # The binaries resolve fixtures through the build job's checkout path, + # which is the same on every runner of one OS. - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: - key: pdm-vex-capstone - # Only main writes the cache: 30 PR matrix cells saving would churn - # the repo's 10 GiB budget (ci.yml's rust-cache note). - save-if: ${{ github.ref == 'refs/heads/main' }} + pattern: pdm-cli-${{ matrix.os }}* + merge-multiple: true + path: target/pdm-e2e - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: '3.12' @@ -179,4 +209,11 @@ jobs: env: SOCKET_PATCH_PDM_E2E_REQUIRED: '1' SOCKET_PATCH_PDM_E2E_VERSION: ${{ matrix.pdm }} - run: cargo test --locked -p socket-patch-cli --test e2e_vex_build -- 'pdm::' --ignored + run: | + set -euo pipefail + chmod +x target/pdm-e2e/* + mkdir -p target/debug target/tmp + cp target/pdm-e2e/socket-patch target/debug/socket-patch + cd crates/socket-patch-cli + export CARGO_MANIFEST_DIR="$PWD" + ../../target/pdm-e2e/e2e_vex_build 'pdm::' --ignored diff --git a/.github/workflows/pipenv-compatibility.yml b/.github/workflows/pipenv-compatibility.yml index 6d7d9c900..a24d9bd27 100644 --- a/.github/workflows/pipenv-compatibility.yml +++ b/.github/workflows/pipenv-compatibility.yml @@ -14,7 +14,7 @@ on: - 'crates/socket-patch-core/src/patch/redirect/pipenv.rs' - 'crates/socket-patch-core/src/vendor/pypi_pipenv.rs' - 'crates/socket-patch-core/src/vendor/pypi.rs' - - 'crates/socket-patch-core/src/vendor/lock_inventory.rs' + - 'crates/socket-patch-core/src/vendor/lock_inventory/**' - 'crates/socket-patch-core/src/crawlers/python_crawler.rs' - 'crates/socket-patch-core/src/utils/pipenv.rs' - 'crates/socket-patch-cli/src/commands/scan/hosted.rs' diff --git a/.github/workflows/pnpm-compatibility.yml b/.github/workflows/pnpm-compatibility.yml index 2aaa8dd9b..11f061728 100644 --- a/.github/workflows/pnpm-compatibility.yml +++ b/.github/workflows/pnpm-compatibility.yml @@ -1,7 +1,26 @@ name: pnpm hosted compatibility on: + # PRs: any crate source, but only the test files these capstones + # compile (a later `!` pattern excludes, a later plain one re-includes). + # Main pushes stay unfiltered. pull_request: + paths: + - '.github/actions/upload-artifact/**' + - 'Cargo.lock' + - 'Cargo.toml' + - 'rust-toolchain.toml' + - '.cargo/config.toml' + - '.github/workflows/pnpm-compatibility.yml' + - 'crates/**' + - '!crates/**/*.md' + - '!crates/socket-patch-node/**' + - '!crates/socket-patch-core/tests/**' + - '!crates/socket-patch-cli/tests/**' + - 'crates/socket-patch-cli/tests/vex_e2e_common/**' + - 'crates/socket-patch-cli/tests/e2e_redirect_pnpm_build.rs' + - 'crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs' + - 'crates/socket-patch-cli/tests/common/cache_env.rs' push: branches: [main] workflow_dispatch: diff --git a/.github/workflows/poetry-compatibility.yml b/.github/workflows/poetry-compatibility.yml index 493677170..585e83786 100644 --- a/.github/workflows/poetry-compatibility.yml +++ b/.github/workflows/poetry-compatibility.yml @@ -15,6 +15,8 @@ on: pull_request: paths: - '.github/actions/upload-artifact/**' + - '.github/actions/pin-socket-hosts/**' + - 'scripts/pin-socket-hosts.py' - '.github/workflows/poetry-compatibility.yml' - 'scripts/backtest-poetry.py' - 'crates/socket-patch-core/src/utils/poetry_lock.rs' @@ -29,6 +31,8 @@ on: branches: [main] paths: - 'scripts/backtest-poetry.py' + - '.github/actions/pin-socket-hosts/**' + - 'scripts/pin-socket-hosts.py' - 'crates/socket-patch-core/src/utils/poetry_lock.rs' - 'crates/socket-patch-core/src/patch/redirect/**' - 'crates/socket-patch-core/src/vendor/pypi*.rs' @@ -40,7 +44,7 @@ permissions: concurrency: group: poetry-compat-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: SOCKET_NO_CONFIG: '1' @@ -91,6 +95,10 @@ jobs: - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: '3.12' + - name: Pin the production patch hosts (macOS) + # The hosted macOS resolver intermittently loses patch.socket.dev for + # minutes (EAI_NONAME) while the service is up; see the action. + uses: ./.github/actions/pin-socket-hosts # uv bootstraps every pinned Poetry release (and its interpreter) # itself; pinning uv keeps the bootstrap reproducible. - run: python -m pip install uv==0.11.19 diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml deleted file mode 100644 index 959305c0f..000000000 --- a/.github/workflows/publish-pypi.yml +++ /dev/null @@ -1,165 +0,0 @@ -name: Publish PyPI -run-name: "Publish PyPI ${{ inputs.version }}${{ inputs.distinct-id != '' && format(' [{0}]', inputs.distinct-id) || '' }}" - -# Publishes socket-patch (platform wheels) + socket-patch-hook (pure-python -# .pth carrier) to PyPI for an existing v release. Dispatched two -# ways, both as a plain workflow_dispatch run: -# - by release.yml (scripts/dispatch-publish.sh), as one leg of the -# single-dispatch release fan-out — distinct-id carries the release -# run's correlation id into this run's name; -# - manually (Actions → Publish PyPI → Run workflow), to retry just this -# registry after a mid-release failure: fix the cause, enter the release -# version, leave distinct-id blank. -# Every run checks out refs/tags/v and takes the prebuilt binaries -# from the GitHub release's assets (verified against SHA256SUMS), so a -# manual retry publishes exactly what the release run would have — no -# rebuild needed. The GitHub release must already exist with all assets. -# -# Idempotent: uploads run with skip-existing, so re-runs and retries after a -# partial upload are safe. -# -# OIDC trusted publishing: each project's PyPI trusted publisher is keyed on -# this repo + THIS file's name (publish-pypi.yml) + environment `pypi`. -# Because this workflow only ever runs as its own top-level -# workflow_dispatch run (never as a called reusable workflow), the OIDC -# token's workflow_ref and job_workflow_ref claims both name this file — so -# the one registration per project keeps working when PyPI flips its -# matching from job_workflow_ref to workflow_ref (warehouse PR #20083). - -on: - workflow_dispatch: - inputs: - version: - description: 'Release version (X.Y.Z; the v GitHub release must exist with binaries + SHA256SUMS)' - required: true - type: string - distinct-id: - description: 'Correlation id set by release.yml to track its dispatched run — leave blank for manual runs' - required: false - default: '' - type: string - sums-digest: - description: 'Expected sha256 of the release''s SHA256SUMS asset — set by release.yml to pin the assets to what its build produced; leave blank for manual runs' - required: false - default: '' - type: string - -# Serialize same-version runs: uploads are skip-existing but the wheel -# builds and both project uploads are not atomic, so keep two runs for one -# version from interleaving. A duplicate (e.g. re-dispatched by a -# release-run watcher whose `gh run watch` timed out while this run was -# still going) waits behind the live run and then no-ops. NOTE: the group -# holds at most ONE waiting run — a further same-version dispatch displaces -# (cancels) the waiting duplicate, and a watcher following the displaced -# run reports that as a failure; the publish itself is unaffected (the -# surviving runs no-op or publish). -concurrency: - group: publish-pypi-${{ inputs.version }} - -permissions: {} - -jobs: - pypi-publish: - runs-on: ubuntu-latest - # Bounds how long a wedged run (hung registry call) can hold this - # workflow's per-version concurrency group before retries can proceed. - # (A pending environment approval pauses the run BEFORE the job starts, - # so it does not consume this timeout.) - timeout-minutes: 45 - # OIDC trusted publishing scoped to a deployment environment; also lets a - # maintainer gate publishing with required reviewers. Auto-created with no - # protection rules until configured. NOTE: if required reviewers are ever - # configured, a pending approval pauses THIS run while the release run's - # watcher counts toward its own timeout-minutes — approve promptly. If - # the watcher timed out, re-running it dispatches a NEW run that needs - # its own approval before it can no-op; this run is unaffected either way. - environment: pypi - permissions: - contents: read - id-token: write - steps: - - name: Validate version input - env: - VERSION: ${{ inputs.version }} - run: | - if ! printf '%s' "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then - echo "::error::'${VERSION}' is not a plain X.Y.Z release version" - exit 1 - fi - - - name: Checkout release tag - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: refs/tags/v${{ inputs.version }} - persist-credentials: false - - - name: Verify tag carries the requested version - env: - VERSION: ${{ inputs.version }} - run: | - CARGO_VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/') - if [ "$CARGO_VERSION" != "$VERSION" ]; then - echo "::error::tag v${VERSION} carries workspace version ${CARGO_VERSION} — refusing to publish" - exit 1 - fi - - - name: Download binaries from the GitHub release - env: - GH_TOKEN: ${{ github.token }} - VERSION: ${{ inputs.version }} - SUMS_DIGEST: ${{ inputs.sums-digest }} - run: | - mkdir artifacts - gh release download "v${VERSION}" \ - --repo "$GITHUB_REPOSITORY" \ - --dir artifacts \ - --pattern '*.tar.gz' --pattern '*.zip' --pattern 'SHA256SUMS' - cd artifacts - # Release assets are mutable (contents:write can clobber them), and - # SHA256SUMS is itself an asset of the same release — so when the - # release run dispatched us it pinned the sums file by digest, - # binding this publish to exactly what that run built (the same - # provenance the pre-split inline jobs got from same-run - # artifacts). Manual retries leave the pin blank: integrity-only. - if [ -n "$SUMS_DIGEST" ]; then - echo "${SUMS_DIGEST} SHA256SUMS" | sha256sum -c - - fi - # -c also fails on a file that SHA256SUMS lists but the download - # missed, so this doubles as a completeness check: the sums file - # was generated from the full 14-target artifact set. - sha256sum -c SHA256SUMS - - - name: Setup Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 - with: - python-version: '3.12.13' - - - name: Copy README for PyPI package - run: cp README.md pypi/socket-patch/README.md - - - name: Build wheels (platform socket-patch + pure-python socket-patch-hook) - env: - VERSION: ${{ inputs.version }} - run: | - # Builds the platform-tagged socket-patch wheels AND the pure-python - # socket-patch-hook wheel (the .pth carrier behind `socket-patch[hook]`). - python scripts/build-pypi-wheels.py --version "$VERSION" --artifacts artifacts --dist dist - # socket-patch and socket-patch-hook are two distinct PyPI projects. - # Publish each from its own dir so trusted publishing mints an OIDC - # token scoped to the right project (one upload spanning both projects - # can be rejected). Each needs its own trusted publisher on PyPI. - mkdir -p dist-hook - mv dist/socket_patch_hook-*.whl dist-hook/ - - - name: Publish socket-patch to PyPI - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 - with: - packages-dir: dist/ - # Idempotent for re-runs: already-uploaded files skip. - skip-existing: true - - - name: Publish socket-patch-hook to PyPI - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 - with: - packages-dir: dist-hook/ - skip-existing: true diff --git a/.github/workflows/publish-rubygems.yml b/.github/workflows/publish-rubygems.yml deleted file mode 100644 index a8e48b3da..000000000 --- a/.github/workflows/publish-rubygems.yml +++ /dev/null @@ -1,165 +0,0 @@ -name: Publish RubyGems -run-name: "Publish RubyGems ${{ inputs.version }}${{ inputs.distinct-id != '' && format(' [{0}]', inputs.distinct-id) || '' }}" - -# Publishes BOTH gems via one OIDC trusted-publishing exchange: -# - gem/socket-patch — the CLI launcher gem (downloads the -# prebuilt binary from the GitHub release at its own version, so this -# workflow only needs the GitHub release + SHA256SUMS to exist). -# - gem/socket-patch-bundler — Phase 2 scaffolding, non-blocking (see the -# step comment below). -# -# Dispatched two ways, both as a plain workflow_dispatch run: -# - by release.yml (scripts/dispatch-publish.sh), as one leg of the -# single-dispatch release fan-out — distinct-id carries the release -# run's correlation id into this run's name; -# - manually (Actions → Publish RubyGems → Run workflow), to retry just -# this registry after a mid-release failure: fix the cause, enter the -# release version, leave distinct-id blank. -# Every run checks out refs/tags/v, so a manual retry publishes -# exactly what the release run would have. -# -# Idempotent: already-published versions are probed and skipped, so re-runs -# and partial-failure retries are safe. -# -# OIDC trusted publishing: the RubyGems trusted publisher on each gem is -# keyed on this repo + THIS file's name (publish-rubygems.yml) + environment -# `rubygems`. Because this workflow only ever runs as its own top-level -# workflow_dispatch run (never as a called reusable workflow), the OIDC -# token's workflow_ref and job_workflow_ref claims both name this file — one -# publisher registration per gem covers every path, and one exchange -# satisfies both gems' publishers. - -on: - workflow_dispatch: - inputs: - version: - description: 'Release version (X.Y.Z; the v GitHub release must exist — the launcher gem downloads its binary from it)' - required: true - type: string - distinct-id: - description: 'Correlation id set by release.yml to track its dispatched run — leave blank for manual runs' - required: false - default: '' - type: string - -# Serialize same-version runs: the already-published probes below are -# check-then-act, so two CONCURRENT runs for one version could both pass a -# probe and the loser would hard-fail on the registry ("Repushing of gem -# versions is not allowed"). Serialized, a duplicate (e.g. re-dispatched by -# a release-run watcher whose `gh run watch` timed out while this run was -# still going) waits behind the live run and then no-ops. NOTE: the group -# holds at most ONE waiting run — a further same-version dispatch displaces -# (cancels) the waiting duplicate, and a watcher following the displaced -# run reports that as a failure; the publish itself is unaffected (the -# surviving runs no-op or publish). -concurrency: - group: publish-rubygems-${{ inputs.version }} - -permissions: {} - -jobs: - rubygems-publish: - runs-on: ubuntu-latest - # Bounds how long a wedged run (hung registry call) can hold this - # workflow's per-version concurrency group before retries can proceed. - # (A pending environment approval pauses the run BEFORE the job starts, - # so it does not consume this timeout.) - timeout-minutes: 45 - # OIDC trusted publishing scoped to a deployment environment; also lets a - # maintainer gate publishing with required reviewers. Auto-created with no - # protection rules until configured. NOTE: if required reviewers are ever - # configured, a pending approval pauses THIS run while the release run's - # watcher counts toward its own timeout-minutes — approve promptly. If - # the watcher timed out, re-running it dispatches a NEW run that needs - # its own approval before it can no-op; this run is unaffected either way. - environment: rubygems - permissions: - contents: read - id-token: write - steps: - - name: Validate version input - env: - VERSION: ${{ inputs.version }} - run: | - if ! printf '%s' "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then - echo "::error::'${VERSION}' is not a plain X.Y.Z release version" - exit 1 - fi - - - name: Checkout release tag - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: refs/tags/v${{ inputs.version }} - persist-credentials: false - - # Ruby is pre-installed on ubuntu-latest; no setup action needed. - - name: Lint + version-check the launcher gem - working-directory: gem/socket-patch - env: - EXPECTED_VERSION: ${{ inputs.version }} - run: | - ruby -c lib/socket_patch/launcher.rb - ruby -c exe/socket-patch - # The gemspec version is baked at the tag by scripts/version-sync.sh. - gemver="$(ruby -e 'print Gem::Specification.load("socket-patch.gemspec").version')" - if [ "$gemver" != "$EXPECTED_VERSION" ]; then - echo "::error::gemspec version $gemver != release $EXPECTED_VERSION (run scripts/version-sync.sh before tagging)" - exit 1 - fi - - - name: Lint + version-check the bundler-plugin gem - working-directory: gem/socket-patch-bundler - env: - EXPECTED_VERSION: ${{ inputs.version }} - run: | - ruby -c plugins.rb - # The gemspec version is baked at the tag by scripts/version-sync.sh. - gemver="$(ruby -e 'print Gem::Specification.load("socket-patch-bundler.gemspec").version')" - if [ "$gemver" != "$EXPECTED_VERSION" ]; then - echo "::error::gemspec version $gemver != release $EXPECTED_VERSION (run scripts/version-sync.sh before tagging)" - exit 1 - fi - - - name: Configure RubyGems credentials (OIDC trusted publishing) - # One OIDC exchange covers both gems: a trusted publisher keyed on - # this repo + workflow (+ the `rubygems` environment) can be - # registered on multiple gems on rubygems.org, and the exchanged - # token pushes any gem whose publisher matches. - uses: rubygems/configure-rubygems-credentials@dc5a8d8553e6ee01fc26761a49e99e733d17954a # v2.1.0 - - - name: Publish socket-patch to RubyGems - working-directory: gem/socket-patch - env: - VERSION: ${{ inputs.version }} - run: | - gem build socket-patch.gemspec - # `gem list -r -e -a` prints `socket-patch (3.3.0, 3.2.0, ...)`; match - # this version as a precise list element (preceded by `(`/space, - # followed by `,`/`)`). - if gem list --remote --exact --all socket-patch 2>/dev/null | grep -qE "[ (]${VERSION}[,)]"; then - echo "socket-patch ${VERSION} already on RubyGems; skipping." - exit 0 - fi - gem push "socket-patch-${VERSION}.gem" - - # Phase 2 scaffolding (CLI_CONTRACT "gem" support matrix): publish the - # `socket-patch-bundler` gem — the published form of the Bundler plugin - # that `socket-patch setup` currently wires via an in-tree `git:` - # reference. This gem is NOT yet the active mechanism (setup::gem still - # emits the in-tree plugin), so the push is **non-blocking** - # (`continue-on-error`). A follow-up switches the generated Gemfile - # directive to `plugin "socket-patch-bundler"` and drops - # continue-on-error. - - name: Publish socket-patch-bundler to RubyGems - continue-on-error: true - working-directory: gem/socket-patch-bundler - env: - VERSION: ${{ inputs.version }} - run: | - gem build socket-patch-bundler.gemspec - # Same precise-list-element match as the launcher gem above. - if gem list --remote --exact --all socket-patch-bundler 2>/dev/null | grep -qE "[ (]${VERSION}[,)]"; then - echo "socket-patch-bundler ${VERSION} already on RubyGems; skipping." - exit 0 - fi - gem push "socket-patch-bundler-${VERSION}.gem" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7c2abcb48..6fbee7000 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,27 +2,21 @@ name: Release # One-dispatch release orchestrator: version gate, build matrix, tag, and # GitHub release live here; each registry publish lives in its own -# independently-runnable workflow (publish-cargo.yml, publish-npm.yml, -# publish-pypi.yml, publish-rubygems.yml). The fan-out jobs below dispatch -# each of those at the release tag via `gh workflow run` +# independently-runnable workflow (publish-cargo.yml, publish-npm.yml). +# The fan-out jobs below dispatch each at the release tag via `gh workflow run` # (scripts/dispatch-publish.sh) and watch the dispatched run to completion, # so this run's job graph still reflects every registry's real outcome and # "Re-run failed jobs" re-dispatches exactly the failed legs. A failed leg # can also be retried without this run at all: fix the cause and dispatch # that registry's workflow manually with the release version — no rebuild -# happens either way. The npm and PyPI legs take the prebuilt binaries from +# happens either way. The npm leg takes the prebuilt binaries from # the GitHub release's assets (verified against SHA256SUMS, pinned by digest -# on the fan-out path), and the launcher gem downloads its binary from the -# release at run time — which is why npm/PyPI/RubyGems wait on -# `github-release` below. +# on the fan-out path), which is why it waits on `github-release` below. # # Why dispatch instead of `uses:` (reusable workflows) — two GitHub/registry # facts, verified 2026-08-21 against the registries' docs and source: -# 1. Registry OIDC trusted publishers are keyed on a workflow FILENAME, -# but the registries disagree on WHICH one: npm and crates.io match the -# top-level workflow (`workflow_ref` claim), while PyPI and RubyGems -# match the file defining the job (`job_workflow_ref`) — and PyPI plans -# to flip to top-level matching (warehouse PR #20083). npm additionally +# 1. npm and crates.io OIDC trusted publishers match the top-level +# workflow filename (`workflow_ref` claim). npm additionally # allows only ONE trusted publisher per package, so a leg that is # sometimes `uses:`-called (top-level = release.yml) and sometimes # dispatched (top-level = its own file) can never be authorized for @@ -31,20 +25,15 @@ name: Release # registration per package covers everything, on every registry. # 2. GitHub suppresses events caused by this workflow's own GITHUB_TOKEN — # a `release: published` (or `push: tags:`) trigger in another file -# would never fire, which is why the old design kept every publish job -# in this file — but workflow_dispatch (and repository_dispatch) events +# would never fire — but workflow_dispatch (and repository_dispatch) events # are documented exceptions, so `gh workflow run` with GITHUB_TOKEN # works without a PAT/GitHub App token. # -# Credentials / deployment-environment matrix (per-registry): +# Registry credentials: # - crates.io: OIDC trusted publishing (rust-lang/crates-io-auth-action); # no long-lived secret, no environment. # - npm: OIDC via `npm stage publish`; staged versions require # manual 2FA approval (see the npm run's step summary). -# - PyPI: OIDC trusted publishing; environment `pypi`. -# - RubyGems: OIDC trusted publishing; environment `rubygems`. One -# repo+workflow publisher per gem (`socket-patch` and -# `socket-patch-bundler`), both satisfied by one exchange. # Every registry's trusted publisher is keyed on the repo + the publish # workflow's own filename (see each publish-*.yml header), NOT release.yml. @@ -242,9 +231,9 @@ jobs: permissions: contents: write outputs: - # sha256 of the SHA256SUMS file uploaded below. The npm/PyPI fan-out - # passes it to those legs, which re-download the assets from the - # release: pinning the sums file by digest binds what the legs publish + # sha256 of the SHA256SUMS file uploaded below. The npm fan-out + # passes it to the publish leg, which re-downloads the assets from the + # release: pinning the sums file by digest binds what it publishes # to exactly what THIS run built, restoring the same-run-artifact # provenance the pre-split inline jobs had (release assets are mutable; # anyone with contents:write could clobber them between jobs). @@ -320,9 +309,8 @@ jobs: VERSION: ${{ needs.version.outputs.version }} run: bash scripts/dispatch-publish.sh publish-cargo.yml "$VERSION" - # npm, PyPI, and RubyGems consume the GitHub release (npm/PyPI take the - # prebuilt binaries from its assets; the launcher gem downloads its binary - # from it at run time), so all three wait on `github-release`. + # npm takes the prebuilt binaries from the GitHub release's assets, + # so it waits on `github-release`. npm-publish: needs: [version, github-release] if: ${{ !inputs.dry-run }} @@ -350,51 +338,3 @@ jobs: exit 1 fi bash scripts/dispatch-publish.sh publish-npm.yml "$VERSION" "sums-digest=${SUMS_DIGEST}" - - pypi-publish: - needs: [version, github-release] - if: ${{ !inputs.dry-run }} - runs-on: ubuntu-latest - timeout-minutes: 45 - permissions: - contents: read - actions: write - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Dispatch and watch Publish PyPI - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - VERSION: ${{ needs.version.outputs.version }} - SUMS_DIGEST: ${{ needs.github-release.outputs.sums-digest }} - run: | - # Fail closed: an empty digest would silently downgrade the leg's - # asset check from provenance-pinned to integrity-only. - if [ -z "$SUMS_DIGEST" ]; then - echo "::error::github-release produced no SHA256SUMS digest; refusing to dispatch an unpinned publish" - exit 1 - fi - bash scripts/dispatch-publish.sh publish-pypi.yml "$VERSION" "sums-digest=${SUMS_DIGEST}" - - rubygems-publish: - needs: [version, github-release] - if: ${{ !inputs.dry-run }} - runs-on: ubuntu-latest - timeout-minutes: 45 - permissions: - contents: read - actions: write - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Dispatch and watch Publish RubyGems - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - VERSION: ${{ needs.version.outputs.version }} - run: bash scripts/dispatch-publish.sh publish-rubygems.yml "$VERSION" diff --git a/.github/workflows/vlt-compatibility.yml b/.github/workflows/vlt-compatibility.yml index 6fd588bda..a30976d9c 100644 --- a/.github/workflows/vlt-compatibility.yml +++ b/.github/workflows/vlt-compatibility.yml @@ -21,6 +21,8 @@ on: pull_request: paths: - '.github/actions/upload-artifact/**' + - '.github/actions/pin-socket-hosts/**' + - 'scripts/pin-socket-hosts.py' - '.github/workflows/vlt-compatibility.yml' - 'Cargo.lock' - 'rust-toolchain.toml' @@ -35,9 +37,8 @@ on: - 'crates/socket-patch-cli/src/commands/apply.rs' - 'crates/socket-patch-cli/src/commands/rollback.rs' - 'crates/socket-patch-cli/src/commands/remove.rs' - - 'crates/socket-patch-cli/src/commands/setup.rs' - 'crates/socket-patch-cli/src/commands/vendor.rs' - - 'crates/socket-patch-cli/src/commands/repair_vendor.rs' + - 'crates/socket-patch-cli/src/commands/vendored_backend/**' - 'crates/socket-patch-cli/src/commands/get.rs' - 'crates/socket-patch-cli/src/commands/vlt_preflight.rs' - 'crates/socket-patch-cli/src/commands/scan/**' @@ -54,10 +55,15 @@ on: - 'scripts/install-vlt.sh' - 'scripts/vlt-historical-integrity.json' - 'scripts/gen-vlt-collation-golden.mjs' + - 'scripts/ci-vlt-proof-suites.py' + - '.github/workflows/ci.yml' + - 'scripts/tests/test_ci_vlt_rows.py' push: branches: [main] paths: - '.github/actions/upload-artifact/**' + - '.github/actions/pin-socket-hosts/**' + - 'scripts/pin-socket-hosts.py' - '.github/workflows/vlt-compatibility.yml' - 'Cargo.lock' - 'rust-toolchain.toml' @@ -72,9 +78,8 @@ on: - 'crates/socket-patch-cli/src/commands/apply.rs' - 'crates/socket-patch-cli/src/commands/rollback.rs' - 'crates/socket-patch-cli/src/commands/remove.rs' - - 'crates/socket-patch-cli/src/commands/setup.rs' - 'crates/socket-patch-cli/src/commands/vendor.rs' - - 'crates/socket-patch-cli/src/commands/repair_vendor.rs' + - 'crates/socket-patch-cli/src/commands/vendored_backend/**' - 'crates/socket-patch-cli/src/commands/get.rs' - 'crates/socket-patch-cli/src/commands/vlt_preflight.rs' - 'crates/socket-patch-cli/src/commands/scan/**' @@ -91,6 +96,9 @@ on: - 'scripts/install-vlt.sh' - 'scripts/vlt-historical-integrity.json' - 'scripts/gen-vlt-collation-golden.mjs' + - 'scripts/ci-vlt-proof-suites.py' + - '.github/workflows/ci.yml' + - 'scripts/tests/test_ci_vlt_rows.py' schedule: # Nightly: vlt releases and the production service drift with no PR open. - cron: '17 4 * * *' @@ -117,11 +125,11 @@ on: permissions: contents: read -# Supersede stale PR runs; main runs are the only rust-cache writers, so they -# are never cancelled mid-save. +# Supersede stale PR runs only: main runs are the only rust-cache writers, so +# push, dispatch and schedule runs are never cancelled mid-save. concurrency: group: vlt-compat-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: CARGO_PROFILE_DEV_DEBUG: '0' @@ -313,6 +321,8 @@ jobs: SOCKET_PATCH_VLT_E2E_STORE_LINKER: ${{ matrix.linker }} SOCKET_PATCH_VLT_E2E_CACHE_ROOT: ${{ matrix.cache_root }} VLT_SUITES: ${{ matrix.suites || 'e2e_redirect_vlt_build e2e_vendor_vlt_build mode_migration_vlt e2e_safety_vlt e2e_vlt' }} + MATRIX_OS: ${{ matrix.os }} + NODE_PIN: ${{ matrix.node }} run: | set -uo pipefail exe='' @@ -328,6 +338,17 @@ jobs: mkdir -p "$SOCKET_PATCH_VLT_E2E_CACHE_ROOT" fi py=$(command -v python3 || command -v python) + # Cells ci.yml's e2e rows run identically (every PR, main push and + # nightly) are left to them; a dispatch runs every cell. + if [ "$GITHUB_EVENT_NAME" != workflow_dispatch ]; then + # shellcheck disable=SC2086 # VLT_SUITES is a word list + VLT_SUITES=$("$py" scripts/ci-vlt-proof-suites.py --os "$MATRIX_OS" --vlt "$SOCKET_PATCH_VLT_E2E_VERSION" \ + --node "$NODE_PIN" --linker "${SOCKET_PATCH_VLT_E2E_STORE_LINKER:-}" \ + --cache-root "${SOCKET_PATCH_VLT_E2E_CACHE_ROOT:-}" $VLT_SUITES | tr -d '\r') || exit 1 + fi + if [ -z "$VLT_SUITES" ]; then + echo "::notice::every capstone of this cell runs in ci.yml's e2e rows; nothing left to run here" + fi status=0 for suite in $VLT_SUITES; do echo "::group::$suite" @@ -390,6 +411,10 @@ jobs: - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: '3.12' + - name: Pin the production patch hosts (macOS) + # The hosted macOS resolver intermittently loses patch.socket.dev for + # minutes (EAI_NONAME) while the service is up; see the action. + uses: ./.github/actions/pin-socket-hosts - name: Backtest against production # Every hosted cell probes the artifact first; it records # blocked-by-server-encoding only when that probe saw a non-identity diff --git a/.github/workflows/vlt-serve-watchdog.yml b/.github/workflows/vlt-serve-watchdog.yml index 28f4af5c7..2bb63e4d7 100644 --- a/.github/workflows/vlt-serve-watchdog.yml +++ b/.github/workflows/vlt-serve-watchdog.yml @@ -12,10 +12,11 @@ name: vlt serve watchdog # Like installer-drift.yml it checks a deployed service, not the diff. It is # `continue-on-error` until the serve fix (`Cache-Control: no-transform`) is # verified in production; removing that line arms it (DESIGN §8.4, the depscan -# rollout's last step). +# rollout's last step). Until then it cannot alert, so it runs once a day to +# record the probe; go back to every 6 hours when arming it. on: schedule: - - cron: '23 */6 * * *' + - cron: '23 4 * * *' workflow_dispatch: permissions: diff --git a/.gitignore b/.gitignore index b3a6faf3c..c2de49e76 100644 --- a/.gitignore +++ b/.gitignore @@ -145,18 +145,12 @@ vite.config.ts.timestamp-* # Rust target/ -# Maven / NuGet launcher build output -maven/socket-patch/target/ -nuget/socket-patch/bin/ -nuget/socket-patch/obj/ - # npm binaries (populated at publish time) npm/socket-patch/bin/socket-patch-* # READMEs copied at publish time crates/socket-patch-cli/README.md npm/socket-patch/README.md -pypi/socket-patch/README.md # Generated by scripts/study-crates.ts study-output/ diff --git a/CHANGELOG.md b/CHANGELOG.md index b85d8bf44..e80101ace 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,1789 +17,128 @@ into the new version's section — see docs/releasing.md. ## [Unreleased] -> **Semver note:** this entry changes `rollback`'s default behavior, narrows -> the meaning of its existing `vendored: []` JSON key, makes vendored mode -> manifest-free, moves vendored cargo wiring from `.cargo/config*` into -> `Cargo.toml`, tags vendored cargo copies' versions with `+socket.` -> (visible to the patched crate as `CARGO_PKG_VERSION`), turns a plain -> non-TTY `scan` report-only, makes `vex` -> refuse to attest stale ledger records and corrupt vendor ledgers, and -> retries a throttled patch API (new error text, added waiting, a throttled -> package failing its legacy-proxy batch) — all -> MAJOR per CLI_CONTRACT.md's semver policy — so it ships as the next major -> release (v5.0). - -### Changed (BREAKING) - -- **Vendored runs refuse lock-text failures before downloading them.** - `scan --mode vendored` and `get --mode vendored` evaluate the vendor - backends' pure lock-text gates — pnpm, yarn classic and yarn berry - (coordinates; the lock / manifest reads and their line-ending, version, - `cacheKey` and `.yarnrc.yml` gates; override and `resolutions` - conflicts; the lock entry present and rewritable) and cargo's - `locked_version_mismatch` when it is the crate's first refusal — before - fetching patch views and pristine sources, so a package that will be - refused costs no network. This applies only to a package the vendor loop - would hand to its backend — one installed on disk, or one the lockfile - resolves to a verifiable registry source (the pristine fetch would - happen); a package absent from the lock and not installed keeps its - `skipped` / `package_not_installed` vendor event and its download - record, exactly as before. Such a package is now reported in the download - phase: `download.patches[]` records it as `action: "failed"` with the - backend's exact `errorCode` and `error`, `download.downloaded` drops and - `download.failed` rises by the number of such packages, and the vendor - envelope no longer carries their `failed` events (`vendor.summary.failed` - drops by the same number) nor, for lockfile-only packages, their - `vendor_fetched_missing` events. Exit code and the top-level `status` - are unchanged; the nested `vendor.status` becomes `success` when those - refusals were the vendor step's only failures (and when every selected - package is refused this way, the human arm prints `Nothing was - vendored: N patches failed (see above).`). - (The interactive human `scan --vendor` arm still fetches the views its - pre-prompt baseline check verifies.) Purls the hosted redirect ledger - claims keep the vendor loop's refusal. Because no view is fetched, the - lock-text refusal now takes precedence over every outcome that came - from the view: a package that would also have hit a paid-access 403, a - failed view fetch or the no-applicable-files guardrail reports the lock - refusal instead. - The manifest-driven `vendor` command keeps its `failed` events but no - longer fetches the pristine source of a lockfile-only package it refuses - this way (no `vendor_fetched_missing` event, no registry request; with an - unreachable registry the gate's code replaces `vendor_fetch_failed`) — - again only when the lockfile resolves it to a verifiable source; one the - lock does not resolve keeps its `package_not_installed` skip. - On the polyglot monorepo fixture: 80 of 560 packages (74 - `vendor_lock_entry_not_found`, 4 `vendor_override_conflict`, 2 - `vendor_lock_entry_unsupported`) move to the download phase, saving 80 - view requests and 3 registry tarballs per run. - -- **Vendored cargo copies carry a tagged version: `+socket.`.** - The vendored copy's own `Cargo.toml` `[package] version` is rewritten to - the patch-tagged version (`1.0.4+socket.`; a version that already - has build metadata keeps it: `2.0.1+zstd.1.5.2.socket.`), and the - detached `Cargo.lock` entry records that tagged version with no - `source` / `checksum` — exactly the lock cargo itself writes when it - resolves the `[patch]` against the tagged copy (verified by building on - cargo 1.41 in docker and on current stable, and by the CI - `cargo-old-toolchains` leg on the 1.41 / 1.56 docker images — a local - run without those images only type-checks on rustup toolchains: - `--locked` builds, the patched bytes compile, a registry crate that - depends on the patched one (`^1`) resolves to the copy too, since cargo - ignores build metadata when matching requirements, and `cargo metadata` - reports the tagged version). Every lock reference that spells the old - version (`"cfg-if 1.0.4"`, v1's `"cfg-if 1.0.4 (registry+…)"`) is - rewritten to the tagged version, in lock formats v1–v4; a lock the edit - cannot keep consistent (a leftover reference in another spelling, a v1 - `replace`, an entry already at the tagged version) refuses before any - write with `cargo_lock_untaggable`, and a copy manifest whose version - literal cannot be rewritten byte-exactly fails the package with - `cargo_copy_untaggable`. The patch uuid of the copy cargo actually - builds is therefore recoverable from `Cargo.lock` alone (a config-level - `[patch]` override pointing elsewhere changes the locked version). **The - patched crate sees the tag in `CARGO_PKG_VERSION`** (and in - `env!("CARGO_PKG_VERSION")`-derived strings such as `--version` output - of a vendored binary crate): requirement matching on it - (`semver::VersionReq::matches`) is unaffected, but string comparisons - AND equality / ordering on a parsed `semver::Version` see the tag - (`semver` 1.x compares build metadata: `1.0.4+socket.` is not - `== Version::new(1, 0, 4)` and sorts above it). Re-runs are idempotent - (a dry run previews the tag as "would tag", and the wet run's - `cargo_lock_untaggable` / `cargo_copy_untaggable` refusals); a uuid bump - re-tags the copy and the lock; `vendor --revert` / `rollback` / - `remove` / GC / the hosted takeover restore the original lock byte for - byte (tag dropped with the `source` / `checksum`; a crate vendored - before any `Cargo.lock` existed has no originals, so only the tag its - first build locked is dropped). A user's own same-version path crate - that cargo later locks beside the tagged copy (an untagged sourceless - entry) is never mistaken for it: re-runs stay in sync, and the revert - restores the registry entry — spelled by its full id while the fork - shares its name+version, exactly as cargo writes it — without touching - the fork. GC keeps an entry whose lock tag is stale (another uuid) while - the manifest still wires this entry's copy: cargo re-locks any unlocked - build to the wired copy, and the next re-run retags. Projects vendored - before tagged versions (the pre-v5 `.cargo/config*` wiring, or an - untagged manifest wiring) are tagged by the next re-run or `repair` - (`cargo_version_tagged` note; a tag `repair` cannot write is the - `cargo_version_untagged` warning); a whole-tree file inventory recorded - for the copy is kept, and still verifies through exactly this uuid's - tag, so tagging never re-baselines it over unverified bytes. VEX - discovery treats the tagged lock version as the primary identity - (`pkg:cargo/@` is the tag stripped): a detached entry - tagged for a different uuid than the wiring's copy path is dead wiring - (not attested), and so is a copy whose own `Cargo.toml` is tagged for - another uuid than its path; a tagged entry no visible wiring names is - diagnosed unattributable; an untagged detached entry counts only beside - an untagged copy (the pre-tag vendored shape) — beside a tagged copy it - is some other crate cargo built, not attested. A patch that edits the - crate's own `Cargo.toml` verifies with the tag dropped — the tag being - this copy's own uuid, the same pin the inventory check applies, so a copy - tagged for another patch stays a mismatch instead of verifying clean - while VEX refuses it. -- **Vendored cargo wiring moved to `Cargo.toml`.** `vendor` / `scan` / - `get --mode vendored` write the `[patch.crates-io]` path entry into the - workspace-root `Cargo.toml` (beside the `Cargo.lock` it detaches) instead - of `.cargo/config.toml` / `.cargo/config`, so Socket scanners can recover - the patch uuid from the manifest alone and single-version wiring builds - on cargo older than 1.56 (the floor of config-file `[patch]`; proven on - cargo 1.41 with no network). TWO vendored versions of one crate need - cargo 1.45 or newer: from 1.45 `--offline` from an empty `$CARGO_HOME` is - enough (without `--offline` it first tries to update the crates.io index - and fails when that is unreachable), while cargo before 1.45 resolves - every source-less lock entry for a crate through one `[patch]` path and - fails closed on the other — see the `cargo_multi_version_old_cargo` - entry under Fixed. The edit is - format-preserving (comments, ordering, CRLF / mixed line endings, a - UTF-8 BOM and the trailing-newline state survive; a revert restores the - manifest byte for byte and keeps a user's own `[patch]` / - `[patch.crates-io]` headers). The key is always the Socket-owned - `-socket-` with `package = ""`, never the bare crate - name: cargo lets a config-file `[patch]` item (project, ancestor - directory or `$CARGO_HOME`) replace the manifest item with the same key - whatever its version, so a crate-named key could be silently shadowed — - and two vendored versions of one crate get distinct keys instead of - clobbering each other (the config wiring keyed by crate name let the - second overwrite the first). The ledger's `cargo_patch_entry` record now - names `Cargo.toml` (its `key` is the TOML key). New refusals, each before - any write: `cargo_manifest_unreadable`, `cargo_manifest_unparseable`, - `cargo_manifest_symlink_unsupported` (vendor and revert), - `cargo_manifest_not_workspace_root` (run from a workspace member, whose - `[patch]` cargo ignores) and `cargo_manifest_patch_source_alias` (the - manifest spells crates.io by URL in `[patch."https://github.com/rust-lang/crates.io-index"]`, - which replaces `[patch.crates-io]` wholesale); - `user_authored_patch_entry` now covers user entries in `Cargo.toml` and - in every cargo config file cargo merges (project, ancestors, - `$CARGO_HOME`) and matches by crate (`package` or key), sparing a path - patch that is provably another version. **Old wiring migrates - automatically**: a re-run or `repair` moves a Socket-owned - `.cargo/config*` entry into `Cargo.toml` (`cargo_wiring_migrated` note; a - migrating vendor re-run reports the package `applied`) and cleans a - config file / `.cargo/` the move emptied — a legacy entry that cannot be - removed fails the run and unwinds it (`cargo_legacy_wiring_kept`) — while - `rollback` / `remove` / `vendor --revert` / GC / hosted takeover remove - both spellings. Projects hit by the pre-v5 multi-version overwrite (a - detached lock entry nothing wired) are healed by a re-run or `repair` - (`cargo_wiring_restored`). User config entries are never touched. VEX - discovery reads the manifest first (key-agnostic), skips a manifest entry - that cargo ignores (a same-key project-config item or a URL-spelled - crates.io table replaces it), and still honors pre-v5 config wiring. The - hosted takeover's missing-ledger guard is now per version. -- **Binary Bun lockfiles are patched natively in place.** Hosted and vendored - modes read and rewrite `bun.lockb` formats 1–3 directly, including mode - changes, repair, and scoped rollback. Binary-to-text conversion, migration - ledger replay, and their warning codes and tests have been removed. -- **`rollback` is now the full-state dual of `scan`.** `scan` and `rollback` - are the batch primaries (`get`↔`remove` stay the single-patch duals): a - bare `rollback` restores the SYSTEM to unpatched across all three modes — - in-place file restore (agent), vendored unwire + artifact deletion + - ledger-entry drop, hosted lockfile-redirect unwind + redirect-record drop - — then removes the rolled-back entries from `.socket/manifest.json` and - GCs the now-unused blobs plus diff/package archives. No `--mode` needed: - state is inferred from the manifest, the vendor ledger, and the redirect - ledger, and rollback now runs manifest-less when a ledger holds work - (hosted-only and vendored projects; the truly-empty project - keeps the "Manifest not found" exit 1, and a wired-but-ledgerless project - errors naming `socket-patch repair`). Wet non-preserve runs confirm once - ("Roll back N patches, remove them from the local manifest, and delete - M vendored artifacts and their ledger records?" — auto-accepted under `--yes`/`--json`/non-TTY; - declining prints "Rollback cancelled." and exits 0). Drift-keeps, hosted - refusals/unsupported targets, corrupt ledgers, and a failed manifest - write exit 1 `partial_failure`; not-installed entries still exit 0. -- **`rollback --json`'s `vendored: []` array narrows** to vendor-owned purls - the run did NOT act on (today: the corrupt-vendor-ledger skip). Acted-on - entries move to the new always-present `vendoredReverted` / - `vendoredPreserved` / `vendoredKept` arrays; the envelope also gains - always-present `warnings[]` (`{code, detail}`, now populated), `hosted` - (`{reverted, failed, unsupported, editedFiles}`), `manifest` - (`{removedEntries, preserved}`), `gc`, and `paths` keys. -- **Vendored mode is manifest-free.** `scan --mode vendored` and - `get --mode vendored` never write (or read) `.socket/manifest.json`: the - selected patch records are fetched into memory and every vendor-ledger entry - carries `detached: true` plus the embedded `record` as its verification - source, so a vendored project's footprint is `.socket/vendor/**` only. The - former `--detached` opt-in is now the only vendored posture — the flag is - hidden, accepted as a no-op for compatibility, and still a usage error - without vendored mode. JSON uses the detached download vocabulary for both - commands (`downloaded: N`, `detached: true`, `patches[].action` = - `downloaded` | `skipped` | `failed`). The vendor step vendors exactly what - discovery selected — the "whole manifest is vendored" re-vendor from a - committed manifest on an empty discovery is retired (`repair` verifies and - rebuilds committed vendored state) — and a legacy manifest record for a purl - a vendored run vendors is migrated into the ledger (dropped from the - manifest; an emptied manifest is left as `{"patches": {}}`). `list` now - reads the vendor ledger too, so a vendored-only project lists its patches - with a `Mode: vendored` label and exits 0 instead of `manifest_not_found`; - `scan --prune`'s lockfile-unused reconcile applies to every ledger entry - (the check is about the lockfile, not the manifest); and standalone `vendor` - with no manifest is a clean exit-0 no-op whose message names the missing - manifest (and the ledger entries `repair` verifies) instead of claiming - "No .socket folder found". -- **A plain `scan` without a TTY is report-only.** When stdin is not a TTY, - `--yes` is absent, and no intent flag (`--mode`, `--apply`, `--sync`, - `--vendor`, `--redirect`, `--prune`) is given, human-mode `scan` prints the - discovery report and the "To apply a single patch, run: …" hint, downloads - nothing, creates no `.socket/`, and exits 0 — it no longer auto-accepts the - apply prompt. Any intent flag, `--yes`, or a TTY keeps the previous - behavior; `rollback`/`remove`/`get`'s non-TTY auto-accept is unchanged. - Human `scan --mode hosted` now prints the results table and update - detection like the other modes and confirms once ("Redirect N packages - to the hosted patch server?" — the same prompt as `get --mode hosted` — - default yes, skipped by `--yes`/`--json`/`--dry-run`; on a non-TTY stdin - without `--yes` it prints `Non-interactive mode detected, proceeding - automatically.` and proceeds), fetches patch details with the agent arm's - progress counter and per-package warnings, and an empty hosted discovery - prints `No patches available for installed packages.` and exits 0 without - entering the redirect engine (was `Redirected 0 package(s)`); a discovery - whose every offer is paid-tier for an org without paid access stops the - same way with `No downloadable patches (paid subscription required).`. A - malformed redirect ledger on a human hosted run that stops before the - engine is reported as the read-only `Warning: the redirect ledger … is - malformed` advisory instead of nowhere. -- **`apply.lock` never outlives a command, and hosted mode takes it.** Lock - acquisition creates `.socket/` when missing; the lock file is unlinked - (while still held) and an otherwise-empty `.socket/` removed when the - command exits, dry runs included, so there is nothing to `.gitignore` and - `repair` no longer has a lock-cleanup step (a leftover from a crashed run is - reclaimed and removed by the next lock-taking command; a live holder is - still `lock_held`, exit 1). `scan`/`get --mode hosted` now acquire the lock - around their first wet write — never on `--dry-run` or when nothing would - be written, so previews create no `.socket/` — and report `lock_held` / - `lock_io` like the other lock holders (top-level `errorCode` on the hosted - JSON shape; a read-only project root or a file squatting on `.socket/` is - refused at the lock, before the redirect ledger is touched, and a - vendored→hosted takeover over a symlinked wiring file is refused with - `redirect_symlinked_file_unsupported` before any revert). A zero-grant wet - run — which holds no lock — no longer moves a malformed - `redirect-state.json` aside: like a dry run it reports the hard error and - leaves the file in place; only the lock holder quarantines. The lock guard - unlinks only the file it holds (a replacement planted by a non-cooperating - `rm` + `touch` is left for the next acquire), and a long `--lock-timeout` - wait behind a hot loop of short commands can no longer accumulate its - vanished-file retries into a spurious `lock_io`. Agent-mode `get` - and `scan --apply`/`--sync` hold one lock window across download → - manifest write → nested apply (the nested apply no longer re-acquires and - now inherits `--lock-timeout`/`--verbose`); `setup` takes the lock while - persisting `--exclude`; `scan --prune` acquires once for its vendored - reconcile and manifest prune, and the GC legs of `scan --prune` and - `vendor` honor `--lock-timeout` and report a lock I/O error instead of - silently skipping on it. -- **Retired:** the legacy `.socket/cargo-patches` redirect takeover in the - cargo vendor backend (never shipped in a tagged release — such - `[patch.crates-io]` entries now refuse as `user_authored_patch_entry`) and - the `pypi_pipenv_invalid_wheel` refusal code (the Pipenv backend takes the - resolved version instead of parsing the wheel filename). -- **`vex` attests a ledger record only while a lockfile still wires it — - even under `--no-verify`.** A vendor-ledger entry whose artifact no - lockfile/config references any more is omitted as `vendor_unwired`, and a - redirect-ledger record whose hosted patch no lockfile references as - `redirect_unwired` (a manifest-owned purl falls back to agent-mode - verification instead). Previously a reverted lockfile plus a leftover - `.socket/vendor/state.json` / `redirect-state.json` kept attesting, and - `--no-verify` / `--vex-no-verify` attested every ledger record outright; - those flags now skip only the hashing, never the wiring, record-match and - conflict gates. A malformed or unreadable `.socket/vendor/state.json` is - now the hard error `vendor_ledger_corrupt` (exit 2 standalone, the host - command fails under `--vex`), mirroring `redirect_ledger_corrupt`, instead - of a warning after which vendored patches silently lost their - committed-artifact verification and detached records. - Lockfiles that wire one package to different patches attest none of them - (`wiring_conflict`), and when the lockfile wires a package to patch U, a - manifest or ledger record for it under another uuid is superseded. - -- **A throttled patch API is retried with a bounded backoff.** An HTTP - 429 or 503 from the patch API made the affected batch (or patch-list - query) fail on the first answer — likelier now that up to 32 requests are - in flight. Now every patch-API JSON call (batch search, per-package patch - lists, patch views and VEX record fetches, hosted package references) - retries a 429 / 503 up to 3 times: it waits as long as `Retry-After` asks - (delta-seconds or HTTP-date; one over 30 s is not waited out — the answer - is final at once — and one under the jittered first step, such as `0` or a - past date, waits that step), otherwise 0.5 s, 1 s, 2 s (steps capped at - 8 s, jittered into their upper half). All retries in a run must end - within a 60 s wall-clock window opened by the run's first retry, so a - throttled run adds at most about a minute however many requests it makes - (requests waiting in parallel each keep their retries). - `SOCKET_API_MAX_RETRIES=` (0-10) changes the count; `0` restores the - old single attempt. Nothing else is retried: 401/403 still trigger the - proxy fallback at once, and the public proxy's permanent `503 "Patch API - is not configured"` is never retried on any path (the batch still - degrades to per-package lookups at once; a per-package lookup answering - it is still skipped). Output folds in request order exactly as an - unthrottled run's. What breaks: a throttled run now takes longer before - it fails (up to ~60 s of added waiting); the error text changes — it - names why retrying stopped (`Rate limit exceeded (HTTP 429, gave up after - 3 retries). Please try again later.`, `API request failed with status - 503: (gave up after 3 retries)`, `(Retry-After 120 s exceeds the - 30 s retry cap)`, `(the run's 60 s retry window has closed)`); and on - the token-less legacy per-package proxy path (a proxy without `POST - /patch/batch`) a package still throttled after its retries now fails its - whole batch query — every package in that batch goes unchecked and the - batch is reported as failed (warning, or the all-failed error when it was - the only batch) — instead of that one package being skipped silently. +v5 centers the workflow on `scan` (hosted patches), `vex` (OpenVEX attestations), +and `vendor` (committed patched packages), with `list` for inspection. See the +[v5 migration guide](docs/migrating-to-v5.md) before updating existing automation. + +### Breaking changes + +- `scan` and `get` default to hosted mode. `scan` never prompts, and hosted or + vendored `get` selects without an interactive menu. Explicit `--mode agent` + retains in-place patching; `get --save-only` and global targeting select agent + behavior by default. A mode-less global scan or `scan --prune` does not acquire + new patches, though `--prune` still cleans up obsolete state. +- Hosted mode writes no ledger. `list`, VEX, and update discovery read the live + dependency references. `rollback` and `remove` restore upstream registry entries + rather than replaying saved edits, and refuse where restoration is unavailable + (including offline operation and binary `bun.lockb`). Legacy hosted ledgers are + read only for compatibility and removed by full rollback. +- `rollback` restores dependencies and removes patch records and unused artifacts. + `--preserve-state`, also available on `remove`, keeps local state for reuse. + Hosted state has no local artifact to preserve. +- Vendored scans and targeted gets are manifest-free. The vendor ledger embeds + patch records; `repair` no longer reconstructs a missing ledger from lockfiles. + `vendor` can eject hosted pins when no agent manifest exists. Reverting an + ejected package restores upstream dependencies. +- Vendored Cargo patches use workspace-root `Cargo.toml` wiring and tagged + `+socket.` versions, visible to `CARGO_PKG_VERSION`. Re-running + vendoring or repair migrates older config-file wiring and untagged copies. +- `socket.yml` policy now constrains scans. Invalid patch policy fails before + requests or writes. Discovered test and fixture projects are excluded by + default; literal project targets skip those defaults. Hosted/vendored PATHs + outside the repository are rejected. +- Automatic patch selection prefers the newest merged patch, otherwise severity + and publication date. Existing patches change only when the new patch outranks + them; tier/UUID tie-breaking alone does not trigger replacement. +- `setup`, its publishing helpers, and the PyPI/RubyGems CLI distributions are + removed. Standalone binaries, Cargo, and npm remain supported; Python and Ruby + project support is unchanged. Remove old hooks using the migration guide. +- Removed `scan --redirect`, `scan --detached`, mode aliases `host`/`redirect`/ + `vendor`, the unimplemented `--one-off` flags, and the three legacy + `SOCKET_PATCH_*` environment aliases listed in the migration guide. + `.socket/packages/` archives are no longer consumed; cleanup removes leftovers. +- `list` on an empty project exits 0; `get` usage errors exit 2. Human help and + output are grouped by task, with diagnostic codes retained in JSON and verbose + output. Hosted JSON identifies lockfiles instead of a ledger; rollback's + `vendored` results contain vendor-owned entries only. See the + [CLI contract](crates/socket-patch-cli/CLI_CONTRACT.md) for exact schemas. +- VEX requires live hosted/vendored wiring and reports corrupt vendor ledgers. + Verified agent patches no longer require a `setup` hook for attestation. +- The core crate removes setup-related modules, obsolete public helpers, and the + unused `DepOverride::berry_zip_url` field. Patch references containing + `berryZipUrl` still parse. ### Added -- **`apply` and `rollback` patch vlt installs in place.** A project - installed by vlt (`node_modules/.vlt/` or `node_modules/.vlt-lock.json`) - is detected as vlt ahead of any sibling bun, pnpm, yarn or npm marker, - and `apply` prints `Note: vlt layout detected…` in human mode. `scan`, - `get`, `apply`, `rollback` and `vex` find every package in vlt's store - (`node_modules/.vlt//node_modules/`) in every DepID era, - including transitive-only packages, aliases, git/remote/`file:` entries - and workspace members' link-only trees. `apply` and `rollback` reach - every store copy of a patched `name@version` (vlt's `~peer.`, - hashed-peer and modifier variants, and the legacy `··` / `·npm·` pair), - and every write replaces the file rather than writing through it, so - vlt 1.2's machine-wide store (hardlinked on Linux) stays untouched. The - store-copy failure note is now `store copy failed to patch` / - `failed to roll back` for pnpm and vlt alike. `--update` in a vlt - project suggests `vlt install @socketsecurity/socket-patch@latest`, and - in vlx's cache `vlx -y -- @socketsecurity/socket-patch@latest …`. -- **`rollback`, `remove` and the vendored takeover revert hosted vlt - redirects.** A `redirect_vlt_lock_node` ledger edit (written by the - depscan PR flow, or by `scan --mode hosted` once it rewrites - `vlt-lock.json`) puts the registry integrity and URL back on the node, - keeping whatever vlt re-laid since (a moved comma, a new flag or bins - slot, CRLF re-saved as LF). A node vlt has since re-locked away is - already reverted; any other change refuses with the `vlt-lock.json` - remedy. Peer and modifier variants are claimed per `name@version`. -- **`scan --mode hosted` and `get --mode hosted` redirect vlt projects.** - `vlt-lock.json` default-registry nodes of a patched `name@version` (every - peer and modifier variant, in every DepID era and CRLF lock) keep their - DepID and get the patched sha512 and hosted URL; `vlt.json` is read only. - vlt drives confirmation when its install state is present or no other - npm-family lock is; otherwise both locks are rewritten - (`redirect_vlt_sibling_lockfiles`). Before anything is written, each - artifact is fetched as vlt fetches it: a response vlt would reject - (re-gzipped, wrong sha512, HTTP error, unreachable) withholds the dep - (`redirect_vlt_artifact_unverifiable`, whose detail spells the URL's - grant-token level ``) instead of pinning a lock `vlt ci` - cannot install. After the write, stale installed copies of the - Socket-owned nodes (`node_modules/.vlt-lock.json` and their - `node_modules/.vlt/` entries) are removed so the next `vlt install` - extracts the patched packages; `rollback` and `remove` do the same for - the registry bytes. New `--no-vlt-install-cleanup` / - `SOCKET_NO_VLT_INSTALL_CLEANUP` keeps them, and the - `redirect_vlt_reinstall_required` advisory says what to run. A stale - copy of an optional dependency is never removed, because `vlt install` - would not put it back; the advisory says to run `vlt ci` instead - (after upgrading to vlt 1.0.5 or later when every dependency is - optional, since 0.0.0-30 … 1.0.4 would drop the installed copy). A - same-run `--vex` does not attest a vlt package whose installed copy is - stale or unchecked, whose lock a vlt release may ignore, or which also - resolves from a non-default registry. vlt ledgers require the socket-patch - release that adds vlt support. -- **`vendor` wires vlt projects.** A `vlt-lock.json` (lockfileVersion 0 - or 1) routes npm vendoring to the new vlt backend ahead of every other - lockfile. A direct dependency of the root or of a workspace member is - vendored as a patched package directory, - `.socket/vendor/npm//-/node_modules//`, so a - package that `require()`s its own name still resolves; its - `devDependencies` are dropped from the vendored `package.json`, and the - uuid dir's `.gitignore` re-includes the payload against the project's - own ignores while `.gitattributes` keeps EOL conversion off it. The - lock's node becomes a `file` node, its importer edges and the importers' - `package.json` specs move to the `file:` path, and every moved entry is - placed where vlt's own serializer puts it, so `vlt ci`, warm and cold - `vlt install --frozen-lockfile` keep the lock byte-identical and `vlt - install ` keeps the wiring (checked against vlt 1.2.0, 1.0.10, - 1.0.4, 1.0.0-rc.32 and 1.0.0-rc.14). A node whose only extra is one - peer context (a root dependency with resolved peers from vlt 1.0.8, a - workspace member's from rc.15) is vendored without the extra, as vlt - writes `file:` dependencies. Transitive targets - (`vendor_vlt_transitive_unsupported`), several instances of one - `name@version`, modifier variants, foreign registries, peer edges and - dependencies declared in several fields refuse before any write, as do - locks vlt - cannot read and specs that no longer match the lock - (`vendor_vlt_lock_out_of_sync`); a payload git would ignore refuses with - `vendor_artifact_gitignored` (git failing to answer warns - `vendor_artifact_gitignore_unchecked`), and a package already vendored through - another lockfile flavor with `vendor_flavor_changed`. `vendor --revert` - restores the registry node, edges and specs (keeping flags, trailing - slots and outgoing edge values vlt rewrote since) or keeps everything on - drift. Lock inventory reads `vlt-lock.json` too, Socket-hosted pins - included. Era-A locks (`··` ids, or URL-segment ids equal to a scalar - `registry`) warn `vendor_vlt_legacy_lockfile`. A vendored optional - dependency gets the new `vendor_vlt_reinstall_required` advisory: from - vlt 0.0.0-30 a plain `vlt install` keeps its installed upstream copy, - so it says to run `vlt ci` (or delete `node_modules` and run `vlt - install`); it also names any dependency whose `node_modules` link still - resolves to vlt's store. Reverting a vendored optional dependency (also - in a vendored-to-hosted takeover) gives the same advisory, since from - vlt 0.0.0-30 a plain `vlt install` then keeps the link to the removed - vendored directory. -- **Vendored vlt through every command.** `vendor`, `scan --mode vendored` - and `get --mode vendored` run the complete vlt vendored preflight (lock - version and layout, transitive, peer or foreign-registry targets, - dependencies declared in several fields, out-of-sync specs, a package - already vendored through another lockfile flavor, the installed copy's - `bundleDependencies` or duplicate `devDependencies`, a git rule that - ignores `.socket/`) before any patch is downloaded, anything is written, - or a hosted redirect is reverted for the takeover; the dry-run previews - report the same codes as `would_refuse`. A committed vlt directory - artifact is staged inventory-verified when nothing is installed (a fresh - clone), and vlt's own link to it is never taken as a pristine source. - After a hosted → vendored takeover the store copies vlt installed from - the hosted pin are removed (`redirect_vlt_reinstall_required`), except - optional ones, which the advisory reports as installed copies of the - vendored optional dependencies. `repair` - finds vlt references in `vlt-lock.json` and workspace `package.json` - files, rebuilds vlt directories against the inventory that leaves out - vlt's `node_modules/` links, restores a missing `/.gitignore` or - `.gitattributes`, and stamps reconstructed entries `flavor: "vlt"`. The - human output names the vlt committables and `vlt install`. A Bun lock - beside `vlt-lock.json` no longer triggers the Bun vendored preflight. - The git-ignore check refuses only a rule that ignores the vendored - uuid directory itself (such as `.socket/`), not one like `*.json` that - the directory's own `.gitignore` overrides. A takeover whose vendoring - then fails still removes the hosted store copies against the restored - registry pin. When vlt's link to the committed directory is the only - installed copy, `vendor` says so (`vendor_ledger_entry_missing`, run - `socket-patch repair`, when the vendor ledger lost the entry) instead of - reporting the package as not installed. -- **`vex` reads `vlt-lock.json`.** Manifest-less VEX (and the ledger - liveness gates behind `vex`, `scan`'s takeovers and the - `hosted_wiring_retained` advisory) discovers hosted vlt nodes (a Socket - URL and sha512 on a registry node, every DepID era) and vendored vlt - package directories, and verifies a vendored directory with the vlt - `package.json` exemption, including the out-of-sync check of the - installed link. A lock vlt cannot read (BOM, other `lockfileVersion`) - wires nothing. A hosted npm package is now judged by every store variant - of its installed copies (pnpm and vlt peer, modifier and registry-alias - instances), and a same-version instance on another registry (or a - Socket-shaped one that does not verify) keeps a vlt hosted pin from - attesting before install, as does a lock some vlt release discards (no - `lockfileVersion`, a pre-v1 legacy-id lock without vlt.json `modifiers`, - or a scalar `registry` outside a v1 lock with `registries.npm`), which - also warns `patched_ref_unattributable`. A vendored vlt directory - verified without its vendor ledger checks a devDependencies-stripped - `package.json` against the patched blob in `.socket/blobs`, and is - omitted as `vendor_manifest_unverifiable` when that blob is absent. - `setup.manual` accepts `vlt`. -- **`setup` wires vlt projects.** A `vlt-lock.json`, `vlt.json`, - `node_modules/.vlt-lock.json` or `node_modules/.vlt/` directory in the - project root makes `setup` treat it as vlt, ahead of any pnpm marker. The - hook is npm's `npx @socketsecurity/socket-patch apply --silent --ecosystems - npm`, and a vlt workspace (vlt.json `workspaces`, or vlt <= 0.0.0-12's - `vlt-workspaces.json`) is wired at the root only, because vlt runs the - root hook once per install. The `setup --json` `packageManager` and the - `patch_setup` telemetry `manager` report `vlt`. vlt before 1.0.0-rc.13 - never runs a root `postinstall`: `setup` still wires the project and - warns `vlt_root_scripts_not_run` — definitely when the `vlt` on `PATH` - reports such a version, and as a "may" when `vlt-lock.json` has - `lockfileVersion` 0 or none and no usable `vlt` is found, or the one - found would not write that lock (a v0 lock beside vlt 1.0.0-rc.15 or - later). `setup --remove` also clears the hooks earlier releases wrote - into vlt workspace members. -- **vlt support is proven against real vlt releases.** Every supported vlt - release (0.0.0-1 … 1.2.0, see `docs/testing/vlt-compatibility.md` for the - excluded ones) ran the five real-vlt capstones locally; CI now runs 35 of - those cells on every pull request (ci.yml's `e2e` vlt rows, each checked - by `scripts/check-vlt-legs.py` against the leg manifest), the vlt legs of - the required `hosted-e2e` production job (hosted and vendored), and the - advisory `vlt-compatibility.yml`: every capstone on every era of Linux, - macOS and Windows, the Node engine floors, the store linkers, - `scripts/backtest-vlt.py` against the production service, a cross-OS - `vlt-lock.json` comparison, and nightly `vlt@latest`, release-watchdog and - downgrade jobs. `vlt-serve-watchdog.yml` probes the public patch artifact - every 6 hours the way vlt fetches it. vlt releases are installed from a - sha512-checked `npm pack` (`scripts/install-vlt.sh`, pins in - `scripts/vlt-historical-integrity.json`). Hosted vlt projects stay - refused (`redirect_vlt_artifact_unverifiable`) until patch.socket.dev - stops re-encoding artifacts; vendored and agent mode work against - production today. -- **`--json` reports a failed patch-API query as a warning.** Under - `--json`, a batch query that failed (after the bounded retry above) while - others succeeded vanished from the envelope without a trace, exit 0, and - the agent / hosted / vendored flows' failed per-package patch-list - queries did the same; the human run already warned on stderr. Each is now - a run-level `warnings[]` entry carrying the human line's text: - `{code: "api_batch_failed", detail: "API batch of failed: - "}` (in batch order) and `{code: "patch_details_failed", detail: - "could not fetch details for : "}`. Additive: `status` and - the exit code are unchanged while some query succeeded, and the - all-failed error envelope and exit 1 still apply when none did. - -- **`redirect_yarn_berry_mixed_line_endings` and - `vendor_yarn_berry_mixed_line_endings`.** A `yarn.lock` (or, vendored, a - root `package.json`) that mixes CRLF and LF line endings — or holds a bare - CR — has no single ending to keep, and yarn itself rejects such a lock - under `--immutable` (YN0028) and rewrites it wholesale on its next plain - install. Both modes now refuse it before any write with a code naming the - line endings and the `yarn install` remedy; a revert never refuses on line - endings (a restored lock entry takes the terminator of the entry it - replaces). The real-yarn berry suites gained `SOCKET_PATCH_YARN_BERRY_EOL=crlf` - to run on CRLF files on macOS / Linux as yarn writes them on Windows, and - print a `BERRY-EOL||||yarn=…|flow=…` line per fixture - file — see [yarn berry compatibility](docs/testing/yarn-berry-compatibility.md). -- **Hosted npm redirects configure npm 12's `allow-remote` for you.** npm 12 - defaults to `allow-remote=none` and refuses (EALLOWREMOTE) a lock that - resolves patched packages from the Socket patch host. When `scan --mode - hosted` / `get --mode hosted` leaves a root `package-lock.json` / - `npm-shrinkwrap.json` redirected, it now writes `allow-remote=all` to the - project `.npmrc` — creating the file, or appending one line with the BOM, - CRLF and every other byte preserved — so a plain `npm ci` installs the - patched bytes on npm 12 (verified on npm 10.9.9, 11.20.0 and 12.1.0). The - edit is ledger-recorded (`redirect_npmrc_allow_remote`, `created` / `added`) - and `rollback`, `remove`, scoped unwinds and the hosted → vendored takeover - remove exactly what was added once no package-lock entry needs it (a - modified created file keeps its other lines: - `redirect_npmrc_allow_remote_modified`, reported by `rollback`, `remove`, - `vendor` and the vendored reconcile). An explicit user - `allow-remote=none` / `root` is respected — in the project `.npmrc`, in the - user / global / builtin npm config (a committed project line would - silently override that machine policy), or in an `npm_config_allow_remote` - environment variable (which beats every `.npmrc`) — and the warning names - where it was found. A symlinked, unreadable or bare-CR `.npmrc` is left - alone (and a symlinked one refuses an unwind before anything is written), - `--dry-run` writes nothing but previews the write — also for a vendored → - hosted takeover — and - `--no-npm-allow-remote-config` / `SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG` opts - out. `redirect_npm_allow_remote` now fires on EVERY hosted npm run — - including when `.npmrc` already allows it — and always states the - tradeoff: `allow-remote=all` admits any url-resolved dependency, not just - Socket's, while the sha512 integrity pins stay enforced. The `.npmrc` - sniff now follows npm 12's measured grammar: only the exact key - `allow-remote` counts (an `allow_remote` / `ALLOW-REMOTE` line, which npm - ignores, previously silenced the warning), lines split on a bare `\r` as - well as `\n`, a `[section]` header only counts on the untrimmed line (as - in npm's `ini`), section bodies are not top-level (a section-scoped copy of - the line never makes the unwind ambiguous), and the value is - case-sensitive. Mode-preserving atomic writes now create their stage file - with the destination's permission bits, so a 0600 token-bearing `.npmrc` - is never staged world-readable. Vendored mode is unaffected - (npm gates `file:` tarballs by `allow-file`, default `all`). - -- **Manifest-less VEX: `vex` attests hosted and vendored patches straight - from the lockfiles.** `socket-patch vex`, and `apply` / `scan` / `vendor - --vex`, no longer need `.socket/manifest.json`, or the `.socket/vendor` - ledgers, to attest hosted and vendored patches. That covers a - depscan-opened PR, a clone that never committed its ledgers, and a lock-only - CI checkout. New read-only discovery (`socket-patch-core` `vex::discover`) - reads every supported root lockfile and config: - - npm: package-lock / shrinkwrap; - - pnpm: every lock generation, plus Rush locks; - - yarn classic and berry; - - `bun.lock` / `bun.lockb`; - - cargo: `Cargo.lock` + `Cargo.toml` + cargo config; - - Go: `go.mod` / `go.work` + sums; - - Python: uv, PEP 723 script locks, `pylock.toml`, poetry, pdm, - `Pipfile.lock`, requirements (+ `-r` includes), Hatch / PEP 621 direct - references; - - `Gemfile.lock` / `gems.locked`; - - `composer.lock`; - - maven: `pom.xml`; - - nuget: `nuget.config` + `packages.lock.json`. - - Each patch uuid is recovered from a Socket patch-host URL (or the - `--patch-server-url` origin) or a `.socket/vendor///…` path, - validated fail-closed. A lock that resolves the same package elsewhere - contests the wiring and blocks it. Records come from the manifest, then - the ledgers, then (online only) the patch API by uuid; nothing is written - to the manifest. `--offline` or a failed fetch omits the patch as - `record_unavailable`, and a record naming another patch or package as - `record_mismatch`. Evidence: vendored patches hash the committed artifact. - Hosted patches hash the installed copy the build consumes (the Go - replacement module, never the pristine cache copy), or, before any - install, attest from the lockfile's integrity pin. Unreadable or - unparseable lockfiles and rejected references surface as run warnings - (`lockfile_unreadable`, `lockfile_unparseable`, `patched_ref_invalid`, - `patched_ref_unattributable`) and never abort the run. Why a patch was - gated rides `warnings[]` too, so `--json` keeps it: a failed record fetch - (`vex_record_fetch_failed`, `vex_record_not_found`, - `vex_record_offline`), a stale-credential fallback to the public proxy - (`api_auth_fallback`, `get` / `scan`'s warning), a wiring conflict - (`vex_wiring_conflict`), a superseded record (`vex_record_superseded`) - and a dead ledger claim (`vex_claim_unwired`); a failed embedded `--vex` - adds one `vex_omitted` per omitted patch to the host command's - `warnings[]`. `apply --vex` and - `vendor --vex` with no manifest now write the document instead of exiting - 0 with none. A project with nothing wired anywhere keeps that calm exit, - and `apply --check` never generates. Manifest-less runs honor `vex - --dry-run` and `-O -` like every `vex` run, and show a transient - `Fetching patch records...` status line on a terminal. Per-PM support and - limitations: the README's "No manifest needed for hosted and vendored - patches" and CLI_CONTRACT.md's "Manifest-less VEX". -- **One lockfile reader per format, and one ledger-liveness rule.** - Discovery and the lock inventory read each lockfile through one reader - (the package-lock, composer.lock and Pipfile.lock entry walks, the hosted - rewriter's `pnpm-lock.yaml` grammar with one key grammar for every lock - generation, the backends' fail-closed `bun.lock` line grammar — so a hand - re-indented `bun.lock` is diagnosed `lockfile_unparseable` instead of - attested — one berry key splitter, which the vendored berry backend now - uses too, a shared `Gemfile.lock` model, the vendor backend's - `Cargo.lock` model, the Python lock and requirements-file readers the - rewriters use, with one exact-pin rule and one hosted pypi url - grammar). - `scan`'s cross-mode takeover warnings (`redirect_supersedes_vendored`, - `vendor_supersedes_redirect`), `hosted_wiring_retained` and - `redirectState.wiringLive` now prove the live lock with the same - discovery and liveness rules `vex` gates attestations on, instead of a - looser text scan: a ledger record counts as live only while a lockfile - wires that package to the record's patch (a hosted url carrying its - uuid, or the exact vendored artifact the ledger names). That rule reads - EVERY lock's entry for the package (a PEP 723 script lock resolving it - from PyPI no longer vetoes the project lock's hosted entry) and a - recorded Gemfile with discovery's source-block grammar (a commented-out - `source … do` block no longer keeps a reverted record alive). The - shared readers - also change the lock inventory at the edges: a v1 `Cargo.lock`'s - `[metadata]` checksums now verify a crates.io fetch, `requirements.txt` is - read as pip's logical lines (continuations joined, comments cut, a BOM - dropped), a wildcard `requirements.txt` pin (`six==1.*`) is no exact - version, a Pipfile.lock hosted reference to an sdist, an `http://` origin - or a path-prefixed origin stays inventoried, a CRLF `pnpm-lock.yaml` is - inventoried (it used to read as having no packages), and a `poetry.lock` / - `pdm.lock` / `Cargo.lock` that is not valid TOML contributes nothing - instead of a best-effort line scan. The vendored berry backend splits a - multi-descriptor lock key the way yarn writes it, so a key mixing the - patched package with another descriptor (`"lp@npm:left-pad@1.3.0, - left-pad@npm:1.3.0"`) is refused `vendor_override_conflict` instead of - being read as one descriptor. -- **Standalone `vendor` embeds the patch record in its ledger entries too.** - Vendored mode (`scan` / `get --mode vendored`) already writes every entry - `detached: true` with its embedded `record`; the manifest-driven `vendor` - command now also writes `record` into `.socket/vendor/state.json` (never - `detached` — the manifest record stays authoritative while the manifest - covers the package), so a project it wired whose manifest is gone still - verifies, lists and attests offline: `vex`, `list` and `setup --check` read - the embedded copy whenever no manifest entry covers the package, and - `repair` recovers the record without the API when there is no manifest at - all. Entries written by older releases keep working. -- **VEX product auto-detection covers Go, Composer, Maven, NuGet and - RubyGems projects**, after the existing git / `package.json` / - `pyproject.toml` / `Cargo.toml` probes: `go.mod` `module`, `composer.json` - `name`, `pom.xml` coordinates, the root's single `*.csproj`, the root's - single `*.gemspec`. Ambiguous roots yield no product rather than a guess. -- **Pipenv projects can use hosted patches, and vendored patches keep every - category.** `scan --mode hosted` rewrites every `Pipfile.lock` category - (`default`, `develop`, Pipenv 2022+ named categories) that pins the patched - release to the hosted wheel — `file` references for Pipenv 2018 and later, - `path` for 7–11 (probed once with `pipenv --version`; `SOCKET_PIPENV_MAJOR` - pins it), pipfile-spec < 6 refused — preserving markers, extras, unrelated - entries, the Pipfile and its content hash, with per-entry rollback - (`redirect_pipenv_entry`). Vendored mode keeps custom categories and extras, - uses `path` for wheels with extras (Pipenv 2022's file-URL bug) and refuses - installers older than 2018 (`pypi_pipenv_installer_unsupported`). A stale - Pipfile.lock only vetoes the sibling Python rewriters on a real pin/source - conflict (`redirect_pipenv_refused`); anything else is - `redirect_pipenv_skipped`. Measured across the last stable release of all - 18 published Pipenv majors — see `docs/testing/pipenv-compatibility.md` and - `scripts/backtest-pipenv.py`. -- **`Pipfile.lock` is inventoried.** Lock-only Pipenv checkouts (a fresh - clone with nothing installed) now discover their pins in every mode — - hosted redirects them, vendored fetches the pristine wheel by one of the - lock's recorded digests (`LockIntegrity::Sha256AnyOf`, resolved through - PyPI's JSON API and verified against the same digest) and agent/scan list - them as lockfile-only packages. Previously they discovered nothing and - exited 0. Socket's own references stay discoverable, so a re-scan of an - already-redirected or already-vendored lock-only checkout re-confirms it; - a lock that resolves only from private indexes is never looked up on - pypi.org. -- **Pipenv's out-of-tree virtualenv is discovered.** Agent mode (bare `scan`, - `rollback`, `vex`) now finds `$WORKON_HOME/-[-]` (the - `.venv` file pointer, `PIPENV_CUSTOM_VENV_NAME` and `PIPENV_PIPFILE` - included) exactly as Pipenv 7 through 2026 place it, instead of falling - through to the global interpreter's site-packages. -- **Pipenv stale-install guard.** Pipenv never reinstalls a release that is - already present, so a hosted or vendored rewrite over a warm venv leaves the - upstream bytes installed; `redirect_pypi_stale_install` / - `pypi_pipenv_stale_install` now say so, naming the site-packages dir and - the verified remedy (`pipenv run pip uninstall -y && pipenv sync`, or - a clean `pipenv --rm && pipenv sync`), and the stale purl is excluded from - the same-run `--vex`. -- **PDM projects take hosted patches, and hosted and vendored patches share - one validated `pdm.lock` rewriter.** `scan --mode hosted` rewrites `pdm.lock` - to point the target `[[package]]` at the hosted wheel URL with the patched - SHA-256 (`redirect_pdm_lock_package`), and `scan --mode vendored` wires the - same unit to a committed wheel through the shared rewriter - (`utils/pdm_lock.rs`). Both preserve line endings and non-canonical spacing, - support the legacy `[metadata.files]` table and separate `extras` entries, - are idempotent, and leave `pyproject.toml` and `content_hash` untouched. - Supported lock formats are `2` (PDM 0.12–1.4) and `4.3`–`4.5.1` (PDM 2.8.1+; - PDM 2.8.0 writes the same `4.3` lock but still loses candidate identity, so - upgrade to ≥ 2.8.1); - the identity-losing `3.1` / `4.0`–`4.2` formats (PDM 1.8–2.7) and unknown - future formats are refused before any write (`redirect_pdm_refused` / - `pypi_pdm_lock_version_unsupported`), leaving the registry lock installable. - A `pdm.lock` written by PDM 0.x/1.x (lock format `2`) warns - `redirect_pdm_legacy_sync_required`: those releases have an upstream - freshness bug, so `pdm install` can regenerate the lock — use `pdm sync`. - Verified end-to-end against real PDM 0.12–2.29 across hosted, vendored and - agent mode on Linux, Windows and macOS; see - `docs/testing/pdm-compatibility.md` and `scripts/backtest-pdm.py`. When - several Python lockfiles coexist, `uv.lock` and `poetry.lock` drive hosted - PyPI redirects ahead of `pdm.lock`, so a leftover `pdm.lock` beside them - neither blocks a live redirect nor is falsely attested. A hosted lock-only - `pdm.lock` checkout is discovered and redirected (the lock inventory now - reads `pdm.lock`), and a re-scan after an external `pdm lock` rebases the - ledger's recorded edits onto the relocked text so `rollback` stays - byte-invertible even when PDM reflows the lock's line endings. -- **Poetry projects take hosted patches, and vendored patches now cover every - `poetry.lock` generation.** `scan --mode hosted` rewrites `poetry.lock` to a - `[package.source] type = "url"` pointing at the Socket-hosted, SHA-256-pinned - wheel (Poetry 1.0 through 2.x; Poetry 0.12 ignores URL sources and is refused - with `redirect_poetry_lock_unsupported`), and `scan --mode vendored` accepts - the legacy `[metadata.hashes]` (0.12) and `[metadata.files]` (lock 1.0/1.1) - layouts next to the 2.x `files` arrays, CRLF locks included. The rewrite keeps - every other byte of the lock — dependency metadata, groups, markers, extras - and the pyproject `content-hash` — and records independent rollback fragments - per patch, so `rollback` restores the recorded originals in any order. - Verified end-to-end against real Poetry 0.12.17, 1.0.10, 1.1.15, 1.2.2, - 1.3.2, 1.4.2, 1.5.1, 1.6.1, 1.7.1, 1.8.5, 2.0.1, 2.1.4, 2.2.1, 2.3.4 and - 2.4.3 in hosted, vendored and agent mode — see - `docs/testing/poetry-compatibility.md` and `scripts/backtest-poetry.py`. - Poetry releases before 1.4 neither verify local wheel hashes nor replace an - already-installed package at the same version; both modes surface that as an - advisory (`pypi_poetry_integrity_unverified`, `redirect_poetry_stale_install_risk`) - keyed on the lock's writer, and the hosted rewriter warns - `redirect_poetry_entry_not_found` when a lock has no entry for a granted - patch (uv parity). A rotated grant token or republished patch supersedes the - earlier hosted URL in place instead of being refused as a foreign source, - a future `lock-version = "2."` is rewritten like 2.1 on every path - (the vendored loader already accepted it), and a malformed - `[metadata.files]` / `[metadata.hashes]` value is refused instead of - panicking the scan. Rollback stays invertible across Poetry's own relocks: - the recorded package fragment carries its boundary header, so a unit that - Poetry 1.1/1.2 re-laid (source kept, inserted `files` line dropped) is - refused rather than mistaken for an already-reverted lock, a lock-1.0 - redirect restored by hand converges instead of refusing, and a re-scan - after such a relock REBASES the ledger's edits (pristine → current) - instead of appending a chain whose older links match nothing — which made - `rollback` and `remove` refuse forever. (#241) -- **Python patches survive uv lockfiles in both hosted and vendored modes.** - `scan --mode hosted|vendored` now rewrites native `uv.lock` together with - the paired `pyproject.toml` source and metadata, PEP 723 script locks - (`*.py.lock` plus the script's inline metadata), PEP 751 `pylock*.toml`, - and uv-compiled hashed `requirements.txt`, so `uv sync --frozen|--locked`, - `uv run --script`, and `uv pip sync --require-hashes` install the patched - wheel instead of the registry artifact. Verified against real uv binaries - from every 0.x release family (0.0 through 0.12) — first and latest release - of each plus every observed behaviour boundary — see - `docs/testing/uv-compatibility.md`: hosted mode covers requirements from - uv 0.0.5 and native `uv.lock` from 0.1.45 — the first release whose - `uv lock` writes one — through all three `[[distribution]]` lock shapes - and `[[package]]`; vendored native covers every `[[package]]` release - (uv ≥ 0.2.35), vendored requirements cover uv ≥ 0.1.24 (hash-enforced by - `uv pip sync --require-hashes` from 0.1.32 and by default from 0.5.x). - Follow-up hardening: - `vendor --revert` refuses to delete a vendored Python wheel a lock still - references when the ledger entry has no wiring to replay (the shape - `repair` rebuilds), a script or PEP 751 lock supplements rather than hides - `poetry.lock`/`requirements.txt` pins, symlinked locks are discovered, and - refused before any write (never rewritten in place — uv writes through the - link, an atomic rename would replace it; hosted - `redirect_symlinked_file_unsupported`, vendored - `pypi_uv_symlink_unsupported` / `pypi_lock_symlink_unsupported`), CRLF - locks keep their line endings in both the hosted rewriter and the vendored - uv backend (`pyproject.toml`, the rewritten `[[package]]` unit and the - appended `[manifest]` / `[package.metadata]` fragments, plus their revert), - and the hosted `[tool.uv.sources]` edit renders as a header after - `[project]` the way uv writes it. (#238, #239) -- **uv `--locked` survives the project shapes the plain fixture never - reached.** A package listed only in `[tool.uv] dev-dependencies` is now - classified as a direct dependency (it was wired as a transitive override - and its `requires-dev` entry left stale), and every duplicate - `requires-dist` / `requires-dev` entry for the package — extras, markers — - is repointed rather than only the first, so `uv sync --locked` and - `uv lock --check` accept the patched lock instead of exiting 2 and a plain - `uv sync` no longer rewrites it. `[tool.uv] constraint-dependencies` / - `build-constraint-dependencies` naming the package have their `[manifest]` - `constraints` / `build-constraints` entries repointed too (uv ≥ 0.5.6 - serializes them with the package's source; 0.2.37–0.5.3 reject the - repointed entry under `--locked`, so the repoint emits the advisory - `pypi_uv_constraints_require_uv_0_5_6`). The transitive - (override-dependencies) branch emits the advisory - `pypi_uv_override_requires_uv_0_5_6`: uv applies `[tool.uv.sources]` to - overrides only from 0.5.6, so on 0.2.35–0.5.3 `--frozen` installs the - patch but a plain `uv sync` reinstalls the registry wheel. The - `[[distribution]]`-grammar vendoring refusal now names the real reasons - (relative path sources unparseable through 0.2.6, rejected by `--locked` - and absolutized by `uv lock` / `uv sync` on 0.2.17–0.2.34) instead of - "records absolute paths". `scripts/backtest-uv.py` gains a project-variant - lane covering these shapes on every `[[package]]` binary and a - `--render-doc-table` mode that prints the doc's results tables from - `results.json`; its export lane reads `uv export` from stdout because - `--output-file` only exists from 0.4.7. (#239) -- **Unwired Python vendor entries revert safely.** `vendor --revert` / - `rollback` on a ledger entry without wiring to replay (the shape `repair` - reconstructs) skips the lock-reference guard under `--preserve-state` - (nothing is deleted, so nothing needs protecting), refuses fail-closed when - the project root cannot be listed or a lock's symlink target cannot be - read (instead of treating "could not enumerate locks" as "no lock - references it" and deleting the wheel), probes `-r` / `--requirement` - includes of `requirements.txt` alongside the root file — the orphan sweep - and `repair` see include-hosted pins too — and reclaims a genuinely - orphaned artifact directory whose lock is gone or whose ledger flavor is - unknown instead of failing forever. Hosted `scan` / `get`, `repair`, and - ledger-less `rollback` read candidate lockfiles through the FIFO-safe - reader, so a named pipe at a lockfile name no longer wedges the command in - `open(2)`. (#239) - -- **Path targeting on `scan` and `rollback`.** `scan [PATHS]...` scopes - discovery to packages with an installed copy under a matching glob - (ancestor rule: `scan packages/foo` covers the subtree; `*` never crosses - `/`; absolute patterns are the only way to reach `--global` stores); the - prune universe is never narrowed (`scan PATHS --prune` prunes exactly - what an unscoped run would), lockfile-only/vendor-ledger supplements are - excluded with a `path_scope_excluded_supplements` warning, an empty match - is a normal empty scan (exit 0, no GC), and PATHS is rejected with - `--mode hosted|vendored` (exit 2). `rollback [TARGET]...` accepts - PURLs, UUIDs, and path globs (variadic, unioned); only path-SHAPED tokens - (separator, glob metachar, `./` prefix, absolute) become globs, so a - mistyped identifier stays a safe exit-1 error. A path target selecting - nothing is an error on rollback (exit 1) and an empty scan on scan - (exit 0); path targets select installed copies, and rollback restores - EVERY installed copy of a selected patch (`out_of_scope_copies_restored` - warning when copies live outside the patterns). -- **`--preserve-state` on `rollback` and `remove`** (env - `SOCKET_PRESERVE_STATE`): fully unpatch the system but keep the local - state for a later re-apply — manifest entries, vendored artifacts + - ledger entries (kept byte-identical; re-vendor re-wires from the live - lock) — and skip all GC. Hosted redirects have no preservable state: - they are unwound and their records dropped either way - (`hosted_state_not_preservable` warning). On `remove`, combining it with - `--skip-rollback` is a usage error (exit 2, flag- or env-sourced): the - combination would be a no-op — one flag keeps the tree and drops the - state, the other restores the tree and keeps the state. -- **Hosted redirect unwind.** Per-purl reverts for cargo + the npm family, - plus a whole-ledger reverse replay (core `patch/redirect/replay.rs`) that - runs whenever the scope covers every redirect record: a per-kind inverse - table, staged all-or-nothing per ecosystem group, covering gem, golang, - pypi, composer, bun, and the non-package rideshare edits (pnpm - `trustLockfile` auto-config — pristine scaffold deleted, modified - scaffold keeps the file and loses only the owned line). Native `bun.lockb` - package snapshots restore binary resolutions directly; - maven and nuget fail closed with `hosted_revert_unsupported` guidance - (their structured-metadata edits keep their ledger records; re-run - `scan --mode hosted` or restore from VCS). Refused groups keep their - edits AND records — the coherent ledger a retry needs. -- **`remove` gains the hosted leg and full archive GC**: an identifier - matching hosted redirect-ledger records unwinds those redirects (per-purl - or via the replay when it covers the full record set; works manifest-less - on hosted-only projects; unsupported ecosystems fail closed with - `hosted_revert_unsupported` before the manifest mutation), and remove's - default GC extends from blobs-only to blobs + diff + package archives - (parity with rollback/repair/`scan --prune`). -- **`SOCKET_API_CONCURRENCY` paces `scan`'s patch-API requests.** `scan` - now keeps several patch-API requests in flight (8 authenticated, 4 on the - public proxy) instead of one at a time. Set this variable — clamped to - `1`-`32`, and on the public proxy only downward — when something in front - of the API caps in-flight requests per client (a self-hosted `--api-url`, - a corporate reverse proxy, a WAF, a CDN) and a scan starts losing - requests to it. `SOCKET_API_CONCURRENCY=1` restores one request at a - time. Unset, empty or non-numeric values keep the defaults. Results, - warnings and their order never depend on the setting. - - Two request-count consequences an operator may see before they read the - code, neither of which changes any output: - - - A vendored run fetches prebuilt archives ahead of the wiring loop. - The plan it fetches is exact — it is gated by the same pre-flight each - vendor backend runs before it would ask the service (an unsupported or - absent lockfile entry, an override conflict, a workspace gate), so a - package the run does not end up vendoring is never asked for: the - `POST /v0/orgs//patches/package` download grants, which can start - a server-side archive build and count against quota, are exactly the - one-at-a-time loop's (71 on a fresh depscan run, where an earlier - draft of the look-ahead issued 74). What changes is only their timing: - up to four are in flight at once. `SOCKET_API_CONCURRENCY=1` turns the - look-ahead off entirely. - - A token revoked *mid-run* now costs the authenticated batch endpoint - the requests already in flight — up to the in-flight cap instead of - one — before the run downgrades to the public proxy. Their answers are - discarded and the connections are dropped mid-response, so the - endpoint's access log shows them; the downgrade warning, the patches - and the exit code are the same as before. +- Vendored Maven reactors and Gradle builds, with committed repositories, + reversible wiring, repair, rollback, and VEX. Reactors use suffixed versions; + Gradle preserves coordinates and lockfiles, checks artifact hashes, and updates + existing verification metadata. `vendor --check` audits artifacts and wiring + offline; `--local-repo` checks Maven cache conflicts and `--maven-config=none` + selects the fallback file repository. Single-POM vendoring is unchanged. +- `socket.yml` patch policy for paths, ecosystems, packages, severity, and per-run + limits. `scan --package`, `--min-severity`, `--max-new-patches`, and + `--no-socket-yml` support targeted and gradual rollout. Already-patched packages + retain protection when excluded; updates do not spend the new-patch budget. + Disk scans and the in-memory hosted engine share these rules. +- Manifest-free VEX discovery from hosted and vendored references, including fresh + checkouts. Product inference covers Go, Composer, Maven, NuGet, and RubyGems in + addition to existing formats. Embedded VEX supports the same evidence checks. +- vlt support across agent, hosted, and vendored workflows, with native installer + coverage and explicit version/layout refusals. +- Native binary `bun.lockb` reading and rewriting, alongside text `bun.lock`, + without invoking Bun or converting binary locks to text. +- Expanded Python lockfile support for uv, Poetry, PDM, and Pipenv, including + lock-only inventory, supported multi-version/marker shapes, and stale-install + diagnostics. Unsupported installer formats are refused before writes. +- npm 12 hosted `allow-remote` configuration and dual-lock handling, plus pnpm + trust-lockfile configuration. Explicit user settings are respected. +- Path targeting on scan and rollback, hosted update detection from lockfiles, + and configurable API request concurrency. + +See [ecosystem support](docs/ecosystems.md) and the +[compatibility guides](docs/testing/README.md) for format boundaries, integrity +limits, and required install commands. ### Fixed -- **Hosted nuget redirects survive a `` in `nuget.config`.** The - Socket source (and, in an existing ``, its - mapping) was inserted ahead of the section's ``, which NuGet - applies to everything read before it: `dotnet restore` then failed - NU1100 / NU1101 for the patched package. Both now land after the last - ``. - -- **nuget redirects and vendoring edit the config NuGet actually reads.** - NuGet reads the first of `nuget.config`, `NuGet.config` and - `NuGet.Config` in a directory. Hosted mode only knew `nuget.config` - and vendored mode missed `NuGet.config`, so on a case-sensitive - filesystem they created a fresh `nuget.config` that shadowed the - project's own file: its sources and mappings vanished and private - packages failed restore. Both modes now edit the existing spelling in - place. - -- **`rollback` fetches a before-blob that only a store peer variant - needs.** The before-blob gate now probes every pnpm and vlt store variant - copy the rollback restores, so an online rollback no longer fails - `Before blob not found` for a still-patched variant beside an - already-original copy. - -- **Re-vendoring under a newer patch never builds from the old patch's - artifact.** With no installed copy, `vendor` staged the committed - artifact of the previous patch as the build source, so a file only the - old patch changed reached the new artifact unnoticed. The committed - artifact is now staged only for the patch that built it; a newer patch - fetches the pristine package per the lockfile (`--offline` skips it). - -- **Ledgers written by a newer socket-patch are never half-reverted.** - A hosted redirect edit kind this release does not understand used to - let `rollback` drop the npm record beside it, leaving that lockfile - redirected with nothing tracking it. Such an edit now holds every - record in the redirect ledger ("the redirect ledger holds a {kind} - edit this socket-patch release does not understand; upgrade - socket-patch"). When it names a purl, that purl's own revert in - `rollback `, `remove` and the hosted-to-vendored takeover - refuses with nothing written, and the takeover's ledger reconcile - leaves the purl for the manual cleanup. When the scope still covers - every hosted record, the whole-ledger replay goes on to unwind the - lockfiles this release understands, but keeps every record and the - unknown edit. - `repair` skips vendored npm entries whose `flavor` it does not know - (`vendor_wiring_unknown_revert_blocked`) instead of rebuilding them - with the wrong layout rules. vlt ledgers (`redirect_vlt_lock_node`, - `flavor: "vlt"`) require the socket-patch release that adds vlt - support. - -- **A patch file the patch never changes no longer blocks vendoring.** The - patch view serves `blobContent` only for the files a patch CHANGES, so a - zero-delta file (`beforeHash == afterHash`) comes back with hashes and no - content — and needs none: the pristine copy already carries the patched - bytes. The vendor stager counted such a view as a failed fetch, which made - any patch carrying a zero-delta file permanently unvendorable (live - example: `pkg:npm/tar-fs@2.1.1`). -- **One unstageable patch no longer kills a whole vendored run.** A package - whose patch content cannot be obtained now gets its own `failed` event - with `errorCode: "no_local_source"` and the rest of the run still vendors, - in `vendor`, `scan`/`get --mode vendored` and `repair` alike. The event's - `error` names the real reason (which file the view served without content, - a malformed blob, or the fetch error) instead of the generic run-level - "patch artifacts unavailable (offline or download failure)". - **JSON consumers:** for a partial staging failure the vendor envelope is - now `status: "partialFailure"` with `error: null` and per-package events, - where it used to be `status: "error"` with a top-level - `error.code: "no_local_source"` and an empty `events[]`. The run-level - shape is unchanged when NOTHING in the manifest can be staged (including - a one-patch manifest) — `no_local_source` can therefore arrive run-level - or event-level, and both shapes are documented in CLI_CONTRACT.md. -- **`get` emits its patch lists in a stable order.** The release-variant - narrowing drained a `HashMap`, so `download.patches`, `apply.patches` and - the per-patch stderr lines came out in bucket order: two identical runs of - the same project emitted the same records in different orders. All of them - are purl-ordered now, matching every sibling collection in the envelope. -- **A requirements.txt this CLI already rewired stays in the lockfile - inventory.** Both shapes we write — the hosted `name @ ` - direct reference and the vendored bare `./.socket/vendor/pypi/…` wheel - path tagged `# socket-patch vendor: ==` — are read back as the - package they replace (discovery-only, exactly like the `==` pin they - replaced). A second hosted run over a wet requirements.txt reported - `packagesWithPatches: 1` instead of 12; a vendored one under-reported the - same way. -- **`vex`'s API-fallback note no longer depends on which refusal landed - first.** When the patch API refuses several patch records, the - `api_auth_fallback` note quoted whichever refusal happened to answer - first — a race, so two runs of the same project could report different - text (`Unauthorized` or `Forbidden`) and retry the refused records in a - different order. Both now follow the order the records are listed in, - like every other note. -- **Hosted Go redirects no longer claim patches that did not land.** - `scan`/`get --mode hosted` counted a Go module as redirected (recorded - it in the redirect ledger, so `vex` attested it) whenever any project - file contained the patch-server origin — e.g. an already hosted - `package-lock.json` — or leftover `patch.socket.dev/gopatch/…` go.sum - lines, even when the go.mod rewrite was refused. A Go module now counts - only when its go.mod `replace` and both go.sum lines are in place. A - module that go.mod does not require and go.sum does not list at the - patched version (another project's module found in the shared module - cache) is refused with `redirect_golang_not_in_module_graph` instead of - getting an inert `replace`. -- **Switching a vendored Go module to hosted mode cleans up the vendored - copy.** `scan`/`get --mode hosted` rewrote the vendored `replace` but - left `.socket/vendor/golang//` and its vendor-ledger entry - behind, so the next `vendor` run switched the module back. The hosted - run now reverts the vendored state first, as it already did for cargo - and npm. -- **Go `replace` directives the CLI did not write are left alone.** A - replace onto another checkout's vendored copy - (`../other/.socket/vendor/golang/…`) or onto a module under - `patch.socket.dev/gopatch/` other than `` was treated as - socket-owned and could be rewritten or removed. Only - `./.socket/vendor/golang/…`, `./.socket/go-patches/…` and exactly - `patch.socket.dev/gopatch/` are now owned. -- **Go replace edits keep go.mod valid and readable.** A go.mod carrying - two socket-owned `replace` lines for one module (for example after a - merge) is collapsed to one instead of leaving a duplicate go rejects; - refreshing a directive keeps its trailing `// comment`; and quoted - module paths (`"github.com/x/y"`) are recognized, so a quoted user - replace is no longer duplicated and a quoted `require` still gets the - version check. -- **`+incompatible` Go modules resolve in vendor and apply.** Purls that - spell the version `v2.0.0%2Bincompatible` are now percent-decoded, so - the module is found in the module cache and the `replace` and copy - directory carry the real `+incompatible` version. -- **`+incompatible` Go copies survive `apply` reconcile.** A manifest key - spelled `%2Bincompatible` no longer makes the freshly applied - `.socket/go-patches/…@v2.0.0+incompatible` copy look orphaned, so it and - its `replace` are kept. -- **Hosted Go rollback restores go.sum byte for byte.** The upstream - module's go.sum lines that the redirect pruned went back at the end of - the file; they now return to the position `go mod tidy` sorts them to - (semver order within a module). CRLF go.mod/go.sum files also unwind - cleanly, with no leftover socket lines or blank lines. -- **Vendoring a hosted Go module unwinds the hosted redirect first.** - `vendor` / `get --mode vendored` over a hosted-redirected Go module left - its redirect-ledger record and the socket module's go.sum lines behind - (with the upstream lines still pruned). Go now takes the same per-purl - takeover revert as cargo and npm (`vendor_takeover_reverted_redirect`), - and scoped `rollback ` / `remove ` of one hosted Go module - works without an unscoped rollback. -- **Vendored Go fetches honor `GOPROXY=off`, `direct` and `GOPRIVATE`.** - With the module missing from the module cache, the pristine fetch fell - back to `https://proxy.golang.org` even when go itself would ask no - proxy, sending private module paths off the machine. It is now refused - (`vendor_fetch_unverifiable`, then the usual `package_not_installed` - skip) unless `SOCKET_GOPROXY` names a proxy. -- **yarn berry projects on Windows (CRLF files) are redirected and vendored - instead of refused.** yarn berry (2.x–4.x) writes a file it creates with - the OS line ending (`os.EOL`) and keeps an existing file's majority ending - on every later write (`normalizeLineEndings` in yarnpkg-fslib's - `FakeFS.ts`, used by `Project.persistLockfile` and - `Workspace.persistManifest`) — so on Windows a fresh `yarn.lock` and the - `package.json` yarn first pretty-prints are CRLF, and a `core.autocrlf` - checkout makes them CRLF on any OS. `scan --mode hosted` / `get --mode - hosted` refused every such lock (`redirect_yarn_berry_crlf_unsupported`, - redirected 0); a CRLF lock is now rewritten in its own line ending — every - untouched byte, a leading BOM included, round-trips — and the ledger records - the lock's on-disk CRLF fragments, so `rollback`, `remove` and the hosted → - vendored takeover restore it byte-for-byte (they also replay a ledger - recorded on a checkout whose uniform line ending has since flipped, LF ↔ - CRLF). `vendor` / `scan --mode vendored` now keep `package.json`'s layout - (BOM, indent, line ending, trailing-newline shape) on both the wiring and - the revert: `vendor --revert` wrote a CRLF manifest back LF, never - byte-identical to the pre-vendor file. A BOM'd `package.json` (and a - BOM'd `.yarnrc.yml`, whose first-line `compressionLevel` was read as - unset) no longer fails the vendored backend, and every berry reader skips - a BOM in front of a header-less `__metadata:`. Verified on real yarn - 4.12.0 (hosted, vendored, workspaces, pnpm linker, both mode takeovers) - with the fixtures re-spelled CRLF, and on yarn 2.4.3 / 3.8.7 (still - refused for their cacheKey, never for their endings). -- **A yarn berry mode takeover no longer strips the old mode's patch before - the new mode refuses the project.** `scan` / `get --mode hosted` over a - vendored berry purl reverted its vendored wiring, ledger entry and - artifact (`redirect_takeover_reverted_vendored`: "now fully hosted") and - only then ran the rewriter, which refused a lock with mixed line endings - (or an unsupported `cacheKey` / `.yarnrc.yml` `compressionLevel`) — - `redirected: 0`, and the next `yarn install` pulled the unpatched registry - package. `vendor` / `scan --mode vendored` over a hosted berry purl did the - same in reverse (`vendor_takeover_reverted_redirect`, then `failed` - `vendor_yarn_berry_mixed_line_endings`). Both takeovers now run the new - mode's berry gates first — wet and `--dry-run` alike — and a refused purl - keeps the old mode's wiring byte-identical. -- **`setup` keeps a CRLF `package.json` CRLF.** `setup` and `setup --remove` - re-serialized `package.json` with bare LF and dropped a leading BOM, so on - a Windows yarn berry project (yarn pretty-prints the manifest with CRLF) a - two-key script edit became a whole-file diff that yarn then kept, and - `setup --remove` could not land byte-identical on the pre-setup file. - `package.json` is now written in its own layout (BOM, indent, line ending, - trailing-newline shape), the same helper the vendored backends use. -- **Two vendored versions of one cargo crate are documented — and now - warned about — as needing cargo 1.45.** The docs said older cargo (1.41) - only needed a populated crates.io index. It needs more than that: cargo - before 1.45 resolves every source-less `Cargo.lock` entry for a crate - through ONE `[patch.crates-io]` path — the entry whose KEY sorts last — - so one of the two versions is pinned to the other's copy and `cargo build - --locked` fails closed with ``patch for `` … did not resolve to - any crates``, index or no index. The old-toolchain e2e passed only - because its fixture uuids happened to sort the other way; it now uses the - adversarial order, and the floor was measured rather than assumed — on - one two-version fixture in both key orders, 1.41.1, 1.42, 1.43 and 1.44 - refuse the adversarial order while 1.45, 1.49, 1.53, 1.56 and current - stable resolve either order, each lock entry to its own copy. Vendoring a - second version of a crate warns with `cargo_multi_version_old_cargo` - unless the project's `rust-version` or `rust-toolchain[.toml]` promises - cargo 1.45 or newer (socket-patch never runs `cargo`, so those files are - the only signal it has). A SINGLE vendored version still builds on cargo - 1.41, as before. -- **A CRLF `Cargo.lock` stays CRLF, and reverts byte-for-byte.** Vendoring - rewrote every line of a lock committed with Windows line endings as LF - (`toml_edit` renders LF only), and `vendor --revert` then "restored" the - all-LF file — a whole-file diff on a Windows checkout and a rollback that - was not byte-identical. The lock edits (detach, retag, restore) now map - the rendering back onto the file's own line endings, the way the copy's - `Cargo.toml` already did, in lock formats v1–v4: a CRLF lock stays CRLF, - a missing trailing newline stays missing, and every line a mixed-ending - lock's edit leaves alone keeps its own ending. -- **Vendored mode can patch a package whose version carries build - metadata.** The patches API serves canonical PURLs, so a semver build - metadata version arrives percent-encoded - (`pkg:cargo/wasi@0.11.0%2Bwasi-snapshot-preview1`). The PURL parsers - compared that raw spelling against the lockfile / install directory's - `0.11.0+wasi-snapshot-preview1`, never matched, and refused the package - (`vendor_fetched_missing`, then `locked_version_mismatch`) — so no cargo - crate with build metadata (`wasi` is in most Rust dependency graphs) - could be vendored at all. Every ecosystem's PURL parse now - percent-decodes the namespace, name and version once, after the - `/`-and-`@` split and before the path-safety guards, so an escaped - separator still cannot introduce a path segment. Hosted mode was already - correct. -- **A vendoring-service outage no longer re-vendors packages.** An npm - re-run (every lock flavor, `bun.lockb` included) re-acquired its tarball - from whichever source answered — the service's prebuilt, or a local pack - with different bytes — so an outage or its recovery rewrote the lock's - integrity and the committed tarball and reported `applied`. A re-run now - keeps the committed artifact whenever the vendor ledger vouches for it - (uuid-bound path, no symlink, sha256 + size equal to the ledger, a - canonical archive an installer extracts exactly as decoded, every - patched file verified from the same bytes) and is `already_vendored` - with no service request, in every `--vendor-source` mode (including - `service` + `--offline`, as cargo and composer already did; golang now - matches). A pypi re-scan after a relock re-wires the committed wheel - instead of pinning a new sha, the PDM partial-relock guard holds - whichever source built the wheel, a wiring failure no longer deletes a - committed wheel, and a missing prebuilt wheel during an outage now says - to wait for the service. `--dry-run` previews the same reuse, so it no - longer predicts a `service` + `--offline` refusal the real run does not - have. Transient service failures (network, timeouts, 429, 5xx) are - retried with backoff, each attempt is time-bounded, and after two - consecutive exhausted fetches the run stops calling the service. -- **Terminal output is clean on every command.** Progress lines no longer - leave stale text behind (`scan` printed e.g. `Found 7 patches for 1 - packagesatch 7/7)`) or run into warnings printed while they are active. - Progress, prompts, color and truncation now share one implementation. - - **Progress lines:** a status line clears itself on finish. It is never - drawn off a TTY, under `TERM=dumb`, in debug mode, or under - `--json`/`--silent`. `fetch`, `vendor`, `setup`, lock waits and - `--update` checks now show progress instead of going quiet. - - **Prompts:** Ctrl-D at a `[Y/n]` prompt now declines instead of - accepting. Keys pressed while a scan is running no longer answer the - prompt that follows. The cursor is restored when a selection menu is - interrupted. - - **Color:** `NO_COLOR`, `CLICOLOR`, `CLICOLOR_FORCE` and `TERM=dumb` are - honored. Colored table rows now align. - - **Wording:** counted nouns read `1 package` / `2 packages` instead of - `package(s)`. `Error:` / `Warning:` prefixes are consistent, and - warnings go to stderr. `--silent` is errors-only, but a failing run - still prints why. Output that came out in random order is now sorted. - `--help` pages no longer show developer notes. - - **Behavior fixes:** `get --dry-run` and `vex --dry-run` no longer write - anything, and `vex -O -` writes to stdout. `scan --json` never stops at - an interactive menu. API errors show the server's message instead of a - raw JSON body. -- **Reversal leaves no `.socket/` residue.** `rollback`, `remove`, - `vendor --revert`, the hosted unwind and the GC sweeps now prune what they - empty: an emptied redirect or vendor ledger is deleted together with the - empty `.socket/vendor//` and `.socket/vendor/` directories (per-entry - vendored reverts prune their ecosystem husk; a `redirect-state.json.corrupt` - quarantine keeps its directory), emptied `blobs/`, `diffs/` and `packages/` - stores are removed, and `.socket/` itself goes with the lock when nothing is - left — so a fully unwound hosted or vendored project has no `.socket/` at - all. Deliberately kept: the zero-patch `.socket/manifest.json` - (`{"patches": {}}` + its `setup` block — `list`/`apply`/`vex` exit codes - depend on it) and the `setup`-owned `.socket/.gitignore`, - `gem-plugin-stamp` and `bundler-plugin/` (rollback never undoes setup). - `setup --remove` now also removes an emptied `.socket/`. -- **`scan --prune` says what it skipped and what it could not finish.** The - `gc` JSON sub-object gains `failedVendoredEntries` plus the additive - `skipped: {code, message}` (`lock_held` | `lock_io`) and - `warnings: [{code, detail}]` (`vendor_state_write_failed`, - `manifest_write_failed`, `cleanup_failed`) keys, with matching `GC: …` - human lines, so a pass that could not take the lock or could not rewrite a - ledger no longer reads as a clean all-zero sweep — and a lock I/O fault or - a failed rewrite is never mislabelled as lock contention. A legacy manifest - record migrated into the vendor ledger is reported as the - `vendor_manifest_record_migrated` / `vendor_manifest_migration_failed` run - warnings; a corrupt manifest no longer fails a vendored run (standalone - `vendor` still fails closed on it). -- **Vendored `get`/`scan` name the patch they replace.** A `downloaded` - record for a purl the vendor ledger holds at another uuid carries `oldUuid` - and the human `[fetch]` line reads `(replacing )`; - `get --mode vendored --dry-run` prints `[dry-run] Would download and vendor - N patches. No changes made.` on both identifier paths; the `[note]` and - `Patch record saved to` lines are gone with the manifest. -- **Agent-mode `get` leaves nothing behind when it records nothing.** - `.socket/` and `.socket/blobs/` are created only when a record is - persisted (all-skipped and all-failed runs leave no `.socket/`), a - same-uuid `get ` re-run rewrites neither the manifest nor the blobs, - and a blob/diff fetch that lands nothing creates no `.socket/blobs/` or - `diffs/` — the `Cannot create blobs/archives directory` all-failed envelope - is gone; an uncreatable cache dir is a per-entry - `Failed to write blob/archive to disk`. -- **`ownership_not_restored` is a warning, not silence.** A file `apply` - patched (or `rollback` restored) whose ownership could not be put back to - the original uid/gid now surfaces as an `ownership_not_restored` run - warning (`warnings[]` plus `Warning (ownership_not_restored): …` on - stderr) instead of riding a successful result unseen; the mode is still - restored. -- **`remove` on ledger-only state.** A missing manifest beside a vendor or - redirect ledger that holds nothing for the identifier answers `not_found` - (exit 1) instead of `manifest_not_found`; a second `--skip-rollback` on - the ledger-only leftover of an earlier `--skip-rollback` is refused with - `vendor_state_retained` (was `not_found`); every matching vendor-ledger - entry — detached or not — is removable through the ledger with - `--preserve-state` and drift-keeps honored exactly as on the manifest - path; manifest entries are removed in sorted order, and the - `(not installed)` line prints only when something was not installed. - `rollback` prints `No patches found in manifest` only for an unscoped run - with no work in any leg. -- **`setup --exclude` persists after the prompt, under the lock.** The - exclusion list is written after discovery and confirmation (also on the - already-configured path when the flag is explicit) as a read-modify-write - under `apply.lock`; a held or unopenable lock, or a manifest that cannot - be read or written, is reported as `not persisting --exclude: …` instead - of being swallowed. `setup --check` reads the vendor ledger even without - a manifest and, on a corrupt one, warns `unreadable vendor state` and - reports a `vendor_ledger` error entry (verdict `error`, exit 1) — never - `configured`; `vex` refuses the same unreadable ledger outright - (`vendor_ledger_corrupt`, see Changed); `list` - degrades a corrupt vendor ledger to a `Warning: unreadable vendor ledger …` - line (muted by `--silent`) rather than an error; `patch_setup` telemetry - fires only for a successful, non-dry-run setup. -- **`repair`/`vendor` state hygiene.** `repair` resolves installed copies - through qualified ledger keys (gem `?platform=`, pypi `?artifact_id=`, - maven `?classifier=` no longer read as "not installed"), puts a crashed - rebuild's `.pre-rebuild` set-aside back when it is the only copy, - and reports an absent or empty blobs dir as `No blobs to clean up.`; every - artifact sweep (`repair`, `rollback`, `remove`, `scan --prune`) keeps going - past one unremovable file and reports the failures afterwards; `vendor`'s - dropped-record reconcile saves per purl and counts a failed save as - `vendor_state_write_failed`; `vendor_marker_write_failed` is the one - marker-failure warning for every backend (cargo/golang/pypi's - `marker_write_failed` retired), and a pypi vendor whose informational - marker cannot be written now succeeds with that warning instead of - sweeping the wheel; npm, yarn (classic and berry) and pnpm reverts honor - the drift-keep on an unwired entry like bun and legacy pnpm already did; - the hosted replay no longer credits a byte-identical hatch rewrite as an - edited file; a corrupt redirect ledger met by a hosted scan is reported - once, not twice. -- **Manifest inputs are validated before they become paths.** `apply` - refuses an `afterHash` that is not a 64-hex blob hash or a uuid that is not - a plain path segment and reads blobs through a symlink-refusing opener (a - poisoned manifest or a planted `blobs/` symlink can no longer read out of - tree); `rollback` deletes patch-added files in every pnpm store copy and - heals a patched twin of an already-original primary. -- **Human chrome.** The global-mode `Using at: ` banner moves to - stderr so piped stdout stays clean; the empty-crawl hint of `scan` and - `get` reads `Run your package manager's install first.` instead of a fixed - npm/yarn/pnpm/pip/cargo/go/mvn/composer list; `vendor --revert` and the no-manifest - no-ops of `vendor` and `apply` (and `apply --check`) build no API client, - so the `SOCKET_API_TOKEN` advisories no longer print on hooked - manifest-less runs, and `repair` prints its token notice once. -- **Telemetry and self-update robustness.** The telemetry client uses a 2 s - connect timeout (a blackholed endpoint no longer stalls every command for - the full request budget), and `--update` maps only a contention errno to - `update_in_progress` — other lock failures surface their real cause. -- **A normal `scan` never creates `.socket/`.** Report-only, `--dry-run`, - zero-discovery and no-op runs (hosted or otherwise) no longer scaffold the - directory or a lock file; a GC pass checks for a manifest before it locks. -- **`apply --silent` on an all-unmatched manifest prints its error line** — - errors are never muted by `--silent`; and the no-manifest early exits of - `apply` and `vendor` name the missing `.socket/manifest.json` instead of - "No .socket folder found" (the folder may legitimately hold setup files or - vendored state). -- **Hosted redirect hygiene.** Missing project files no longer skip silently: - `redirect_composer_no_lockfile`, `redirect_gem_no_gemfile` (neither manifest - nor lock present) and `redirect_maven_no_pom` (no `pom.xml`, no Gradle - build) warn once per run; a present-but-corrupt `packages.lock.json` warns - `redirect_nuget_lock_unparseable` before any config mutation; a `Cargo.lock` - with several same-name+version `[[package]]` blocks and no `source` - disambiguation warns `redirect_cargo_lock_pkg_ambiguous` and skips - transactionally; a registry override of the wrong kind now warns the arm's - missing-override code for nuget/gem/golang (previously a silent skip); the - ledger's `redirect_nuget_source` edit records `action: "added"` when - `nuget.config` was authored from scratch; hosted-revert lockfile restores are - atomic and mode-preserving (including `bun.lockb`), and a FIFO or symlink - squatting on a lockfile is refused instead of wedging the revert. -- **Vendor backend parity.** Gem reverts follow every other backend's - drift-keep rule (genuine drift keeps artifact + ledger entry; converged files - are silent; a missing `Gemfile`/`Gemfile.lock` warns `vendor_lockfile_missing` - and still removes the artifact); composer, maven and nuget reverts keep the - artifact + ledger entry (`kept_artifact`, the `vendor_revert_kept` skip) - while the live `composer.lock` / `pom.xml` / `nuget.config` still names the - drift-skipped entry's uuid dir — previously the dir was deleted under a - `` / `` that still routed at it — and remove it once - nothing references it; the golang service leg stages its download - and, when a re-download of a wired present copy fails, keeps the copy and - directive instead of tearing them down; poetry/pipenv/requirements refuse - symlinked targets (`pypi_{poetry,pipenv,requirements}_symlink_unsupported`) - and every pypi flavor refuses a project file that changed between plan and - write (`pypi_{poetry,pdm,pipenv,uv}_changed`) instead of clobbering it; - `pyproject.toml` edits made by `setup` preserve CRLF line endings; an - unreadable (EACCES / squatting directory or FIFO) redirect ledger is - reported as unreadable and left in place instead of being quarantined as - "malformed"; a blob-cleanup pass keeps sweeping after one unremovable file - and reports the first error afterwards; the ledgers skip byte-identical - rewrites. -- **Hosted composer redirects no longer fall back to the pristine git - source.** Composer 1 and 2.2 LTS silently install the upstream commit from - `source` when the hosted dist fails; the rewriter now drops the entry's - adjacent `source` block (one fragment edit, reverted byte-for-byte) and - warns `redirect_composer_source_kept` when a hand-ordered source cannot be - dropped. -- **Hosted gem locks keep bundler's source order.** The patch-registry - `GEM` section is inserted where bundler sorts it, so `BUNDLE_FROZEN=true - bundle install` on bundler ≥ 4.0.19 no longer refuses the converged lock. -- **v1 `Cargo.lock` files redirect and vendor correctly.** Hosted and - vendored rewrites now follow the `[metadata]` checksum table and rewrite - dependents' full-id references, so `cargo --locked` accepts the lock (and - the revert stays byte-identical). -- **Hosted cargo pins every declaration of the patched version.** Each - version of a multi-version crate is pinned only in the declarations whose - requirement selects it (a requirement matching several locked versions is - refused `redirect_cargo_toml_dep_unrewritable`), and workspace-member and - in-root path-dependency manifests are pinned beside the root, so - `cargo --locked` accepts the redirected lock. Member discovery never - follows a symbolic link, so nothing outside the project is rewritten. -- **Hosted cargo refuses crates a pin cannot reach.** A crate another - `Cargo.lock` package also depends on (a crates.io or git crate, or a path - package outside the project) now warns - `redirect_cargo_transitive_dependents` and is skipped instead of being - reported redirected while that package compiled the unpatched copy; a - transitive-only crate's `redirect_cargo_toml_dep_not_found` detail now - says so and points to `--mode vendored`. A crate declared only with - requirements the patched version does not satisfy (cargo resolves those - declarations to another version) is refused - `redirect_cargo_toml_dep_unrewritable`, the Socket backend's code for the - same shape, instead of `redirect_cargo_toml_dep_not_found`. A project with - NO `Cargo.lock` has no resolved graph to ask, so a crate declared beside - any other dependency — anything but a path dependency on a manifest the - same run pins — or beside a workspace member this run did not read (a - glob, a member outside the project or behind a symbolic link) is refused - `redirect_cargo_lockless_dependents` (commit a lockfile, or use `--mode - vendored`); a project whose only dependency is the patched crate has - nothing that could pull it in and still redirects. -- **CRLF cargo projects redirect in hosted mode.** All-CRLF `Cargo.toml`, - `Cargo.lock` and cargo configs are rewritten with their endings kept - (they were refused), and `remove` / rollback still find the recorded - edits after a checkout converts the line endings. -- **Hosted cargo `remove` restores every byte, in any order.** An appended - registry block leaves the user's config exactly as it was (trailing blank - lines or a missing final newline included) and a created config is - deleted with the last block; v1-lock and multi-version patches, ledgers - written by older CLIs included, can be removed in any order; and a crate - declared with the same line in two sections gets both pins reverted. -- **yarn 4.0.x checksums keep the lock's own spelling.** Vendored and hosted - berry rewrites write bare-hex `cacheKey: 10c0` checksums when the lock - does, so `yarn install --immutable` no longer fails with YN0028. -- **npm 12 dual-lock projects vendor both locks.** `vendor` rewires a - `package-lock.json` npm 12 keeps beside a committed shrinkwrap (else warns - `vendor_npm_sibling_lock_unwired`), and hosted runs warn - `redirect_npm_allow_remote` (npm 12's `allow-remote=none` refuses - redirected tarballs unless `.npmrc` sets `allow-remote=all` — which hosted - mode now writes itself, see Added) and `redirect_npm_legacy_client` (npm 6 - ignores a v1 lock's `resolved`). - -- **Bun refusal safety:** hosted compatibility is checked before removing - an existing vendored patch, including during dry-run. Vendored preflight - exemptions require live local lock tuples; a ledger retained by - `rollback --preserve-state` cannot bypass a refusal or hide it in a preview. - Symlinked `bun.lockb` files are refused before patching so their links - survive, and `vendor --silent` keeps refusal diagnostics on stderr. - -- **Bun projects: every text-lock generation is accepted, vendored refusals - fire before any write, and every mode change unwinds.** `bun.lock` - `lockfileVersion` 0 — the opt-in text lock Bun 1.1.39–1.1.45 write with - `--save-text-lockfile` — joins 1 and 2 in the shared version gate, so hosted - mode redirects it (golden fixture `npm/bun/lock-v0`), vendored mode wires it - and the lockfile inventory discovers it; a newer version is now refused with - "update socket-patch" instead of a re-lock that would reproduce it. Workspace - locks are refused only where Bun cannot consume the rewrite: hosted mode - refuses a version-0 lock holding `workspace:` packages - (`redirect_bun_workspace_unsupported`; delete `bun.lock` and re-lock with - Bun ≥ 1.2, which writes version 1 — accepted; a plain in-place - `bun install` bumps the version only when a workspace depends on another - workspace, otherwise Bun 1.2.0 keeps version 0 and 1.2.23+ fail to - resolve) and vendored mode refuses any pre-version-2 workspace lock before - writing (`vendor_bun_workspace_unsupported` — Bun 1.2–1.3 resolve a - workspace member's local tarball path relative to the member; delete - `bun.lock` and re-lock with Bun ≥ 1.4, since an in-place `bun install` - keeps the existing version, or — for a version-1 lock — use hosted mode; a - version-0 lock is told to re-lock with Bun ≥ 1.2 first, since hosted - refuses it too), while purls already vendored (by the ledger at the - selected uuid, or with every matching lock tuple already pointing into - `.socket/vendor/`, so a superseding patch uuid re-pins in place), re-runs - and `repair` on such a lock keep working; a corrupt - `.socket/vendor/state.json` met by that preflight is reported as - `vendor_state_unreadable` rather than a Bun lock code. `scan --mode vendored`, - `get --mode vendored` (search and uuid paths) now - preflight the Bun lock BEFORE any download: a malformed binary, unreadable, - unsupported-version or pre-version-2 workspace lock marks the npm patches - `failed` with the vendor refusal code and detail, fetches nothing and - records no patch — the `scan` / `get ` path writes nothing under - `.socket/` and exits `partial_failure`, `get --mode - vendored` exits 1 with `status: "error"` and writes nothing — where - previously the record landed in the manifest and the vendor step failed - afterwards (and a detached run over an alias install misreported - `package_not_installed`). The refusals stay visible under `--silent` - (code-tagged stderr line), `--dry-run` previews them as the additive - `would_refuse` action (the human `scan` and `get` previews both print the - `[would-refuse]` lines). Valid binary locks are inventoried and patched - directly without a Bun runtime; malformed binary locks report - `bun_lockb_invalid`, `redirect_bun_lockb_invalid`, or - `vendor_bun_lockb_invalid` at the corresponding entry point. Hosted → vendored - takeover now works for bun — - `scan`/`get --mode vendored` and `vendor` over a hosted-redirected `bun.lock` - claim and replay that purl's hosted edit instead of refusing - `redirect_revert_failed`, and `vendor --dry-run` probes the takeover instead - of promising it — as do `rollback ` / `remove ` of one of - several hosted bun records; on a lock the vendored backend refuses (a - pre-version-2 workspace lock) `vendor` and its dry run report the refusal - BEFORE the hosted revert, leaving the purl hosted-patched instead of - un-hosting it and then refusing. Native `bun.lockb` edits preserve the - dependency graph and unrelated package metadata while updating binary - pointers, tarball integrity, and the package metadata hash. The hosted text - rewrite keeps CRLF on the rewritten `bun.lock` line. Real-Bun - coverage now runs in CI: the hermetic hosted and vendored suites on Linux, - macOS and Windows (Bun 1.4.2, plus 1.1.45 and 1.2.23 lock-era legs), and - the production native matrix — 16 releases from 0.8.1 to 1.4.2 in hosted - and vendored mode — on pull requests and `main` (rows - carry `cliRevision` and `cliBuildSha` provenance), with - the corrected digest boundary (Bun verifies URL/local tarball sha512 from - 1.3.10, not 1.3.14). Bun 1.1.39–1.3.9 also re-save a hosted URL or - vendored local-tarball tuple WITHOUT its `sha512` on any later lock - re-save (`bun add`, `bun install` after a manifest change); that - digest-less 2-tuple is now recognised as the CLI's own wiring — repeat runs - heal the digest, `repair` rebuilds through it, and `rollback`, scoped - `rollback` / `remove`, `vendor --revert` and both mode takeovers unwind it - to the registry line — where previously every re-save on those releases - left `redirect_bun_entry_not_found` beside `redirected: 1`, a - `partial_failure` rollback and `vendor_lock_entry_not_found` / - `vendor_lock_entry_drifted` refusals. See - `docs/testing/bun-compatibility.md` and `scripts/backtest-bun.py`. (#245) -- **Rollback after a Pipenv relock no longer refuses forever.** `pipenv lock` - (and `update`, and `install ` before 2024) regenerates a redirected - or vendored entry to registry shape on every Pipenv major; that is now the - desired end state — the hosted edit retires and the vendored record is - dropped (`vendor_lock_entry_relocked`) — instead of a permanent drift - refusal that held every pypi revert and kept the orphaned wheel dir. A - foreign `file`/`path` reference is still drift. -- **Same-run `--vex` attests lock-only pypi redirects.** The confirmed purl is - unqualified while the ledger records the API's artifact-qualified purl; - both sides now match on the qualifier-stripped purl, so a lock-only Pipenv - (or uv) checkout no longer exits 1 `no_applicable_patches` after - redirecting its lock. -- **The Pipenv installer probe runs only when a patch targets the lock**, warns - only when the lock was actually rewritten, resolves `pipenv` on absolute - `PATH` entries only (a relative entry would have executed a `pipenv` planted - in the scanned repository), finds `.bat`/`.cmd` shims on Windows, and takes - only the token after `version` (never a stray `Python 3.12` banner). -- Hosted Python redirects now warn when installed files still contain upstream - or modified bytes and omit those packages from same-run VEX. The read-only - probe covers Poetry virtualenvs, repeats on re-scans, and uses persisted patch - records if fetching fresh records fails. - -- **Agent mode finds Poetry's out-of-tree virtualenv.** Poetry keeps a - project's virtualenv under `{cache-dir}/virtualenvs/--py` - by default, so after a plain `poetry install` the crawler saw no - `VIRTUAL_ENV` / `.venv` / `venv` and fell through to the global interpreter: - `scan --mode agent` patched nothing for the project's dependencies (or the - wrong interpreter) while reporting success, and a bare `rollback` pruned the - manifest while the venv stayed patched. The crawler now reproduces Poetry's - own placement — `virtualenvs.create` / `in-project` / `path` and `cache-dir` - from `POETRY_*`, the project's `poetry.toml` and the user `config.toml`, the - platform default cache dir, and Poetry's env-name hash — without running - Poetry, and scans every `-py` sibling. `poetry run socket-patch …` and - `VIRTUAL_ENV` keep working as before. -- **`scan --mode vendored` works from a lock-only Poetry checkout.** The - `poetry.lock` inventory was discovery-only, so a fresh clone with nothing - installed was skipped with `vendor_fetch_unverifiable` even though the lock - records the wheel's sha256 (uv's lock vendored fine in the same scenario). - The inventory now carries the pure-Python wheel's sha256 from `files` (lock - 2.x) or `[metadata.files]` (lock 1.0/1.1), and the pypi fetcher resolves a - hash-only entry through PyPI's JSON API by that digest (verified again after - download; `SOCKET_PYPI_JSON_API` overrides the endpoint). Poetry 0.12's bare - `[metadata.hashes]` names no wheel and still needs an installed copy. -- **`remove` no longer drops the manifest entry of a drift-kept vendored - purl.** When the vendored revert keeps the artifact (`kept_artifact` — - the lockfile drifted), the manifest entry is now kept too - (`skipped`/`vendor_revert_kept`), matching the core RevertOutcome - contract; previously the entry was deleted, stranding a live ledger - entry with no backing record. An all-kept run exits 1 `partialFailure` - with `summary.removed: 0` (never `not_found` — the identifier matched). -- **Rebuilding a missing gem, maven or nuget vendored artifact now updates - the ledger.** When `vendor` / `scan --vendor` found a wired project whose - committed artifact was missing or broken, it rebuilt the artifact but kept - the old fingerprint in `.socket/vendor/state.json` (the gem file - inventory, the maven/nuget `sha256`, and the nuget `packages.lock.json` - pin). If the rebuild came from the other source (the patch service instead - of a local build, or the reverse), the new bytes no longer matched the - ledger. VEX and verification then reported the artifact as tampered, - `repair` could fail, and `vendor --revert` left `packages.lock.json` - pinned to the patched `contentHash`. A rebuild from the patch service was - also reported as `already_vendored` instead of `applied`. The rebuild now - records the new fingerprint and keeps the entry's original wiring records, - so revert still restores the pre-vendor files. If `state.json` has no - entry for the package, or only an entry from another patch uuid, the - rebuild still runs but the ledger is left as it is, because the run has no - pre-vendor originals to record. -- **A prebuilt maven `.jar`, nuget `.nupkg`, pypi wheel or npm tarball from - the patch service must now contain the patched files.** Checking its integrity hash only showed that - the download was intact, not that the archive carried the patch. The - archive was still written as-is and every file was reported as already - patched, so an unpatched archive could be committed and then rebuilt on - every run. Each patched file inside the archive is now checked against the - patch's expected hash before the archive is used. On a mismatch, `auto` - builds the archive locally and warns `vendor_prebuilt_layout_mismatch`, - and `--vendor-source=service` refuses with `vendor_prebuilt_required` (npm - fails the package with the detail). -- **A prebuilt artifact that fails its integrity check is always refused.** - Under the default `--vendor-source=auto`, npm, pypi, golang, composer and - gem (both the `.gem` and its stub gemspec) printed a warning and built the - package locally when the downloaded bytes did not match the integrity the - patch service reported. Bytes that fail verification may have been - tampered with, so these ecosystems now refuse the package in every mode, - as cargo, maven and nuget already did. The refusal code is - `vendor_prebuilt_integrity_mismatch` (npm fails the package with the - integrity detail). Under `--vendor-source=service`, golang, composer, gem - and pypi now report `vendor_prebuilt_integrity_mismatch` instead of - `vendor_prebuilt_required`. -- **`service` vendor source without an API client is refused.** This affects - `socket-patch-core` callers that pass a `VendorServiceConfig` with - `source: Service` and no `client` (the CLI always configures a client). - Every backend used to build the artifact locally in that case, even though - `service` promises that only the patch service's artifact is used. They now - refuse with `vendor_prebuilt_required` before doing any work, the same way - `--offline` is already refused. -- **Rebuilding a missing cargo vendored copy now honours - `--vendor-source`.** When a wired project's committed crate copy was - missing or stale, `vendor` always rebuilt it locally from the installed - source. Under `--vendor-source=service` it did so even with `--offline` - or without an API client, and reported success. The rebuild now uses the - patch service's prebuilt crate like a fresh vendor does, so `service` - mode refuses (`vendor_service_offline_conflict` / `vendor_prebuilt_required`) - when the service cannot be used, and `auto` still builds locally when it - has no prebuilt crate. - -### Changed - -- **The npm crawl skips tagged cache directories.** The walk that finds - workspace `node_modules` trees no longer descends into a directory that - carries a [Cache Directory Tagging](https://bford.info/cachedir/) - `CACHEDIR.TAG` beginning with the standard signature (every cargo - `target/` does), using the directory listing it already reads. One - semantic change: a `node_modules` inside such a directory, or anywhere - below it, is no longer crawled, so its packages are no longer scanned, - patched or attested. Every command that looks for installed npm copies - walks the same trees, so the change reaches past `scan`: `scan --prune` / - `--sync` treat a package installed only under a tagged directory as not - installed and garbage-collect its manifest entry and blobs (unless a - lockfile still resolves it), and `apply`, `rollback`, `remove`, `repair`, - `vendor` and `vex` no longer find copies there — so `remove` leaves such a - copy's patched files in place. The scan root itself is always crawled, and a - `CACHEDIR.TAG` without the signature (or that is a directory or a - symlink) prunes nothing. On a Rust-plus-JS monorepo this skipped 57% of - the walked directories. See docs/ecosystems.md. - -- **`scan` sends up to 32 patch-API requests at once on the authenticated - API, up from 8.** Each step sizes its window from the requests it has to - make: a quarter of them, between 8 and 32 — the batch queries, the - per-package patch lists, the hosted and vendored record views, discovery's - baseline views and `get`'s views. A step with 32 or fewer requests still - runs 8 at once; one with 128 or more runs 32. The fixed-size windows - follow the new cap up to their own ceilings: `vex` / `scan --vex` record - fetches now run up to 10 at once (was 8), wheel metadata stays at 4 and the - vendored archive prefetch at 4. The public proxy stays at 4. - `SOCKET_API_CONCURRENCY=` still overrides the adaptive cap (1-32; on - the proxy it can only lower it); the fixed windows keep their own ceilings - on top of it. Output is unchanged — every window folds its - answers in request order — but a large monorepo's hosted scan at 100 ms of - latency drops from ~20 s to ~9 s. - -- **`scan` queries the authenticated API 500 packages per batch, up from - 100.** Unset, `--batch-size` / `SOCKET_BATCH_SIZE` now follows the - endpoint: 500 purls per `POST /v0/orgs/{org}/patches/batch` (the server's - own per-request maximum) and 100 per `POST {proxy}/patch/batch` on the - public proxy, as before. A given size still applies as-is on either - endpoint. A batch whose JSON body would pass 256 KiB (the public proxy's - body cap) is now split, deterministically, into consecutive smaller - batches; at the default sizes that takes purls averaging over ~500 bytes. - A run downgraded to the proxy mid-run keeps its chunks, so it can send - the proxy batches of up to 500 purls (within the proxy's 256 KiB cap and - its upstream's 500-purl limit). Output is unchanged; the request count - and shape change — a large monorepo sends 30 batch requests instead of - 147 (depscan: 12 instead of 56), and `api_batch_failed` warnings number - the larger batches (`API batch 2 of 12 failed: …`). - -- **The crawl's directory walks run on 4 threads by default.** The walk - pool behind the `node_modules` walk and the Maven repository walk (and its - POM parse) used one thread per logical CPU (up to 16), but the walk is - bound by the kernel's directory cache: on a 14-core Mac and on Linux - ext4, 4 threads walked a large monorepo's `node_modules` as fast as or - faster than one per CPU, with a quarter of the system time (see - `walk_pool.rs` for the measurements; the Maven walk shares the pool and was - not measured separately). The default is now 4, or the performance-core - count when that is lower (`hw.perflevel0.logicalcpu` on Apple silicon). - `SOCKET_WALK_THREADS=` overrides it, clamped to 1-16 and to the CPU - count. What the crawl finds, and its order, are unchanged. - -- **Maven discovery takes coordinates from the `~/.m2` path.** A POM at its - canonical `///-.pom` - location is no longer opened: its groupId / artifactId / version come from - the directory names, which is where Maven itself writes every POM, so the - crawl skips reading and parsing tens of thousands of files. The path only - spells the right group when the scan root is the repository root, so each - top-level group directory (`org/`, `com/`, ...) is confirmed first: the - first canonical POM under it whose contents parse must agree with its - path, and a directory whose first such POM disagrees — every one of them - when `--global-prefix` / `SOCKET_GLOBAL_PREFIX` / `MAVEN_REPO_LOCAL` points - one level above or inside the repository — is read content-first exactly - as before. Other `.pom` files (timestamped SNAPSHOT POMs, hand-placed - extras, a dotted group directory) are parsed as before. One semantic - change: under a confirmed directory, a POM at a canonical path whose - contents disagree with its directory (hand-placed, or a legacy upstream - POM with mismatched coordinates) now reports the directory's coordinates - instead of the ones in the file. -- **`scan --ecosystems` crawls only the named ecosystems.** Without - `--prune`/`--sync`, a `scan -e npm` no longer walks `~/.m2`, the cargo - registry, the Go module cache and the rest only to filter their packages - away. What the run counts, queries and shows is unchanged - (`scannedPackages` already counted only the selected ecosystems), with - one exception: `lockfileOnlyPackages` and the human "not yet installed" - note now count only the selected ecosystems' lockfile-only entries - instead of every ecosystem's. A GC run (`--prune`/`--sync`) still crawls - every ecosystem — the prune needs the full installed set — and reports - exactly what it did before. - -- **An already-vendored project re-runs `vendor` without the network.** A - vendorable purl with no installed copy (the fresh-clone case) used to have - its pristine artifact downloaded and verified before the backend was even - asked, although the backend's in-sync check answers from the committed - artifact alone. That download is now deferred to the backend branch that - actually reads the pristine tree, whenever the vendor ledger already - covers the purl (its entry records the record's patch uuid and the - committed artifact is on disk — a file artifact such as a wheel or - tarball only while it still hashes to the ledger's `sha256`; `--force` - keeps the eager fetch), and for every lockfile-only cargo crate the - registry could fetch and verify (a crates.io `Cargo.lock` entry with a - checksum, or the pre-vendor resolution the ledger recovers) while the - patch service is enabled (the cargo backend reads the pristine source - only once `cargo_service_copy` falls back to the local build). A git, - path or custom-registry crate is never deferred: it keeps the eager - ladder's `vendor_fetch_unverifiable` + `package_not_installed` refusal - and is not vendored from the service's crates.io build, and a committed - file artifact that no longer matches its pin keeps the eager ladder's - outcome too. Visible effects: an idempotent re-run - makes no registry requests and no longer reports `vendor_fetched_missing` - for fetches it never needed; with no network (or under `--offline`) the - re-run of an already-vendored pypi, cargo, go or lockfile-only gem - project now SUCCEEDS (`already_vendored`, exit 0) instead of failing - `vendor_fetch_failed` / `package_not_installed`; a cargo crate the service - serves is never downloaded from the registry. When a deferred fetch does - happen (a drifted committed copy being rebuilt locally, a service miss), - its `vendor_fetched_missing` warning is recorded just ahead of that - package's own event instead of in the up-front fetch pass, and a failed, - unverifiable or `--offline`-refused deferred fetch reports exactly the - eager ladder's outcome for the purl (`vendor_fetch_failed` / - `vendor_fetch_unverifiable` + `package_not_installed` / - `package_not_installed`), in loop order. `vendor --vendor-source build` - (or no service config) now refuses a not-installed gem that the lock can - verify and no ledger entry covers with `gem_spec_missing` BEFORE - downloading it: a local build can never vendor a downloaded `.gem` (no - eval-able stub gemspec), so the download was pure waste. The refusal - keeps the backend's detail text and drops the `vendor_fetched_missing` - warning that used to precede it; since it now comes first, a gem the - backend would have refused for another reason after the download (an - uneditable Gemfile declaration, a Gemfile.lock it cannot edit) also - reports `gem_spec_missing`. `--dry-run` is unchanged: it still fetches - the gem and previews it (`vendor_fetched_missing` + `verified`). - -- **The vendor ledger stores a whole-file snapshot's new text as an edit.** - Several backends record an entire file as a wiring record's `original` / - `new` (maven's `pom.xml`, nuget's config, `pylock*.toml`, PEP 723 scripts - and hatch's project files), so a ledger held two near-identical copies of - that file per vendored package. In `.socket/vendor/state.json` such a - record's `new` text of 1 KiB or more is now written as a line-level edit - of the same record's `original`: `{"snapshot": "", - "ops": [[start, len] | "inserted text", …]}` (a `[start, len]` pair copies - that byte range of the `original`, a string inserts itself), and the - ledger's `"version"` is `2`. The `original` stays the plain string it - always was, every lockfile fragment record (poetry, composer, npm, …) is - untouched, and a ledger without such a record keeps its version-1 bytes. - Every command reads both versions: version-1 ledgers load and revert - exactly as before, and a version-2 edit is rebuilt and checked against its - hash (an edit that does not reproduce its text, has no `original` to - apply to, or any other `{"snapshot": …}` value, is - `vendor_state_unreadable`). Revert, repair, `vex`, rollback and the - re-vendor carry-forward see the same full texts as before. Each record is - self-contained, so an older socket-patch that re-saves the ledger (it - keeps `original` / `new` verbatim) loses nothing; reading a version-2 - record it leaves that fragment alone with its drift warning. No consumer - outside socket-patch reads `state.json` wiring. - -- **A vendored run commits its lockfile and ledger edits once, not per - package.** `vendor`, `scan --mode vendored` and `get --mode vendored` used - to rewrite every touched lockfile / `package.json` / `pnpm-workspace.yaml` - / config and the whole `.socket/vendor/state.json` after EACH package. The - run now captures those edits in memory (every backend still reads its own - and its siblings' earlier edits) and writes the final state once, after - the loop, through a roll-forward journal - (`.socket/vendor/.commit-journal.json`, removed when the commit - completes). The packages that succeeded are committed even when others - failed, so a completed run leaves exactly the files per-package commits - left. Crash semantics move from per-package to per-run: a crash before - the commit leaves the project's lockfiles and ledgers as they were before - the run (the artifacts it wrote are orphans the next run re-vendors over); - a crash during the commit is finished by the next command that takes the - apply lock, before it reads any of those files. When a file the journal - covers was edited since, that file is never written over and the journal - is set aside (`.commit-journal.set-aside-.json`, which keeps every - file's pre-commit bytes; a stderr warning names `repair`): if the edited - files still carry the commit's own lines the rest of the commit is - finished around them (so the ledger records the wiring on disk), if none - of them does the files the crash had already replaced are put back to - their pre-commit bytes, and otherwise nothing is applied. A journal that - would write through a symbolic link, or outside the lockfiles and - ledgers, is set aside unapplied. A replay that fails on I/O keeps the - journal and fails the command's lock acquire (`lock_io`) rather than - letting it work over a half-committed project. A re-vendor under a newer - patch uuid now removes the replaced uuid's artifact dir after the commit, - so its `vendor_stale_artifact_removed` event comes after the run's - per-package events instead of right after the package's own; a golang - takeover likewise deletes the `.socket/go-patches/` copy only after the - commit that repoints `go.mod` away from it. A commit that cannot be - written fails the run with the new top-level error `vendor_commit_failed` - (exit 1), leaving the pre-run lockfiles and ledger in place — or, when - putting back the files already replaced failed too, keeping the journal - (the message says so) for the next locked command to finish the commit. - `repair`, `vendor --revert` and `rollback` keep their per-entry saves. - -- **Vendored artifacts are no longer fsynced one by one.** The files a - vendored run produces under `.socket/vendor///` — patched copy - trees, the `.tgz` / `.whl` / `.nupkg` / `.jar` + `.pom` artifacts and their - `.sha1` sidecars, and the marker — are still written atomically (stage + - rename) but without their own `fsync`/`F_FULLFSYNC`. One durability - barrier syncs every such file and, once per directory, their directories - (with a single `F_FULLFSYNC` per device on macOS) before the next durable - commit point — a lockfile, `go.mod`/`go.sum`, `pom.xml`, `nuget.config`, - `package.json`, `pnpm-workspace.yaml`, the vendor ledger or the redirect - ledger — is written, so nothing durable ever names an artifact that could - still be lost. An artifact rebuilt in place that no commit point follows - (a drifted committed artifact healed with the lockfiles and ledger - unchanged) is synced by the same barrier at the end of the vendored run's - commit and when the command releases the apply lock, so no command - returns with an unsynced artifact the committed state names; a failed - barrier keeps its files pending for the next one. A crash can at worst - lose an artifact nothing durable names yet, which the next run rebuilds - (see `socket_patch_core::utils::durability` for the full argument). The - in-place `apply` of an installed tree keeps its per-file durable writes. - -- **Release publishing decomposed into per-registry workflows.** The - crates.io, npm, PyPI, and RubyGems legs of the `Release` workflow now live - in their own workflows - (`.github/workflows/publish-{cargo,npm,pypi,rubygems}.yml`). A release is - still one dispatch — `release.yml` dispatches each leg at the release tag - (`scripts/dispatch-publish.sh`) and watches it to completion — but after a - mid-release failure any single registry can now also be retried - standalone (Actions → the registry's publish workflow → Run workflow with - the release version) without rebuilding: each leg checks out the - `v` tag and, where binaries are needed, takes them from the - GitHub release's assets verified against `SHA256SUMS` (release-run - dispatches additionally pin the sums file by digest, and same-version leg - runs serialize through a concurrency group). Registry-side - trusted publishers must be re-registered against the new workflow - filenames (the legs always run as top-level `workflow_dispatch` runs, so - every registry's filename matching sees the leg's own file) — see - docs/releasing.md § One-time registry setup. +- Patch application, reversal, and cleanup handle missing files, release variants, + corrupt state, newer ledger formats, and unsafe manifest paths without silently + dropping protection. File ownership restoration failures produce warnings. +- Hosted Cargo handles v1 locks, CRLF files, and repeated declarations, and refuses + transitive dependencies its registry pin cannot reach. Vendored Cargo preserves + multiple versions and warns about old-toolchain limitations. +- Go preserves user-authored replacements, handles `+incompatible` versions, + restores checksums, and unwinds hosted references during vendoring. Registry + fetches honor `GOPROXY` and private-module settings. +- Yarn Berry preserves supported line endings and checksum spellings. Mode + preflights, including Bun's, run before discarding existing protection. +- Composer hosted references remove upstream source fallbacks and mirrors; + RubyGems hosted locks preserve source order; NuGet edits use the active config + and survive `` entries. +- Python rewrites preserve supported markers, groups, extras, source metadata, and + integrity pins. Relocks, out-of-tree environments, and lock-only VEX are handled + consistently with each installer's supported behavior. +- Vendoring reuses valid committed artifacts during service outages. Updates do + not build from a previous patch's modified bytes. Verified service artifacts + keep their identity; integrity failures do not fall through to a local rebuild. + Repair rebuilds against recorded pins and reports unavailable inputs. +- API throttling uses bounded retries, failed queries appear in JSON diagnostics, + and hosted reference resolution handles batches larger than 500 patches. +- Transient apply locks are removed on normal command exit; no-op scans and full + reversal avoid leaving unused `.socket/` state. Terminal output, telemetry + timeouts, and update-check handling are more consistent. + +### Maintenance + +- Shared format models, project snapshots, ledger views, and a vendored backend + consolidate discovery and lifecycle handling. Grouped writes and bounded + concurrency reduce repeated disk and network work. +- CI reuses compiled test binaries and splits broader compatibility matrices into + dedicated jobs. Release publishing uses separate Cargo and npm workflows. +- Documentation now separates usage, configuration, migration, compatibility, and + development guidance; completed plans, prototype research, and historical run + reports are removed from the maintained docs. ## [4.0.0] — 2026-08-20 @@ -1899,7 +238,7 @@ plain (agent), `(vendored)`, and `(redirected)` (hosted). partial writes) on missing hashes, an out-of-namespace module path, a require-version mismatch, or a user-authored replace conflict; references without the override keep the historical `redirect_golang_unsupported` - warning (paid tier stays vendored — see `docs/design/golang-hosted.md`). + warning (paid tier stays vendored — see `docs/ecosystems.md#go-directory-replaces-and-gosum`). Wire schema gains `integrity.goModH1` and `registryOverride.identifiers.goModuleVersion` (additive). Requires server-side publication of the grant-free `gopatch` artifact flavor — @@ -1954,7 +293,7 @@ plain (agent), `(vendored)`, and `(redirected)` (hosted). warns once on stderr and is ignored; `--json` stdout is unaffected. The telemetry endpoint now resolves the API base through the same chain as client construction, so a config-supplied `apiBaseUrl` applies to both. - Design notes: `docs/design/configuration.md`. + Design notes: `docs/configuration.md`. - **Hosted patch mode: `scan --mode hosted` (a.k.a. the hidden `--redirect`).** @@ -2103,7 +442,7 @@ plain (agent), `(vendored)`, and `(redirected)` (hosted). config; Go's module-path identity would force per-grant artifacts against the build-once converter; and the default `GOPROXY` chain would leak licensed bytes / tokened URLs to the public mirror. The full analysis lives - in `docs/design/golang-hosted-no-go.md`; both the CLI rewriter and the + in `docs/ecosystems.md#go-directory-replaces-and-gosum`; both the CLI rewriter and the depscan backend twin emit `redirect_golang_unsupported` naming the remedy (use vendored mode, which gives Go everything hosted promises elsewhere). The one sanctioned exception — an ephemeral-CI GOPROXY recipe — is @@ -2480,7 +819,7 @@ and regression tests were added throughout (the lib + integration suites grow by ### Tests -- New `tests/telemetry_e2e.rs` end-to-end behavioral coverage: +- New `tests/cli/telemetry_e2e.rs` end-to-end behavioral coverage: apply/scan/get/list emit telemetry against a wiremock recorder; `SOCKET_OFFLINE=1` produces zero telemetry POSTs across all four; scan falls back on 401 + tags the resulting event; scan does NOT diff --git a/Cargo.lock b/Cargo.lock index 7b687168e..efa5fd8b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,17 @@ dependencies = [ "memchr", ] +[[package]] +name = "annotate-snippets" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f211a51805bc641f3ad5b7664c77d2547af685cc33b4cd8d31964027a46f13f1" +dependencies = [ + "anstyle", + "memchr", + "unicode-width", +] + [[package]] name = "anstream" version = "0.6.21" @@ -73,6 +84,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -122,6 +139,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -266,6 +293,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -404,6 +437,29 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "encoding_rs_io" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fba3fe847045ecff794b9c138293a80db914678c453ad63fbf0c6a9eb6e00b22" +dependencies = [ + "encoding_rs", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -628,6 +684,29 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +[[package]] +name = "globset" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "granit-parser" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e20f99e46474f56bd905c56e817ebddcf377a611f94c53ac4649e4d3fa3c0cd0" +dependencies = [ + "arraydeque", + "smallvec", +] + [[package]] name = "h2" version = "0.4.14" @@ -896,6 +975,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -1040,6 +1135,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "napi" version = "3.13.0" @@ -1390,9 +1491,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -1589,6 +1690,20 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-saphyr" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8050abb251097357e24aff63ba2c52a6309ecb7d23a5474023df960a02694d8" +dependencies = [ + "annotate-snippets", + "encoding_rs_io", + "granit-parser", + "num-traits", + "serde_core", + "smallvec", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -1742,6 +1857,12 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.12" @@ -1769,6 +1890,7 @@ dependencies = [ "hex", "libc", "portable-pty", + "qbsdiff", "regex", "reqwest", "semver", @@ -1798,6 +1920,7 @@ dependencies = [ "fs2", "futures-util", "hex", + "ignore", "libc", "once_cell", "qbsdiff", @@ -1808,6 +1931,7 @@ dependencies = [ "self-replace", "semver", "serde", + "serde-saphyr", "serde_json", "serial_test", "sha1", @@ -1816,6 +1940,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", + "tokio-util", "toml_edit", "uuid", "walkdir", @@ -1832,7 +1957,6 @@ dependencies = [ "napi-derive", "serde", "serde_json", - "socket-patch-cli", "socket-patch-core", "tokio", "tokio-util", diff --git a/Cargo.toml b/Cargo.toml index b985c7005..2d241db05 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,8 @@ serial_test = "=3.4.0" napi = { version = "=3.13.0", features = ["napi8", "tokio_rt"] } napi-derive = "=3.6.9" napi-build = "=2.5.0" +serde-saphyr = { version = "=1.3.0", default-features = false, features = ["deserialize"] } +ignore = "=0.4.33" [profile.release] strip = true @@ -67,7 +69,7 @@ opt-level = "s" # CI-only profile for the test-release job. Inherits the shipped profile's # semantics (opt-level = "s", debug-assertions off, overflow-checks off) — -# which is what test-release exists to validate (commit b96a13f) — but drops +# which is what test-release exists to validate — but drops # the full-LTO link: ~23m of that job's ~29m was LTO-relinking 159 test # binaries, and LTO relinks are structurally uncacheable. release.yml still # builds the real full-LTO [profile.release] for every shipped target. @@ -84,9 +86,8 @@ lto = "thin" # Test-execution speed: `cargo test` builds dependencies with the dev # profile; at opt-level 0 the hash/compression/bsdiff hot loops are 10-100x -# slower (the self_update fixture family measured 403s debug vs ~2s release -# on CI run 30289993021 — it gzips and sha256-hashes the multi-MB debug CLI -# binary per test). Workspace members are NOT matched by "*" — they stay +# slower (the self_update fixtures gzip and sha256-hash the multi-MB debug +# CLI binary per test: ~403s debug vs ~2s release). Workspace members are NOT matched by "*" — they stay # opt-level 0, so incremental compile speed, debugging, and llvm-cov line # fidelity (reports filter to workspace crates) are unchanged. Dependencies # keep debug-assertions and overflow-checks ON — only codegen opt changes. diff --git a/README.md b/README.md index 4b77c0322..e74246412 100644 --- a/README.md +++ b/README.md @@ -1,1447 +1,140 @@ -# Socket Patch CLI +# Socket Patch -Fix known vulnerabilities in the dependencies you already have — without waiting for an -upstream release, and without a risky version bump. +Apply security fixes to the dependency versions your project already uses. +Socket provides patches for specific package versions so you can address known +vulnerabilities without waiting for an upstream release or upgrading the dependency. -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. +The default workflow is **scan → install → vex**: -**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) +- `socket-patch scan` finds available patches and updates your dependency files to + use Socket-hosted patched packages. +- Your package manager installs those packages using the updated references and + integrity pins. Commit the files that `scan` reports. +- `socket-patch vex` produces an OpenVEX document describing the vulnerabilities + addressed by those patches. -## Installation - -One-line install (macOS / Linux): - -```bash -curl -fsSL https://install.socket.dev/patch | sh -``` - -Detects your platform (macOS/Linux, x64/ARM64), downloads the latest binary, verifies it -against the release's `SHA256SUMS`, and installs to `/usr/local/bin` or `~/.local/bin`. -Use `sudo sh` instead of `sh` if `/usr/local/bin` requires root. Pin a version with -`SOCKET_PATCH_VERSION=3.3.0 sh` instead of plain `sh`. - -On a network that blocks or distrusts `github.com`, set `SOCKET_PATCH_BASE_URL` so the -archives come from Socket too — `install.socket.dev` relays them from the GitHub release, -checksums included: - -```bash -curl -fsSL https://install.socket.dev/patch \ - | SOCKET_PATCH_BASE_URL=https://install.socket.dev/patch/SocketDev/socket-patch/releases sh -``` - -`install.socket.dev` serves a copy of [`scripts/install.sh`](scripts/install.sh) from -this repository — read it before you run it, either there or at -[install.socket.dev/patch](https://install.socket.dev/patch). If you would rather not -depend on the Socket domain, `curl -fsSL -https://raw.githubusercontent.com/SocketDev/socket-patch/main/scripts/install.sh | sh` -does the same thing from the same bytes. See -[docs/installer-hosting.md](docs/installer-hosting.md) for how the hosted copy is -published. - -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` | - -The gem package is a thin launcher: 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. - -
-Manual download - -Download a prebuilt binary from the [latest release](https://github.com/SocketDev/socket-patch/releases/latest): - -```bash -# macOS (Apple Silicon) -curl -fsSL https://github.com/SocketDev/socket-patch/releases/latest/download/socket-patch-aarch64-apple-darwin.tar.gz | tar xz - -# macOS (Intel) -curl -fsSL https://github.com/SocketDev/socket-patch/releases/latest/download/socket-patch-x86_64-apple-darwin.tar.gz | tar xz - -# Linux (x86_64) -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-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). - -
- -### Updating - -If you installed via the one-liner or a manual download, the CLI updates itself: - -```bash -socket-patch --update # latest release (--update 3.4.0 pins a version) -``` - -It downloads the release for your platform, verifies its SHA-256 against the -published `SHA256SUMS`, and atomically swaps the binary in place. Package-manager -installs are detected and pointed at their own upgrade command instead (e.g. -`npm update -g @socketsecurity/socket-patch`). When a newer release exists, -interactive runs print a once-a-day reminder on stderr — set -`SOCKET_NO_UPDATE_CHECK=1` to turn that off. - -## Five-minute tutorial - -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: - -```bash -cd your-project -socket-patch scan -``` - -`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 -socket-patch list -``` - -``` -Found 1 patch: - -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 -socket-patch setup # e.g. adds a postinstall script for npm projects -git add .socket package.json # npm example — setup prints which files it changed -git commit -m "apply Socket security patches" -``` - -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 -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. - -## 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` | Agent mode: the record of downloaded patches — PURLs, file hashes, vulnerability metadata ([format](#manifest-format)) | -| `.socket/blobs/` | Agent mode: patched file contents, named by git-sha256 hash | -| `.socket/vendor/` | Vendored package artifacts and the vendor/redirect ledgers — the **only** state vendored and hosted modes write (the vendor ledger embeds the patch records; neither mode touches `manifest.json`) | - -> While a command runs it holds a transient advisory lock, `.socket/apply.lock`, and -> removes it when it finishes — the file never outlives the command, so there is nothing -> to `.gitignore`. A crashed run can leave one behind; the next command reclaims and -> removes it. Nothing in the table is written until there is something to record: a -> report-only `scan`, a `--dry-run`, or a run that changes nothing leaves no `.socket/` at -> all, and a full [`rollback`](#rollback) removes everything it created (only the -> zero-patch `manifest.json` and any [`setup`](#setup) files stay). - -### 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/` (with a ledger that embeds the patch records — `scan --mode vendored` writes no manifest; the standalone `vendor` command is manifest-driven and keeps only a fallback copy in the ledger); 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 — [`rollback`](#rollback) replays its recorded pre-redirect originals to unwind the redirect, see [Undo things](#undo-things), and [`vex`](#vex) uses its records offline; `vex` also works from the rewritten lockfile alone) | 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. - -### npm compatibility (hosted mode and npm 12) - -npm 12 defaults to `allow-remote=none` and refuses (`EALLOWREMOTE`) any lockfile -entry whose tarball is not served by your configured registry — which is what a -hosted redirect writes. So when `scan --mode hosted` (or `get --mode hosted`) leaves a -`package-lock.json` / `npm-shrinkwrap.json` pointing at `patch.socket.dev`, it also -writes `allow-remote=all` to the project `.npmrc` (creating it, or appending one line -and keeping everything else byte-for-byte) and warns `redirect_npm_allow_remote`. -Commit the `.npmrc` with the lock; a plain `npm ci` then installs the patched -packages on every npm from 7 to 12. The tradeoff: `allow-remote=all` lets npm install -**any** URL-resolved dependency, not just Socket's patched ones — the per-entry sha512 -integrity pins are still enforced. An explicit `allow-remote=none` / `root` of yours is -never changed or overridden — whether it sits in the project `.npmrc`, in your user -(`~/.npmrc`), global or builtin npm config, or in an `npm_config_allow_remote` -environment variable (the warning names where it found it and how to install anyway), -`--no-npm-allow-remote-config` -(`SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG`) turns the write off (install with -`npm ci --allow-remote=all` instead), and `rollback` / `remove` / switching to vendored -mode remove exactly the line or file the run added. Vendored mode needs none of this: -npm treats its `file:` tarballs under `allow-file`, which defaults to `all`. See -[npm compatibility](docs/testing/npm-compatibility.md) for the tested majors. - -### Bun compatibility - -Both text `bun.lock` and binary `bun.lockb` support hosted and vendored -patches, mode switching, repair, and rollback. Binary locks are read and -patched natively: Socket Patch does not need Bun installed to discover or -rewrite them, and does not convert them to text. If both filenames exist, -`bun.lock` takes precedence. See [Bun compatibility](docs/testing/bun-compatibility.md) -for the tested versions, workspace behavior, and installer integrity limits. - -### vlt compatibility - -[vlt](https://www.vlt.sh) projects (`vlt-lock.json`) work in agent and hosted mode on -every vlt release from 0.0.0-1 to 1.2.0 (both DepID grammars and every -`lockfileVersion`), and in vendored mode on locks with `lockfileVersion` 0 or 1 -(0.0.0-19 and later); older locks are refused with `vendor_lockfile_version_unsupported`. -Agent mode patches each copy in `node_modules/.vlt` without writing through vlt 1.2's -shared store. Hosted mode repoints the patched nodes' integrity and URL, first checks -that each artifact is served the way vlt can verify, and removes stale installed copies -so the next `vlt install` fetches the patched packages. Vendored mode commits a patched -package directory for each direct dependency of the root or a workspace member -(transitive dependencies need hosted mode), including one vlt gave a single peer -context; after vendoring an optional dependency run `vlt ci`, because a plain `vlt -install` keeps its installed upstream copy. vlt is detected ahead of every other -npm-family package manager. vlt ledgers require the socket-patch release that adds vlt support. -See [vlt notes](docs/ecosystems.md#npm-vlt-notes) for the caveats (`vlt update`, optional -dependencies, registry configuration) and [vlt compatibility](docs/testing/vlt-compatibility.md) -for the tested releases. - -### Pipenv compatibility - -Hosted mode rewrites every `Pipfile.lock` category that pins the patched -release (`default`, `develop`, and Pipenv 2022+ named categories) and -preserves the Pipfile, its content hash, markers, extras, and unrelated lock -entries, so `pipenv install --deploy`, `pipenv sync` and `pipenv verify` keep -passing. The reference shape follows the installing Pipenv: releases 7–11 -need `path` references, 2018 and later use `file` references, and lock -formats before `pipfile-spec: 6` (Pipenv 0–6) are refused without changing -the lock. Socket Patch probes `pipenv --version` once per run (only when a -patch targets the lock); `SOCKET_PIPENV_MAJOR=` pins the answer for -machines without pipenv on PATH. Hosted references carry both the `#sha256=` -URL fragment (verified by Pipenv 2023+) and a `hashes` entry (verified by -2018–2022; Pipenv 11 verifies either), so a tampered lock fails to install -on every supported release. - -Vendored mode requires Pipenv 2018 or later. Wheels with extras use `path` -references to avoid Pipenv 2022's local-file URL parsing bug. Pipenv 2023+ -does not enforce hashes on local wheels; commit the wheel and run -`socket-patch vex --product ` (a Pipfile names no project, so pass the -product purl explicitly). +Use `socket-patch vendor` to put the selected patched packages in your repository +when installs must work without Socket's patch server. -Fresh checkouts work in every mode: a clone with only `Pipfile` + -`Pipfile.lock` is discovered from the lock (hosted redirects it, vendored -fetches the pristine wheel by one of the lock's recorded digests), and agent -mode finds Pipenv's default out-of-tree virtualenv under `WORKON_HOME` -without `pipenv run`. +> This branch documents the v5 prerelease. Installation commands below select the +> latest published release, which may have different behavior. To try this branch, +> [build from source](docs/development.md#build). Existing users should read the +> [v5 migration guide](docs/migrating-to-v5.md). -Pipenv never reinstalls a release that is already present: `pipenv install`, -`pipenv install --deploy` and `pipenv sync` all exit 0 and keep the installed -bytes, on every Pipenv major. A hosted or vendored rewrite therefore -protects fresh installs, and Socket Patch warns -(`redirect_pypi_stale_install` / `pypi_pipenv_stale_install`) when a venv -still holds the upstream release, with the verified remedy: -`pipenv run pip uninstall -y && pipenv sync` (or `pipenv --rm && -pipenv sync`). Do not use `pipenv uninstall ` for this — it rewrites the -Pipfile and re-locks the patch away. `pipenv lock` / `pipenv update` -regenerate the entry to its registry reference (a silent unpatch): re-run -Socket Patch afterwards; `rollback` retires the stale record cleanly. - -`scripts/backtest-pipenv.py` drives the real CLI and the last stable release -of every published Pipenv major through hosted, vendored, agent and -out-of-tree agent mode, and `docs/testing/pipenv-compatibility.md` holds the -measured boundaries and results. - -## 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) -``` - -### Patch one specific CVE, advisory, or package - -```bash -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" -``` - -`socket-patch ` with a bare patch UUID is a shortcut for `get `. - -### Keep patches applied across installs - -```bash -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 -``` - -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. - -### Persist patches with no CI or install-hook changes (vendored / hosted) - -```bash -# Vendored: commit the patched packages themselves (airgap-friendly) -socket-patch scan --json --mode vendored --yes -git add .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 .npmrc # .npmrc: npm 12 allow-remote -``` - -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 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), including how to supply -`SOCKET_API_TOKEN` for org-tier patches. - -### Tell your vulnerability scanner about the patches - -```bash -socket-patch vex --output socket.vex.json -grype --vex socket.vex.json # or trivy image --vex ... -``` - -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). - -### Work offline / airgapped - -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) | **Fully unpatches, in every mode**: restores the original file bytes, unwinds vendored and hosted lockfile wiring, removes the rolled-back entries from the manifest (a zero-patch `{"patches": {}}` husk stays) and garbage-collects their blobs — everything, or just the given targets; `--preserve-state` keeps the local patch state for a later re-apply | -| [`remove`](#remove) | The single-patch dual of `rollback`: everything `rollback ` does for one PURL/UUID (restore, unwind its vendoring or hosted redirect, drop the entry, GC), plus `--skip-rollback` to drop only the record — **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, and cleans up unused ones | - -And `setup --remove` reverts the install hooks that `setup` added. - -> Hosted mode is unwound by [`rollback`](#rollback), which replays the original -> lockfile / registry-config fragments recorded in `.socket/vendor/redirect-state.json` -> and drops the redirect records. If you revert a hosted edit by hand instead (e.g. -> `git checkout -- `), also delete that ledger — its recorded originals are -> then stale. (A leftover ledger no longer makes [`vex`](#vex) attest the removed -> redirects: a record attests only while a lockfile still wires its hosted patch, and is -> otherwise omitted as `redirect_unwired`.) - -## 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) | Fully unpatch everything (or the given targets) in every mode and drop the rolled-back manifest entries (`--preserve-state` keeps them) | -| [`get`](#get) | Fetch and apply a patch by UUID / CVE / GHSA / PURL / name (alias: `download`) | -| [`list`](#list) | List recorded patches: manifest entries plus vendor-ledger and redirect-ledger records | -| [`remove`](#remove) | Remove a patch: roll back files + delete the manifest entry | -| [`repair`](#repair) | Download missing blobs, rebuild vendored artifacts, clean up unused ones (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** — with one extra fallback layer for the three authentication -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. 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. | -| `--download-mode ` | `SOCKET_DOWNLOAD_MODE` | Artifact to fetch when local files are missing: `diff` (default, smallest delta) 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, 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"`, `"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. | -| `-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 the commands that take the lock — `apply`, `rollback`, `repair`, `remove`, `vendor`, `setup` (while persisting `--exclude`), and `scan`/`get` whenever they write (agent-mode download + apply, vendored, hosted). The lock file exists only while a command runs. | -| `--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).) - -One more env-only knob tunes *pacing* rather than routing. `scan` queries the patch API -with several requests in flight: against the authenticated endpoint, a quarter of the -requests a step has to make, between 8 and 32 (so a step with 128 or more requests runs -32 at once, one with 32 or fewer runs 8); against the public proxy, which shares one -server-side limit across anonymous callers, 4. The patch-record fetches behind `vex` and -`scan --vex` run up to 10 at once (4 on the proxy). -`SOCKET_API_CONCURRENCY=` overrides that, clamped to `1`-`32`; on the public proxy it -can only lower it. Set it when an endpoint in front of the API caps in-flight requests -per client — a self-hosted `--api-url`, a corporate reverse proxy, a WAF or a CDN — and -a scan starts reporting fewer patches than it should because some requests are being -rejected. `SOCKET_API_CONCURRENCY=1` sends one request at a time, the slowest and most -conservative setting. An unset, empty or non-numeric value leaves the defaults in place. - -A throttled patch API is retried, within bounds. An HTTP `429` or `503` answer to any -patch-API query (batch search, patch lists, patch views, VEX record fetches, hosted -package references) is retried up to 3 times, waiting as long as the server's -`Retry-After` asks (seconds or an HTTP date; a request asked to wait more than 30 s gives -up at once) or, without one, 0.5 s, 1 s, 2 s with jitter. All retries in one run must -finish within 60 s of the run's first retry (wall-clock: requests waiting in parallel -don't add up), so a heavily throttled run gives up instead of hanging. `SOCKET_API_MAX_RETRIES=` changes -the per-request count (`0`-`10`; `0` turns retries off). Other errors are never retried. -A query still throttled after its retries is reported, never dropped: a failed batch -prints `Warning: API batch of failed: …` (under `--json`, a top-level -`warnings[]` entry with code `api_batch_failed`), a failed patch-list lookup prints -`Warning: could not fetch details for : …` (`--json`: `patch_details_failed`), and -if every query fails the scan exits 1 with an error, as before. - -The crawl has a pacing knob too. Its directory walks (`node_modules`, and the Maven -repository with its POM parse) run on a small pool of threads: 4 by default (fewer on a machine with fewer performance cores), because the walk is bound -by the kernel's directory cache and more threads only add system time. -`SOCKET_WALK_THREADS=` overrides that, clamped to `1`-`16` and to the machine's CPU -count; an unset, empty or non-numeric value leaves the default in place. A soft open-file -limit below 128 still runs the walk on one thread, whatever the knob says. - -The sections below list only each command's **command-specific** flags. - -### `scan` - -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); it is manifest-free — the vendor ledger embeds the patch records and - nothing else is written under `.socket/`; -- `--mode hosted` rewrites lockfiles / registry configs so only the patched dependencies - resolve to Socket-hosted packages. - -Without a mode, interactive `scan` prompts before applying (in a TTY — when stdin is not a -TTY and neither `--yes` nor a mode/`--prune` flag is given, it is report-only: it prints what -it found plus the "To apply a single patch, run: …" hint, writes nothing, and exits 0), and -`scan --json` is read-only (discovery plus an `updates[]` array; no mutation). - -`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 | 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 any manifest entry it still had dropped). Orthogonal to `--mode` — combines with any mode. | -| `--detached` | — | Hidden compatibility no-op. Vendored mode is manifest-free by default: the vendor ledger (`.socket/vendor/state.json`) embeds the patch records and `.socket/manifest.json` is never written, so this former opt-in changes nothing. Still an error without `--mode vendored`. | -| `--batch-size ` | `SOCKET_BATCH_SIZE` | Packages per API request (default: `500` on the authenticated API, `100` on the public proxy). A request whose body would exceed 256 KiB is split into smaller ones. | -| `--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 -# Scan local project (interactive prompt to apply) -socket-patch scan - -# Scan with JSON output (discover + updates, no mutation) -socket-patch scan --json - -# Agent mode: discover + apply patches in place (non-interactive) -socket-patch scan --json --mode agent --yes - -# 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 -socket-patch scan --json --mode agent --prune --yes --dry-run - -# Scan only npm packages -socket-patch scan --ecosystems npm - -# Scan global packages -socket-patch scan -g - -# Agent mode + emit an OpenVEX attestation in one pass -socket-patch scan --json --mode agent --prune --yes --vex socket.vex.json - -# Vendored mode: build + commit every patched dependency (see the vendor -# command). Works on a completely fresh clone: dependencies listed in the -# lockfile but not yet installed are fetched pristine from their registry and -# integrity-verified against the lockfile before vendoring. -socket-patch scan --json --mode vendored --yes +## Installation -# Preview a vendored run (would_vendor / would_revendor / already_vendored) -socket-patch scan --json --mode vendored --yes --dry-run +Install a standalone binary on macOS or Linux: -# Hosted mode: rewrite lockfiles so patched deps resolve to Socket-hosted -# integrity-pinned packages — no artifact bytes in the repo, no CI changes. -socket-patch scan --json --mode hosted --yes +```sh +curl -fsSL https://install.socket.dev/patch | sh ``` -> 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. -> -> Hosted-managed dependencies get the same signal: `updates[]` also consults the -> `.socket/vendor/redirect-state.json` ledger, so a superseded hosted patch shows up in -> read-only `scan --json` — re-run `scan --mode hosted` to take it. - -### `apply` +The installer verifies the download against the release's `SHA256SUMS` and installs +in `/usr/local/bin` or `~/.local/bin`. To choose a directory or release, pass +`SOCKET_PATCH_INSTALL_DIR` or `SOCKET_PATCH_VERSION` to `sh` after the pipe. +See the [installer source](scripts/install.sh) and +[mirror configuration](docs/installer-hosting.md). -Apply security patches from the local manifest. Idempotent — safe to run from install -hooks and CI on every build. +On Windows, extract a `socket-patch-*-pc-windows-msvc.zip` archive from +[GitHub Releases](https://github.com/SocketDev/socket-patch/releases) into a directory +on your `PATH`, or install through npm: -**Usage:** -```bash -socket-patch apply [options] +```sh +npm install -g @socketsecurity/socket-patch ``` -**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 -# Apply patches -socket-patch apply +Cargo users can build and install the published CLI with +`cargo install socket-patch-cli`. All distributions support the same ecosystems; +you do not need Node.js or Rust to use the standalone binary. -# Dry run -socket-patch apply --dry-run +For standalone installs, run `socket-patch --update` to update. For npm or Cargo +installs, use that package manager's update command. The +[platform matrix](docs/ecosystems.md#supported-platforms) lists release targets. -# Apply only npm patches -socket-patch apply --ecosystems npm +## Quick start -# Apply in offline mode -socket-patch apply --offline +From the root of a project with dependency files: -# JSON output for CI/CD -socket-patch apply --json - -# Apply and emit an OpenVEX attestation in one step -socket-patch apply --vex socket.vex.json +```sh +socket-patch scan --dry-run # preview available patches and edits +socket-patch scan # apply hosted references; never prompts ``` -> 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. - -### `vex` +Without an API token, the CLI uses Socket's public proxy for free patches. +To use your organization's patch tier, set `SOCKET_API_TOKEN`, or sign in with the +separate Socket CLI using `socket login`. See [configuration](docs/configuration.md). -Generate an [OpenVEX](https://github.com/openvex) 0.2.0 attestation describing the -vulnerabilities that the applied patches have mitigated — agent-mode patches from the -manifest, and hosted / vendored patches straight from the lockfiles (no manifest needed). -See [OpenVEX attestations](#openvex-attestations) below for the full workflow. +A scan with no available patches means the catalog has no applicable patch for +this run. It is **not** a finding that the project has no vulnerabilities. -**Usage:** -```bash -socket-patch vex [options] -``` - -**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 patch records — useful on a build machine that doesn't have the patched files laid out. The wiring checks still apply: a hosted/vendored ledger record the lockfile no longer wires, or a lockfile reference whose record is unavailable or names another package, is omitted either way. | -| `--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 -# Print a VEX document to stdout (human-readable status goes to stderr) -socket-patch vex +Review and commit the files the scan changed. Hosted mode keeps no patch ledger; +its state is in the project's lockfiles, manifests, and package-manager configuration. +For example, an npm project may change both `package-lock.json` and `.npmrc`: -# Write the document to a file +```sh +git diff +git add package-lock.json .npmrc +git commit -m "Apply Socket security patches" +npm ci 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 -[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/vendor/` — -the vendored artifacts plus the ledger whose embedded patch records [`vex`](#vex), -[`list`](#list), and [`repair`](#repair) read (vendored mode writes nothing else under -`.socket/`; `vex` can also attest from the lockfile wiring alone) — 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. - -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 | 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: - -- [`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 - `--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. -- Re-running `vendor` is idempotent. Standalone `vendor` (no flags) is driven by - `.socket/manifest.json` — patches dropped from that manifest are auto-reverted on the - next run — so on a project vendored by `scan --mode vendored` (no manifest) it is a - clean no-op; use [`repair`](#repair) to verify or rebuild the committed artifacts there. - -**Examples:** -```bash -# Vendor every patched dependency listed in the manifest -socket-patch vendor - -# Preview without writing anything -socket-patch vendor --dry-run - -# Then make it stick: commit .socket/ (vendor artifacts + ledger) and the lockfile -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 - -# JSON output for scripting -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. - -What gets wired, per ecosystem: - -- **npm / yarn / pnpm / bun / vlt** — writes `postinstall` and `dependencies` scripts into - `package.json` so any install — including `npm install ` — re-applies patches - (pnpm and vlt: root package only). vlt uses the same `npx` hook, runs it on every - install that changes the tree (never on a no-op install), and aborts the install when - it fails; vlt before 1.0.0-rc.13 never runs a root `postinstall`, which `setup` warns - about (`vlt_root_scripts_not_run`). -- **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`, and **bundler >= 2.2**: bundler 1.x cannot load a `plugin ... path:` - directive — it resolves it as an ordinary gem and every later `bundle install` fails — - so `setup` refuses to wire a project whose lock or `bundle --version` reports an older - bundler, and `setup --check` red-flags a wired project that lands in that state.) -- **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, note that in-place patching leaves the caches' own checksum sidecars stale - (NuGet's fixup deletes `.nupkg.metadata` and raises an advisory for the signed-package - `.nupkg.sha512` marker; Maven's `.jar.sha1`/`.jar.md5` are left as-is) — the copy-out - modes, `scan --mode vendored` and `scan --mode hosted`, never touch the caches and - avoid the issue entirely. See - [ecosystems.md](docs/ecosystems.md#maven--nuget-caveats). - -**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` 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 — including bundler's machine-local `.bundle/plugin` registration, so later `bundle install`s don't warn about the unwired plugin — and the Composer `post-install-cmd`/`post-update-cmd` script entries). If the registration can't be cleared automatically (unexpected index format), the error names the fallback: `bundler plugin uninstall socket-patch`. | -| `--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. - -#### 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 **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 -# 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 or a patch has drifted) -socket-patch setup --check - -# JSON output for scripting -socket-patch setup --json -y -``` - -### `rollback` - -Roll back patches to restore the system to unpatched. If no target is given, everything -is rolled back, across all three modes: in-place file restores (agent), vendored unwire + -artifact deletion + ledger-entry drop, and hosted lockfile-redirect unwind + record drop. -The rolled-back entries are then removed from `.socket/manifest.json` (a zero-patch -`{"patches": {}}` husk stays) and their blobs are garbage-collected — a later `apply` has -nothing to re-apply. Pass `--preserve-state` to keep the local patch state (manifest -entries, vendored artifacts + ledger entries) for a later re-apply; use -[`remove`](#remove) for a single patch. - -A wet run confirms once (auto-accepted under `--yes`/`--json`/non-TTY). Vendor-owned purls -the run did NOT act on (today: a corrupt vendor ledger) are listed in the JSON output's -`vendored` array; acted-on entries ride `vendoredReverted` / `vendoredPreserved` / -`vendoredKept`. - -**Usage:** -```bash -socket-patch rollback [targets]... [options] -``` - -**Arguments:** -- `targets` — zero or more package PURLs, patch UUIDs or path globs (unioned). Omit to roll - back everything. - -**Command-specific options** (plus all [Global options](#global-options)): -| Flag | Env var | Description | -|------|---------|-------------| -| `--preserve-state` | `SOCKET_PRESERVE_STATE` | Unpatch the system but keep the local patch state — manifest entries, vendored artifacts + ledger entries — for a later re-apply, and skip GC. Hosted redirects have no preservable state and are unwound either way. | -| `--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 -# Rollback all patches -socket-patch rollback - -# Rollback a specific package -socket-patch rollback "pkg:npm/lodash@4.17.20" - -# Rollback by UUID -socket-patch rollback 550e8400-e29b-41d4-a716-446655440000 - -# Dry run -socket-patch rollback --dry-run - -# JSON output -socket-patch rollback --json -``` - -### `get` - -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`. And as a shortcut, `socket-patch ` with a bare patch UUID is -rewritten to `socket-patch get `. - -**Usage:** -```bash -socket-patch get [options] -``` - -**Arguments:** -- `identifier` — patch UUID, CVE ID, GHSA ID, package PURL, or package name. Type is - auto-detected; force it with `--id` / `--cve` / `--ghsa` / `--package`. - -**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 (hidden from `--help`): 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 -# 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 recorded locally: the manifest's entries plus the vendor ledger's -(labeled `Mode: vendored`) and the hosted redirect ledger's records, so it works on -manifest-less vendored or hosted projects too. - -**Usage:** -```bash -socket-patch list [options] -``` - -No command-specific options — see [Global options](#global-options) (`--json`, -`--manifest-path`, `--cwd` are the relevant ones). - -**Examples:** -```bash -# List patches socket-patch list - -# JSON output -socket-patch list --json -``` - -**Sample output:** -``` -Found 1 patch: - -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-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. Patches vendored by `scan --mode vendored` have no manifest entry and are -removable by PURL or UUID all the same (reverting the vendoring *is* the removal, so -`--skip-rollback` is refused for them). - -**Usage:** -```bash -socket-patch remove [options] -``` - -**Arguments:** -- `identifier` — package PURL (e.g. `pkg:npm/package@version`) or patch UUID. - -**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 a vendored package that still has a manifest entry this also leaves the vendor wiring + artifact in place; refused for manifest-less vendored patches, where the revert *is* the removal). | - -**Examples:** -```bash -# Remove by PURL -socket-patch remove "pkg:npm/lodash@4.17.20" - -# Remove by UUID -socket-patch remove 550e8400-e29b-41d4-a716-446655440000 - -# Remove without rolling back files -socket-patch remove "pkg:npm/lodash@4.17.20" --skip-rollback - -# JSON output -socket-patch remove "pkg:npm/lodash@4.17.20" --json -``` - -### `repair` - -Download missing blobs, rebuild missing or corrupt vendored artifacts, and clean up unused -blobs. - -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. 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. - -Like every other mutating command, `repair` takes the `.socket/apply.lock` advisory lock -while it runs and removes it when it finishes. 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 | 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 — missing blobs are warned about and skipped, never downloaded -socket-patch repair --offline - -# Download missing blobs only -socket-patch repair --download-only - -# JSON output for scripting -socket-patch repair --json -``` - -## OpenVEX attestations - -`socket-patch vex` turns the patches your project carries 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. Gathers every patch the project can prove: agent-mode patches from - `.socket/manifest.json`, and [vendored](#vendor) / [hosted](#three-patch-modes) patches - from the wiring in your **lockfiles** (plus the `.socket/vendor` ledgers when they are - committed). See [No manifest needed for hosted and vendored - patches](#no-manifest-needed-for-hosted-and-vendored-patches). -2. Unless `--no-verify` is passed, re-checks each patch's bytes so the attestation only - covers patches that are actually applied: agent patches against the installed tree, - vendored patches against the **committed artifact** (marker `(vendored)`), and hosted - patches against the installed copy the build consumes — or, before any install, against - the lockfile's integrity pin (marker `(redirected)`). Vendored and hosted patches need - no `setup` install hook to be attested. Whatever `--no-verify` says, a ledger record the - lockfile no longer wires is never attested. -3. 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/@` - - `go.mod` → `pkg:golang/` - - `composer.json` → `pkg:composer//[@]` - - `pom.xml` → `pkg:maven//[@]` - - the root's single `*.csproj` → `pkg:nuget/[@]` - - the root's single `*.gemspec` → `pkg:gem/[@]` -4. 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](#three-patch-modes): - -| Impact statement | Mode | What the evidence is | What a consumer should do | -|---|---|---|---| -| `Patched via Socket patch ` | agent | The installed tree: every patched file's hash was verified against the manifest's `afterHash` | Trust the statement as long as the agent install hook (or a CI `apply`) keeps re-applying; ecosystems without a hook must be declared in `setup.manual` | -| `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. A post-install `socket-patch vex` hash-verifies the installed copy; before any install it attests from the pin. When emitted in-run by `scan --mode hosted --vex`, the statement is attested **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** to have the redirected patches hash-verified against the installed tree | - -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 | 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. - -**Using it with a scanner** - -```bash -# Generate the attestation as part of CI, then hand it to a scanner -socket-patch vex --output socket.vex.json - -# Suppress already-patched findings in Grype -grype --vex socket.vex.json - -# Or with Trivy -trivy image --vex socket.vex.json ``` -Apply patches first (in any mode). When nothing names a patch anywhere — no manifest -entry, no `.socket/vendor` ledger entry, no hosted or vendored lockfile reference — `vex` -errors with `no_patches` (exit 1) when the manifest file exists but is empty, or with -`manifest_not_found` (exit 2) when there is no manifest either. When there are patches but -none can be attested, it exits 1 with `no_applicable_patches`, and each omission is listed -with its reason: `hash_mismatch`, `record_unavailable`, `redirect_unwired`, and so on. +Use your project's normal install command in place of `npm ci`. Some package +managers reuse installed or cached upstream packages; follow any reinstall warning +from the CLI and the [ecosystem notes](docs/ecosystems.md). Generate VEX after +installing to verify the copies your build consumes, then pass the document to your +VEX-aware vulnerability scanner. -### No manifest needed for hosted and vendored patches - -A hosted or vendored checkout needs no `.socket/manifest.json`, and no `.socket/vendor` -ledgers either. This covers a depscan-opened PR, a clone of a repo that never committed its -ledgers, and a `scan --mode hosted` run. `vex` reads the patch reference out of each root -lockfile or config: a `patch.socket.dev` URL or a `.socket/vendor///…` path -carries the patch uuid. It then finds that patch's record in the manifest or ledgers, or -fetches it from the patch API. The references it accepts are what socket-patch's own -rewriters write: a URL on any other host, or an entry the package manager would not -install from, is ignored. - -```bash -# Fresh clone of a hosted or vendored project: nothing installed, no .socket/manifest.json -socket-patch vex --output socket.vex.json -``` +## Patch modes -Behavior worth knowing: +| Mode | Command | What to commit | What installs need | +| --- | --- | --- | --- | +| Hosted (default) | `socket-patch scan` | Changed lockfiles, manifests, and registry configuration | Access to Socket's patch server | +| Vendored | `socket-patch scan --mode vendored` | Changed dependency files and `.socket/vendor/` (artifacts and ledger) | The committed patched packages | +| Agent | `socket-patch scan --mode agent` | `.socket/manifest.json` and patch data; Go also uses a committed patched tree | `socket-patch apply` after dependency installs | -- **Network.** Without a local record, `vex` fetches the patch by uuid from the patch API. - With `--offline`, or when the fetch fails or the patch is paid and not entitled, the patch - is omitted as `record_unavailable`. Commit the ledgers, or keep the manifest, to attest - offline. -- **Liveness.** A ledger entry attests only while a lockfile still wires it. Otherwise it is - omitted as `vendor_unwired` or `redirect_unwired`, even under `--no-verify`. Lockfiles - that wire one package to different patches (`wiring_conflict`) attest none of them. A - package that one lock wires to a patch while another lock resolves it from the registry - is not attested either. -- **Diagnostics.** A lockfile that cannot be read or parsed, or a Socket reference that - fails validation, is reported as a warning (`lockfile_unparseable`, `patched_ref_invalid`, - …) and never aborts the run. With `--json` these go in `warnings[]`. -- **Scope.** Only the project root is read (plus Rush's `common/config` pnpm locks). Nested - workspace-member lockfiles are not. +Vendored mode stores **patched dependencies**, not the entire dependency graph. +Other dependencies still need their normal registry, mirror, or offline cache. +Hosted and vendored installs do not need an install hook or the Socket Patch CLI. -| Ecosystem | Files read (project root) | Limitations | -|---|---|---| -| npm | `package-lock.json`, `npm-shrinkwrap.json` (both) | `link` / bundled entries never count | -| pnpm | `pnpm-lock.yaml` (all generations), `shrinkwrap.yaml` (pnpm 1/2), Rush locks | Aliased / nested `resolution` shapes are diagnosed, not attested; `overrides` alone prove nothing | -| yarn | `yarn.lock` (classic + berry) | Berry vendored entries also need the root `package.json` `resolutions` mapping; member locks are not read | -| bun | `bun.lock`, else `bun.lockb` | A hosted entry that Bun < 1.3.10 re-saved without its sha512 attests only after install | -| vlt | `vlt-lock.json` (`lockfileVersion` absent, 0 or 1) | A BOM-prefixed or other-version lock wires nothing; a same-version instance on another registry keeps a hosted pin from attesting before install; a vendored directory whose `package.json` patch lost its devDependencies needs the patched blob in `.socket/blobs` without the vendor ledger | -| cargo | `Cargo.lock`, `Cargo.toml`, `.cargo/config[.toml]` | Root manifest + project config only (no `$CARGO_HOME` / parent configs); vendored `[patch.crates-io]` entries are read from `Cargo.toml` first (v5), the project config for pre-v5 projects, and must agree with the detached lock entry's tagged version `+socket.` (a tag for another uuid — in the lock or in the copy's own `Cargo.toml` — is dead wiring; an untagged detached entry counts only beside an untagged, pre-tag copy); a manifest entry cargo ignores (a same-key project-config item, or a URL-spelled crates.io `[patch]` table) is not attested; a lockless hosted pin needs the redirect ledger's record | -| golang | `go.mod`, `go.work`, `go.sum`, `go.work.sum` | A replace that `require` no longer selects is inert; `vendor/modules.txt` is not read | -| pypi | `uv.lock`, `*.py.lock`, `pylock*.toml`, `poetry.lock`, `pdm.lock`, `Pipfile.lock`, `requirements.txt` (+ `-r` includes), `pyproject.toml` / `hatch.toml` | A `uv.lock` beside a `pyproject.toml` must agree with its `[tool.uv.sources]`; PDM 3.1 / 4.0–4.2 locks are refused; a Pipenv project needs `--product` (or a git remote) | -| gem | `Gemfile.lock`, `gems.locked` | Platform gems unsupported; a Gemfile-only (pre-bundler-2.6, not yet locked) wiring needs the redirect ledger | -| composer | `composer.lock` | `installed.json` and `COMPOSER=`-renamed locks are not read | -| maven | `pom.xml` (+ `.mvn/` checksums) | Root pom only (no parents / submodules, no Gradle); legacy same-GAV hosted repositories cannot be attributed | -| nuget | `nuget.config`, `packages.lock.json` | Hosted needs a `packages.lock.json` entry for the id (with no lock at all, an exclusive exact-id mapping still keeps the redirect ledger's record live); root config only | -| deno | none | No hosted or vendored mode exists; Deno patches attest only through the manifest (agent mode + `setup.manual`) | +The CLI supports npm, PyPI, Cargo, Go, RubyGems, Maven, Composer, NuGet, and Deno. +Mode and package-manager support vary: Deno uses agent mode, for example. Check the +[ecosystem support matrix](docs/ecosystems.md) before choosing a mode. -The full recognition rules are in -[CLI_CONTRACT.md](crates/socket-patch-cli/CLI_CONTRACT.md) ("Manifest-less VEX"). +Vendored Maven reactors and Gradle 6.8+ builds are supported. See +[JVM vendoring](docs/design/maven-vendoring.md) for supported project shapes, +cache behavior, and offline checks. -### Inline VEX on `apply` / `scan` / `vendor` +## Common commands -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 auto-update-bot pass -socket-patch scan --json --mode agent --prune --yes --vex socket.vex.json - -# Vendor and attest — manifest-less by construction -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. - -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 project **as it stands after the run** — the manifest (including - any `--mode agent` writes, with or without `--prune`), the `.socket/vendor` ledgers, and - the lockfile wiring — and verified against on-disk state unless `--vex-no-verify` is set. - Generated for real applies and read-only scans alike; `--dry-run` skips it (nothing was - changed, so nothing is attested). -- `apply --vex` and `vendor --vex` with **no manifest** still attest what the lockfiles and - ledgers wire. A project with nothing wired anywhere keeps the calm exit 0 and writes no - document. `apply --check` never generates one. -- **Fail-the-command:** if `--vex` was requested but generation fails (no detectable - product, nothing attestable, a corrupt ledger, 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. - -**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) -result=$(socket-patch scan --json --ecosystems npm) -patches=$(echo "$result" | jq '.totalPatches') - -# 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 // 0) -}' -# 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" +```sh +socket-patch scan --package lodash # limit selection to a package +socket-patch scan --max-new-patches 5 # introduce at most five new patches +socket-patch scan 'apps/*' # scan project directories in a monorepo +socket-patch get CVE-2024-12345 # target an advisory; hosted by default +socket-patch vendor # eject an existing hosted patch set +socket-patch rollback # restore upstream dependencies ``` -When stdin is not a TTY (e.g. in CI pipelines), interactive prompts auto-proceed instead -of blocking — with one deliberate exception: a plain `scan` (no `--mode`/`--apply`/`--sync`/ -`--vendor`/`--prune` and no `--yes`) is report-only there. It prints what it found and the -"To apply a single patch, run: …" hint, writes nothing, and exits 0; add `--yes` or a mode flag -to mutate. Progress indicators and ANSI colors are automatically suppressed when output -is piped. - -The exact JSON shapes, exit codes, and stability guarantees are specified in -[CLI_CONTRACT.md](crates/socket-patch-cli/CLI_CONTRACT.md). - -## Manifest format - -Downloaded patches are stored in `.socket/manifest.json`: - -```json -{ - "patches": { - "pkg:npm/package-name@1.0.0": { - "uuid": "unique-patch-id", - "exportedAt": "2024-01-01T00:00:00Z", - "files": { - "path/to/file.js": { - "beforeHash": "git-sha256-before", - "afterHash": "git-sha256-after" - } - }, - "vulnerabilities": { - "GHSA-xxxx-xxxx-xxxx": { - "cves": ["CVE-2024-12345"], - "summary": "Vulnerability summary", - "severity": "high", - "description": "Detailed description" - } - }, - "description": "Patch description", - "license": "MIT", - "tier": "free" - } - } -} -``` +`get` also accepts a GHSA, patch UUID, PURL, or package name. `scan` selects from +patches your account can download, preferring the newest merged patch and then the +highest-severity patch. Existing patches are upgraded only by a better-ranked patch. -Patched file contents are in `.socket/blobs/` (named by git SHA256 hash). +Hosted rollback resolves upstream metadata and generally needs network access. +Where restoration is unsupported, including hosted binary `bun.lockb`, the CLI +refuses the change and gives a version-control recovery hint. See +[usage and recovery](docs/usage.md). -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`). +Use `socket-patch --help` for options and +[`socket.yml`](docs/configuration.md#repository-patch-policy) for a shared rollout policy. -## Further reading +## Documentation -- **[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. [the configuration model](docs/design/configuration.md) - and [hosted mode for Go](docs/design/golang-hosted.md) (free tier; the - [paid-tier no-go analysis](docs/design/golang-hosted-no-go.md) it supersedes). -- **[Changelog](CHANGELOG.md)** +- [Usage](docs/usage.md): targeting, CI, vendoring, agent mode, VEX, and recovery. +- [Configuration](docs/configuration.md): authentication, environment, and rollout policy. +- [Ecosystem support](docs/ecosystems.md): package-manager formats and limitations. +- [Migrating to v5](docs/migrating-to-v5.md): changed defaults and retired install hooks. +- [CLI contract](crates/socket-patch-cli/CLI_CONTRACT.md): flags, JSON, diagnostics, and exit codes. +- [Development](docs/development.md): code map, builds, and test entry points. +- [Release runbook](docs/releasing.md) and [changelog](CHANGELOG.md). diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 66d224b0d..afe5eac12 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1,37 +1,48 @@ # socket-patch CLI contract -This document defines the **public surface** of the `socket-patch` binary. Anything listed here is part of the user-visible contract: third-party scripts, CI pipelines, and the npm/pypi/cargo wrappers depend on it. Changes are governed by the semver policy at the bottom of this file. +This document defines the **public surface** of the `socket-patch` binary. Third-party scripts, CI pipelines, and the npm distribution depend on this contract. Changes are governed by the semver policy at the bottom of this file. -> **Why this exists.** Until late 2026 the CLI crate had zero unit tests under `src/` — only network-dependent `tests/e2e_*.rs` suites that run with `--ignored`. A flag rename, a default-value change, or a JSON key rename could land green and break every shipped wrapper silently. The contract below is now backed by the unit tests under `crates/socket-patch-cli/src/**` (`#[cfg(test)] mod tests`) and the parser tests under `crates/socket-patch-cli/tests/cli_parse_*.rs`. Changes that violate the contract must update those tests in lock-step with a major version bump. +> **Why this exists.** A flag rename, a default-value change, or a JSON key rename can land green and break every shipped wrapper silently. The contract below is backed by the unit tests under `crates/socket-patch-cli/src/**` (`#[cfg(test)] mod tests`) and the parser tests under `crates/socket-patch-cli/tests/cli_parse_*.rs`. Changes that violate the contract must update those tests in lock-step with a major version bump. + +For task-oriented guidance, start with [usage](../../docs/usage.md), +[configuration](../../docs/configuration.md), or [v5 migration](../../docs/migrating-to-v5.md). + +**Reference:** [Commands](#subcommands) · [Arguments](#global-arguments) · +[Policy](#socketyml-patch-policy-v50) · [VEX](#manifest-less-vex-lockfile-discovery) · +[Vendoring](#vendor-command-contract) · [Rollback](#rollback-command-contract-v50) · +[Environment](#environment-variables) · [JSON](#json-output-shapes) · [Exit codes](#exit-codes) ## Subcommands | 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, the `.socket/vendor` ledgers, and the hosted / vendored patch references the project's lockfiles wire (no manifest required) | +| `scan` | — | Find patches for installed packages and apply them. **v5.0 (MAJOR)**: a bare `scan` runs hosted mode (rewrites lockfiles so only the patched dependencies resolve to Socket-hosted, integrity-pinned packages); `--mode vendored` / `--mode agent` pick the other modes. Never prompts. See [scan modes](#scan-modes-v50) | +| `vex` | — | Emit an OpenVEX 0.2.0 attestation derived from the local manifest, the vendor ledger, and the hosted / vendored patch references the project's lockfiles wire (no manifest required; hosted records come from the API) | | `vendor` | — | Eject patched dependencies into committable `.socket/vendor/` and rewire lockfiles | -| `setup` | — | Wire automatic-patching install hooks (npm/pypi/gem) | -| `rollback` | — | **Full-state rollback (v5.0, MAJOR)**: restore original files AND unwind vendored/hosted lockfile wiring, remove the rolled-back entries from the manifest, and GC their blobs/archives; takes optional variadic positional `targets` (PURL \| UUID \| path glob). See [Rollback command contract](#rollback-command-contract-v50) | -| `get` | `download` | Fetch + apply patch; requires positional `identifier` | -| `list` | — | Print patches in the local manifest, plus the vendor ledger's (v5.0) and the hosted redirect ledger's records (labeled; see the `manifest_not_found` row and the action matrix) | -| `remove` | — | Remove patch from manifest (rolls back first); requires positional `identifier` | -| `repair` | `gc` | Download missing blobs, rebuild missing/corrupt vendored artifacts, and clean up unused ones (refuses with `lock_held` when a live process holds the lock; see "Lock lifecycle" below) | +| `list` | — | Print patches in the local manifest, plus the vendor ledger's records (v5.0) and the hosted pins the lockfiles wire (labeled; see the action matrix; an empty project exits 0) | +| `get` | `download` | Fetch a selected patch in hosted mode by default; `--mode agent` selects in-place application, also the default with `--save-only` or global targeting. Requires positional `identifier`. | +| `apply` | — | Agent mode: apply patches from the local manifest | +| `rollback` | — | **Full-state rollback (v5.0, MAJOR)**: restore original files AND unwind vendored lockfile wiring / restore hosted pins to their upstream registry entries, remove the rolled-back entries from the manifest, and GC their blobs/archives; takes optional variadic positional `targets` (PURL \| UUID \| path glob). See [Rollback command contract](#rollback-command-contract-v50) | +| `remove` | — | Restore and remove one patch across hosted, vendored, and agent state; requires positional `identifier`. | +| `repair` | `gc` | Download missing agent blobs, re-vendor missing/corrupt vendored artifacts (never re-synthesizing a lost ledger), and clean up unused ones (refuses with `lock_held` when a live process holds the lock; see "Lock lifecycle" below) | + +Rows are in `--help` order (v5.0): the hosted/vendored workflow (`scan` → `vex` → `vendor`, with `list` to inspect), then the agent-mode (in-place patching) commands. + +**Removed in v5.0:** the `setup` subcommand (see [Agent mode in CI](#agent-mode-in-ci-v50-setup-removed)). **Removed in v4.0:** the `unlock` subcommand (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; `repair` briefly owned lock-file cleanup in v4.x, and since v5.0 every lock-taking command removes its own lock file on exit). -**Lock lifecycle (v5.0).** `<.socket>/apply.lock` never outlives the command that took it: acquisition creates `.socket/` when it is missing, the guard's drop unlinks the file WHILE the lock is still held (so a waiter can never lock an orphaned inode), releases it, and then removes `.socket/` itself if that left the directory empty — a run that had nothing to persist leaves no `.socket/` behind, and there is nothing to `.gitignore`. A leftover file from a crashed (SIGKILLed) run is reclaimed in place and removed by the next lock-taking command. The lock is taken by `apply`, `rollback`, `remove`, `repair`, `vendor`, `setup` while it persists `--exclude` (v5.0), agent-mode `get` and `scan --apply`/`--sync` (download → manifest write → nested apply is ONE lock window — the nested apply never re-acquires), and `scan`/`get` in vendored **and hosted** mode — hosted acquires it around its first wet write (the takeover pre-reverts), never on `--dry-run` and never when the run would write nothing, so hosted previews and no-op runs create no `.socket/`. Dry runs of the other commands may still take the lock; it is residue-free either way. A live holder is `lock_held` (exit 1); a directory or special file squatting on `.socket/` or on the lock path is a lock I/O error — `lock_io` (exit 1, `failed to open lock file at : …`; a read-only project root surfaces the same code at the acquire, before any ledger or manifest write) — never `lock_held`. +**Lock lifecycle (v5.0).** `<.socket>/apply.lock` never outlives the command that took it: acquisition creates `.socket/` when it is missing, the guard's drop unlinks the file WHILE the lock is still held (so a waiter can never lock an orphaned inode), releases it, and then removes `.socket/` itself if that left the directory empty — a run that had nothing to persist leaves no `.socket/` behind, and there is nothing to `.gitignore`. A leftover file from a crashed (SIGKILLed) run is reclaimed in place and removed by the next lock-taking command. The lock is taken by `apply`, `rollback`, `remove`, `repair`, `vendor`, agent-mode `get` and `scan --apply`/`--sync` (download → manifest write → nested apply is ONE lock window — the nested apply never re-acquires), and `scan`/`get` in vendored **and hosted** mode — hosted acquires it around its first wet write (the takeover pre-reverts), never on `--dry-run` and never when the run would write nothing, so hosted previews and no-op runs create no `.socket/`. Dry runs of the other commands may still take the lock; it is residue-free either way. A live holder is `lock_held` (exit 1); a directory or special file squatting on `.socket/` or on the lock path is a lock I/O error — `lock_io` (exit 1, `failed to open lock file at : …`; a read-only project root surfaces the same code at the acquire, before any ledger or manifest write) — never `lock_held`. **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). **Root `--update` flag.** `socket-patch --update [VERSION]` updates the binary itself from GitHub Releases. It is a root flag, not a subcommand: argv is rewritten (the same mechanism as the bare-UUID fallback) onto an internal hidden subcommand whose name carries no stability guarantee — script the flag, never the internal name. Combining the flag with a subcommand (`socket-patch --update scan`) is a usage error (exit 2). Full contract: [Self-update contract](#self-update-contract-socket-patch---update). -**Internal `hosted-bundle` subcommand.** `socket-patch hosted-bundle` is a hidden, INTERNAL parity/debug harness for the in-memory hosted engine (`src/hosted_memory/`, the engine the Node addon embeds): it reads a JSON bundle `{"files": {path: text}, "binaryFiles"?: {path: base64}, "presentOnly"?: [path], "symlinks"?: [path], "projectRoots"?: [dir], "pipenvMajor"?: n, "batchSize"?: n}` on stdin, queries the authenticated org API built from `--api-url` / `--api-token` / `--org` only (both of the latter are required; no public-proxy fallback), and prints the engine result — or `{"status":"error","error":{"code","message"}}` with exit 1 (exit 2 for unusable input or missing credentials). It never touches the filesystem. Its name, input and output carry NO stability guarantee; do not script it. +**Internal `hosted-bundle` subcommand.** `socket-patch hosted-bundle` is a hidden, INTERNAL parity/debug harness for the in-memory hosted engine (`socket-patch-core` `src/hosted/memory/`, the engine the Node addon embeds): it reads a JSON bundle `{"files": {path: text}, "binaryFiles"?: {path: base64}, "presentOnly"?: [path], "symlinks"?: [path], "projectRoots"?: [dir], "pipenvMajor"?: n, "batchSize"?: n, "noSocketYml"?: bool, "minSeverity"?: s, "policyPaths"?: [path], "policySha256"?: s}` on stdin, queries the authenticated org API built from `--api-url` / `--api-token` / `--org` only (both of the latter are required; no public-proxy fallback), and prints the engine result — or `{"status":"error","error":{"code","message"}}` with exit 1 (exit 2 for unusable input or missing credentials). It never touches the filesystem. Its name, input and output carry NO stability guarantee; do not script it. ## Global arguments -In v3.0 every subcommand accepts the same set of "global" flags via a single shared `GlobalArgs` struct that's `#[command(flatten)]`-ed into each per-command struct (`crates/socket-patch-cli/src/args.rs`). Subcommands that don't actually consume a given flag accept it silently — e.g. `list --global` parses fine and is a no-op. Every flag also has an environment-variable binding; precedence is **CLI arg > env var > default** — and for exactly three keys (`--api-token`, `--org`, `--api-url`) the JS socket-cli's persisted login sits between env var and default: **CLI arg > env var (canonical, then `SOCKET_CLI_*` alias) > socket-cli `config.json` > default**. See "Persisted configuration" under Environment variables. +Every subcommand accepts the same set of "global" flags via a single shared `GlobalArgs` struct that's `#[command(flatten)]`-ed into each per-command struct (`crates/socket-patch-cli/src/args.rs`). Subcommands that don't actually consume a given flag accept it silently — e.g. `list --global` parses fine and is a no-op. For flags with an environment-variable binding, precedence is **CLI arg > env var > default** — and for exactly three keys (`--api-token`, `--org`, `--api-url`) the JS socket-cli's persisted login sits between env var and default: **CLI arg > env var (canonical, then `SOCKET_CLI_*` alias) > socket-cli `config.json` > default**. See "Persisted configuration" under Environment variables. | Long | Short | Env var | Default | Type | Semantic | |---|---|---|---|---|---| @@ -42,8 +53,9 @@ In v3.0 every subcommand accepts the same set of "global" flags via a single sha | `--org` | `-o` | `SOCKET_ORG_SLUG` | (auto-resolve) | string | Org slug | | `--proxy-url` | — | `SOCKET_PROXY_URL` | `https://patches-api.socket.dev` | string | Public proxy when no token | | `--ecosystems` | `-e` | `SOCKET_ECOSYSTEMS` | (all) | CSV → `Vec` | Restrict to these ecosystems | -| `--download-mode` | — | `SOCKET_DOWNLOAD_MODE` | **`diff`** | enum: `diff` \| `package` \| `file` | Patch artifact format | +| `--download-mode` | — | `SOCKET_DOWNLOAD_MODE` | **`diff`** | enum: `diff` \| `file` (`package` was removed and is rejected) | Patch artifact format | | `--vendor-source` | — | `SOCKET_VENDOR_SOURCE` | **`auto`** | enum: `auto` \| `service` \| `build` | How `vendor` acquires the installable artifact (see "Prebuilt vendor artifacts") | +| `--maven-config` | — | — | (recorded choice, else `auto`) | enum: `auto` \| `none` | Maven reactor vendoring: write the repository tail (`auto`) or use only the fallback file repository (`none`). The choice persists in the vendor ledger. | | `--vendor-url` | — | `SOCKET_VENDOR_URL` | (active API/proxy base) | string | Base host for the vendoring-service package-reference request | | `--patch-server-url` | — | `SOCKET_PATCH_SERVER_URL` | (server-returned) | string | Override the host of the prebuilt-archive download URL (local-dev / testing) | | `--offline` | — | `SOCKET_OFFLINE` | `false` | bool | **Strict airgap on every command** — never contact the network | @@ -54,15 +66,15 @@ In v3.0 every subcommand accepts the same set of "global" flags via a single sha | `--verbose` | `-v` | `SOCKET_VERBOSE` | `false` | bool | Extra detail | | `--silent` | `-s` | `SOCKET_SILENT` | `false` | bool | Errors only | | `--dry-run` | — | `SOCKET_DRY_RUN` | `false` | bool | Preview, no mutations (a dry run may still take the transient `apply.lock`, removed again on exit — see "Lock lifecycle"; hosted and vendored previews never leave a `.socket/`) | -| `--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 lock-taking subcommands — `apply`, `rollback`, `repair`, `remove`, `vendor`, `setup` (while persisting `--exclude`), and `scan`/`get` whenever they write (agent-mode download + apply, vendored, hosted) | +| `--yes` | `-y` | `SOCKET_YES` | `false` | bool | Skip prompts (`scan` never 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 lock-taking subcommands — `apply`, `rollback`, `repair`, `remove`, `vendor`, and `scan`/`get` whenever they write (agent-mode download + apply, vendored, hosted) | | `--debug` | — | `SOCKET_DEBUG` | `false` | bool | Verbose debug logs to stderr | | `--no-telemetry` | — | `SOCKET_TELEMETRY_DISABLED` | `false` | bool | Disable anonymous usage telemetry | | `--no-trust-lockfile-config` | — | `SOCKET_NO_TRUST_LOCKFILE_CONFIG` | `false` | bool | Opt out of hosted mode's automatic `trustLockfile: true` write to `pnpm-workspace.yaml` (see the pnpm trust-config note under the scan arguments) | | `--no-npm-allow-remote-config` | — | `SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG` | `false` | bool | Opt out of hosted mode's automatic `allow-remote=all` write to the project `.npmrc` (see the npm allow-remote note under the scan arguments). Read by `scan --mode hosted` and `get --mode hosted`; other subcommands accept it silently | | `--no-vlt-install-cleanup` | — | `SOCKET_NO_VLT_INSTALL_CLEANUP` | `false` | bool | Opt out of hosted mode's warm-tree heal for vlt: stale installed copies (`node_modules/.vlt-lock.json` and the stale `node_modules/.vlt/` entries) are left in place after `vlt-lock.json` is repointed (`scan`/`get --mode hosted`) or restored (`rollback`/`remove`), and the `redirect_vlt_reinstall_required` advisory tells you to run `vlt ci` instead. Stale copies of optional dependencies are always left in place (see `redirect_vlt_reinstall_required`). Other subcommands accept it silently | -The `--offline` semantics unified in v3.0. Previously `apply` enforced strict airgap, `repair` skipped network ops, and `rollback` failed when blobs were missing. All three now mean the same thing: never contact the network, fail loudly when a required local source is missing. On `repair`, `--offline` and `--download-only` are mutually exclusive (exit 2). `scan` and `get` need remote data for their core function (patch discovery / patch fetch), so `--offline` refuses them up front — exit 1 with an error naming the offline gate (JSON: `status: "error"`), before any crawl, client build, or network contact. This covers `scan --vendor` too: offline vendored staging is `vendor --offline`'s job. +`--offline` means the same thing on every command (v3.0): never contact the network, fail loudly when a required local source is missing. On `repair`, `--offline` and `--download-only` are mutually exclusive (exit 2). `scan` and `get` need remote data for their core function (patch discovery / patch fetch), so `--offline` refuses them up front — exit 1 with an error naming the offline gate (JSON: `status: "error"`), before any crawl, client build, or network contact. This covers `scan --vendor` too: offline vendored staging is `vendor --offline`'s job. The `--strict` mismatch policy applies to the in-place apply paths (apply/get/scan --apply/hook/go redirect). DEFAULT (v3.4): a file whose on-disk content matches neither the patch's beforeHash nor its afterHash is overwritten with the FULL verified patched content (the diff strategy self-disables on a wrong base; archive/blob writes are hash-gated to exactly afterHash; the missing blob is downloaded on demand) and surfaced as a `content_mismatch_overwritten` stderr warning + Skipped event. `--strict` turns that case into a hard error. `--force` overrides `--strict` and additionally skips missing files. Vendor staging is unaffected (it always auto-overwrites into its private stage). @@ -75,90 +87,101 @@ Beyond the globals above, each subcommand defines a small set of local arguments | `apply` | `--force` / `-f` | `SOCKET_FORCE` | Bypass beforeHash check | | `apply` | `--check` | — | Read-only audit that the committed **Go** `replace`-redirects match the manifest (CI / GitHub-App auditing) — Go ONLY (cargo patches in place, so there is no redirect to audit). Lock-free, crawl-free, offline-safe; exits 0 in sync, 1 on drift. Vendored modules are excluded from the audit | | `vendor` | `--force` / `-f` | `SOCKET_FORCE` | Tolerate missing patch-target files in the stage + bypass the variant probe. A beforeHash mismatch no longer needs it: vendor staging auto-overwrites with the verified patched content (`vendor_content_mismatch_overwritten` warning) | -| `vendor` | `--revert` | `SOCKET_VENDOR_REVERT` | Undo vendoring: restore recorded original lockfile fragments + remove `.socket/vendor/` artifacts. Works without a manifest | +| `vendor` | `--revert` | `SOCKET_VENDOR_REVERT` | Undo vendoring: restore recorded original lockfile fragments + remove `.socket/vendor/` artifacts. Works without a manifest. A package vendored over a hosted pin returns to its upstream registry entry, never to hosted (see "Takeover reconciliation") | +| `vendor` | `--check` | — | Offline, read-only artifact and wiring audit; exits 1 on drift. Conflicts with `--revert`. | +| `vendor` | `--local-repo ` | — | With `--check`, also inspect suffixed Maven jar/POM copies in this cache for conflicts. | | `apply`, `scan`, `vendor` | `--vex` | `SOCKET_VEX` | Generate an OpenVEX 0.2.0 document at this path on a successful run; see "embedded VEX" below | | `apply`, `scan`, `vendor` | `--vex-product`, `--vex-no-verify`, `--vex-doc-id`, `--vex-compact` | `SOCKET_VEX_PRODUCT`, `SOCKET_VEX_NO_VERIFY`, `SOCKET_VEX_DOC_ID`, `SOCKET_VEX_COMPACT` | Passthrough to the embedded VEX builder; mirror the standalone `vex` knobs. Inert unless `--vex` is set | -| `scan` | positional `[PATHS]...` | — | (v5.0) Optional path globs scoping DISCOVERY to packages installed under matching paths (`packages/foo`, `apps/**`). Purl-level: a package is in scope when ANY of its installed copies sits under a matching path. Rejected with `--mode hosted`/`--mode vendored` (exit 2, `resolve_mode_flags` — their lockfile rewiring is whole-project by construction); combines with `--apply`/`--sync`/`--prune`. See "Path-scoped scans" below | -| `scan` | `--mode ` | — | The documented selector for the three patch-application modes. Each value is equivalent to one legacy boolean spelling: `hosted` == `--redirect`, `vendored` == `--vendor`, `agent` == `--apply` (`--sync` counts as an agent spelling). Combining `--mode` with a boolean of a DIFFERENT mode is a usage error (exit 2, enforced in `resolve_mode_flags` — clap's `conflicts_with` is value-independent); the same mode spelled both ways is accepted. `--prune` is an orthogonal GC knob and never conflicts — but hosted mode runs no GC, so `--mode hosted --prune` emits an explicit `redirect_prune_ignored` warning (JSON `redirect.warnings[]` + stderr) instead of silently dropping the flag | -| `scan` | `--redirect` | — | Hosted mode's legacy boolean spelling (**hidden from `--help`** and **deprecated** — `--mode hosted` is the documented spelling; this alias is scheduled for removal in v4): rewrite lockfiles / registry configs so ONLY the patched dependencies resolve to Socket's hosted patch server; no artifact bytes land in the repo. Conflicts with `--apply`/`--sync`/`--vendor` | -| `scan` | `--apply` / `--prune` / `--sync` | — | Mode selectors (sync = apply + prune); `--apply` == `--mode agent` | -| `scan` | `--vendor` / `--detached` | — | Vendor every patched dependency instead of applying in place (`--vendor` == `--mode vendored`; conflicts with `--apply`/`--sync`, combines with `--prune`). Vendored mode is manifest-free (v5.0): the vendor ledger embeds the patch records and `.socket/manifest.json` is never written. `--detached` — the former opt-in for exactly that — is **hidden** and retained for compatibility as a no-op; it is still a usage error (exit 2) without vendored mode in either spelling | +| `scan` | positional `[PATHS]...` | — | (v5.0) Meaning depends on the mode. **Hosted / vendored** (bare `scan` included): each PATH, or directory glob (`apps/*`), is a project directory scanned on its own as if it were `--cwd`. **Agent** (and a mode-less `--prune`/`--global` report): path globs scoping DISCOVERY to packages installed under matching paths (`packages/foo`, `apps/**`). See "Path-scoped scans" below | +| `scan` | `--mode ` | — | The documented selector for the three patch-application modes (v5.0 default: `hosted`, except that a `--prune` or `--global`/`--global-prefix` scan with no mode is report-only). v5.0 removes the hidden value aliases `host`/`redirect`/`vendor` (now an invalid-value usage error). `vendored` and `agent` each keep one hidden, deprecated boolean spelling: `vendored` == `--vendor`, `agent` == `--apply` (`--sync` counts as an agent spelling); hosted has none (v5.0 removes `--redirect`). Combining `--mode` with a boolean of a DIFFERENT mode is a usage error (exit 2, enforced in `resolve_mode_flags` — clap's `conflicts_with` is value-independent); the same mode spelled both ways is accepted. `--prune` is an orthogonal GC knob and never conflicts — but hosted mode runs no GC, so `--mode hosted --prune` emits an explicit `redirect_prune_ignored` warning (JSON `redirect.warnings[]` + stderr) instead of silently dropping the flag | +| `scan` | `--apply` / `--prune` / `--sync` | — | `--apply` == `--mode agent` (deprecated spelling); `--prune` = GC after the scan (ignored with a `redirect_prune_ignored` warning in hosted mode); `--sync` = `--mode agent --prune` | +| `scan` | `--package ` (repeatable or comma-separated) | `SOCKET_SCAN_PACKAGES` | (v5.0) Only scan these packages: a name (`lodash`, `@scope/pkg`, `requests`, `group:artifact`; matched against the full name or its last segment, case-insensitively) or a purl with or without a version (`pkg:npm/lodash` matches every version, `pkg:pypi/requests@2.31.0` only that one). Qualifiers are ignored. Filters the crawl like `--ecosystems`, after the prune universe is captured, so `--prune` still judges the full crawl | +| `scan` | `--vendor` | — | Vendor every patched dependency instead of applying in place (`--vendor` == `--mode vendored`; conflicts with `--apply`/`--sync`, combines with `--prune`). Vendored mode is manifest-free (v5.0): the vendor ledger embeds the patch records and `.socket/manifest.json` is never written. The former opt-in for exactly that, `--detached`, is removed in v5.0 (unknown-flag usage error) | | `scan` | `--batch-size` | `SOCKET_BATCH_SIZE` | API batch chunk size. Unset (v5.0): `500` on the authenticated API (the server's per-request maximum), `100` on the public proxy; a given value applies on either endpoint (`0` is floored to `1`). A chunk whose request body would exceed 256 KiB (the public proxy's body cap) is split into consecutive smaller chunks, deterministically (greedy, in crawl order). A mid-run downgrade to the proxy keeps the chunks already formed | +| `scan` | `--max-new-patches ` | `SOCKET_MAX_NEW_PATCHES` | (v5.0) Per-run cap on NEW patches (packages with no recorded patch in the project), most severe first; the rest are deferred to the next scan. `0` admits upgrades only, `none` (case-insensitive) is unlimited, absent is unlimited unless socket.yml sets `patches.maxNewPatches`. Precedence: flag > env > socket.yml > unlimited (`--no-socket-yml` drops the socket.yml layer). The env value is read at run time (the `rollout` block reports `flag` vs `env`): empty is unset, malformed is a usage error (exit 2, before any network access). Upgrades and already-applied patches are never capped. See "Per-run limit on new patches" below | +| `scan` | `--no-socket-yml` | `SOCKET_NO_SOCKET_YML` | (v5.0) Ignore the repository's socket.yml patch policy (its `patches` block and `projectIgnorePaths`) for this run; the built-in test/fixture ignores still apply. The `policy` block reports `source: "bypassed"`. See "socket.yml patch policy". | +| `scan` | `--min-severity ` | `SOCKET_MIN_SEVERITY` | (v5.0) Severity floor for the patch a package may receive (worst advisory severity; unknown severity is skipped whenever a floor is set). Beats `patches.minSeverity`; the flag beats the env; `none` lifts the floor. A malformed value is exit 2. | | `get`, `scan` | `--all-releases` | `SOCKET_ALL_RELEASES` | Download patches for every release/distribution variant of a matched package — PyPI wheel/sdist (`artifact_id`), RubyGems (`platform`), Maven (`classifier`) — not just the one(s) matching the locally-installed distribution. On `scan` this makes the stored manifest portable across environments (e.g. cross-platform CI caches). On `get` (v3.6) it ALSO disables the coarse installed-**version** narrowing of CVE/GHSA fan-outs (see "get --mode and installed narrowing"): every found version's patch is fetched, installed or not | -| `get` | positional `identifier`; `--id` / `--cve` / `--ghsa` / `--package` (`-p`); `--save-only` (alias `--no-apply`); `--one-off` (hidden from `--help`: always fails "not yet implemented"); `--mode ` | `SOCKET_SAVE_ONLY`, `SOCKET_ONE_OFF` | Patch lookup + consumption mode (v3.6). `--mode` reuses scan's value enum (same hidden value aliases `host`/`redirect`/`vendor`; deliberately no env binding, matching scan). Default `agent` = today's save+apply flow, unchanged. `--save-only` conflicts with `--mode hosted\|vendored` — rejected with **exit 1** via get's established self-enforced-conflict style (unlike scan's exit-2 mode conflicts; see the exit-code table) | +| `get` | positional `identifier`; `--id` / `--cve` / `--ghsa` / `--package` (`-p`); `--save-only` (alias `--no-apply`); `--mode ` | `SOCKET_SAVE_ONLY` | Patch lookup + consumption mode (v3.6). `--mode` reuses scan's value enum (same hidden value aliases `host`/`redirect`/`vendor`; deliberately no env binding, matching scan). Default (v5.0): `hosted`, like scan; `agent` (save + apply in place) when `--save-only` or `--global`/`--global-prefix` is given. An explicit `--save-only` conflicts with `--mode hosted\|vendored` — rejected with **exit 1** via get's established self-enforced-conflict style (unlike scan's exit-2 mode conflicts; see the exit-code table) | | `remove` | positional `identifier`; `--skip-rollback`; `--preserve-state` (v5.0) | `SOCKET_SKIP_ROLLBACK`, `SOCKET_PRESERVE_STATE` | Manifest entry removal. `--preserve-state` is the single-patch twin of `rollback --preserve-state`: restore the tree and unwind the identifier's vendored/hosted wiring, but keep the manifest entry, the vendored artifact + ledger entry, and skip all GC. Combining it with `--skip-rollback` is a self-enforced usage error (exit 2): one flag keeps the tree and drops the state, the other restores the tree and keeps the state — together they select the do-nothing quadrant ("the combination would be a no-op: nothing would change"). The conflict fires whether either flag is spelled on the command line or sourced from its env var | -| `rollback` | optional variadic positional `targets` (PURL \| UUID \| path glob); `--one-off`; `--preserve-state` (v5.0) | `SOCKET_ONE_OFF`, `SOCKET_PRESERVE_STATE` | Rollback scope. Multiple targets union. A token becomes a path glob ONLY when it is path-SHAPED — contains a separator (`/` or `\`) or a glob metacharacter (`*?[`), or starts with `./`, or is absolute; a `pkg:` prefix is a PURL and every other bare word keeps identifier (PURL/UUID) semantics, so a mistyped identifier or truncated UUID stays a safe exit-1 "No patch found matching identifier: X" (with a hint suggesting `./X` or `X/**` for directory targeting) instead of silently becoming a path scope. An unparseable glob is a usage error (exit 2) | +| `rollback` | optional variadic positional `targets` (PURL \| UUID \| path glob); `--preserve-state` (v5.0) | `SOCKET_PRESERVE_STATE` | Rollback scope. Multiple targets union. A token becomes a path glob ONLY when it is path-SHAPED — contains a separator (`/` or `\`) or a glob metacharacter (`*?[`), or starts with `./`, or is absolute; a `pkg:` prefix is a PURL and every other bare word keeps identifier (PURL/UUID) semantics, so a mistyped identifier or truncated UUID stays a safe exit-1 "No patch found matching identifier: X" (with a hint suggesting `./X` or `X/**` for directory targeting) instead of silently becoming a path scope. An unparseable glob is a usage error (exit 2) | | `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) | -| `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) | **pnpm hosted-mode contract**: `scan --mode hosted` handles block and flow resolutions in legacy `shrinkwrap.yaml` and lockfileVersion 5.x, 6.0, and 9.0. The [pinned compatibility matrix](../../docs/testing/pnpm-compatibility.md) samples pnpm majors 1–12. Early shrinkwrapVersion 3 without a positive minor version is refused with `redirect_pnpm_legacy_lockfile_unsupported`: pnpm 1.0.0 discards hosted URLs even on frozen installs. Upgrade to a tested release (1.43.1 or newer) and regenerate the lock, or use agent mode. -Each matching package instance is spliced, including scoped, quoted and nested-peer keys, with one `redirect_pnpm_resolution` revert-ledger edit per changed instance. LF/CRLF and unrelated lock bytes are preserved. Unsupported matching instances refuse that dependency across the lockfile set; an already-hosted URL elsewhere cannot confirm a partial rewrite. +Each matching package instance is spliced, including scoped, quoted and nested-peer keys, one `redirect_pnpm_resolution` edit per changed instance (`rollback` / `remove` restore each from the npm registry — see "Hosted unwind coverage"). LF/CRLF and unrelated lock bytes are preserved. Unsupported matching instances refuse that dependency across the lockfile set; an already-hosted URL elsewhere cannot confirm a partial rewrite. -For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLockfile: true` (created with a root-only `packages:` scaffold, or appended while preserving user bytes). pnpm >=11 requires this to accept hosted URLs; it disables registry re-verification for the whole lock, while sha512 tarball integrity remains enforced. The write is ledger-recorded as `redirect_pnpm_workspace_trust`, respects `--dry-run`, skips legacy locks and Rush repos, preserves explicit user settings, and is disabled by `--no-trust-lockfile-config`. The `redirect_pnpm_trust_lockfile` warning explains manual configuration when required and clean reinstall guidance for all pnpm versions. Existing installs and warm stores can retain upstream files; use a clean install tree and empty store, then verify installed files with `socket-patch vex`. Neither a successful install nor a local VEX export guarantees hosted SBOM recognition or changes dashboard alert actions/counts. +For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLockfile: true` (created with a root-only `packages:` scaffold, or appended while preserving user bytes). pnpm >=11 requires this to accept hosted URLs; it disables registry re-verification for the whole lock, while sha512 tarball integrity remains enforced. The write (edit kind `redirect_pnpm_workspace_trust`) respects `--dry-run`, skips legacy locks and Rush repos, preserves explicit user settings, and is disabled by `--no-trust-lockfile-config`. The `redirect_pnpm_trust_lockfile` warning explains manual configuration when required and clean reinstall guidance for all pnpm versions. Existing installs and warm stores can retain upstream files; use a clean install tree and empty store, then verify installed files with `socket-patch vex`. Neither a successful install nor a local VEX export guarantees hosted SBOM recognition or changes dashboard alert actions/counts. -**npm hosted-mode `allow-remote` contract**: npm >=12 defaults `allow-remote=none` and refuses (EALLOWREMOTE) every lockfile entry whose `resolved` tarball is not served by the configured registry — exactly what a hosted redirect writes into `package-lock.json` / `npm-shrinkwrap.json`. Whenever a run leaves a ROOT npm lock carrying a granted hosted artifact URL (spliced this run, or already redirected by an earlier one — a missed config heals on re-run), the CLI ensures `allow-remote=all` in the project-root `.npmrc`: the file is created holding exactly `allow-remote=all\n` when absent, otherwise one `allow-remote=all` line is spliced in after the last non-empty top-level line (before any ini `[section]` header), in the file's own line ending, with the BOM, CRLF and trailing-newline shape preserved. The write lands in `redirect.rewrittenFiles` and is ledger-recorded as `redirect_npmrc_allow_remote` (`path: ".npmrc"`, `key: "allow-remote"`, `new: "all"`; `action: "created"` for a new file, `"added"` for a spliced line), respects `--dry-run` (nothing written; the warning says what would be — including for a vendored → hosted takeover the dry run only previews), and is disabled by `--no-npm-allow-remote-config` / `SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG`. The `.npmrc` grammar is npm's own `ini` parser's (cross-checked against it): lines split on any run of `\r` / `\n` (a bare `\r` ends a line), only the exact key `allow-remote` counts after ini unquoting (npm ignores `allow_remote` / `ALLOW-REMOTE` in a `.npmrc`; such a line is left alone and the real key appended), comment lines are ignored, a `[section]` header is recognized only as npm does — on the UNTRIMMED line (an indented or BOM-prefixed `[sec]` is a plain top-level key) — and ends the top-level scope, quotes and inline comments are stripped, the LAST top-level assignment wins, and the value is case-sensitive. An explicit other value (`allow-remote=none` / `root` / anything but `all`) is RESPECTED and never rewritten — the pnpm `trustLockfile: false` precedent — in the project `.npmrc` AND in every other npm config layer npm would consult: an `npm_config_allow_remote` environment variable (any spelling npm normalizes; it beats every `.npmrc`, so a project write could not take effect), and — when the project file sets nothing — the user (`npm_config_userconfig` / `~/.npmrc`), global (`npm_config_globalconfig` / `/etc/npmrc`, prefix from `npm_config_prefix`, the user/builtin config, `PREFIX` or the `node` binary's install root) and builtin (npm's own `npmrc` beside the `node` binary: `/lib/node_modules/npm/npmrc`, `\node_modules\npm\npmrc` on Windows) config files — path values `${VAR}`-expanded and `~`-expanded like npm, env names case-insensitive on Windows, where a committed project line would silently override a machine / org policy. A symlinked, non-regular or unreadable `.npmrc`, or one with bare-`\r` line endings (npm splits on them, the line splice does not), is left untouched. Every variant emits the `redirect_npm_allow_remote` warning (written / would write / already set / explicit value respected — naming the project file, the env var, or the user/global/builtin config path — / opted out / unreadable or unsupported), always with the tradeoff: `allow-remote=all` lets npm install ANY url-resolved dependency, not just Socket's patched ones, while the per-entry sha512 integrity pins stay enforced; the remedy for the non-writing variants is `allow-remote=all` in `.npmrc` or `npm ci --allow-remote=all`. npm <=11 is unaffected (11 defaults to `all`, <=10 has no such setting). **Unwind**: the edit is removed exactly once no `redirect_npm_lock_entry` / `redirect_npm_lock_dep` edit remains in the ledger — by the per-purl npm revert of the LAST package-lock entry (scoped `rollback ` / `remove `, the hosted → vendored takeover), by the whole-ledger replay (`rollback` / `remove`, `npm` group — a refused package-lock edit keeps the setting it needs), and by the vendored-supersedes-hosted reconcile. A `created` file still holding exactly `allow-remote=all\n` is deleted; otherwise only the one top-level `allow-remote=all` line is removed, user edits kept (a modified created file warns `redirect_npmrc_allow_remote_modified`, surfaced in the `warnings[]` of `rollback`, `remove`, `vendor` and the vendored reconcile, and as a `Warning (): …` stderr line in human mode); copies under an ini `[section]` are inert to npm and never counted, and a duplicated TOP-LEVEL line refuses fail-closed (ambiguous). A symlinked or non-regular `.npmrc` refuses the unwind while it is still being PLANNED, so the revert writes nothing (never a reverted lock behind a ledger that still records the redirect). The rewrite's stage file is created with the `.npmrc`'s own permission bits (a 0600 token-bearing file is never staged world-readable). **Vendored mode is unaffected**: its `file:.socket/vendor/…` resolutions are npm `file` specs, which npm gates by `allow-file` (default `all`), never `allow-remote` — verified by the real npm 12 vendored matrix. +**npm hosted-mode `allow-remote` contract**: npm >=12 defaults `allow-remote=none` and refuses (EALLOWREMOTE) every lockfile entry whose `resolved` tarball is not served by the configured registry — exactly what a hosted redirect writes into `package-lock.json` / `npm-shrinkwrap.json`. Whenever a run leaves a ROOT npm lock carrying a granted hosted artifact URL (spliced this run, or already redirected by an earlier one — a missed config heals on re-run), the CLI ensures `allow-remote=all` in the project-root `.npmrc`: the file is created holding exactly `allow-remote=all\n` when absent, otherwise one `allow-remote=all` line is spliced in after the last non-empty top-level line (before any ini `[section]` header), in the file's own line ending, with the BOM, CRLF and trailing-newline shape preserved. The write lands in `redirect.rewrittenFiles` (edit kind `redirect_npmrc_allow_remote`), respects `--dry-run` (nothing written; the warning says what would be — including for a vendored → hosted takeover the dry run only previews), and is disabled by `--no-npm-allow-remote-config` / `SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG`. The `.npmrc` grammar is npm's own `ini` parser's (cross-checked against it): lines split on any run of `\r` / `\n` (a bare `\r` ends a line), only the exact key `allow-remote` counts after ini unquoting (npm ignores `allow_remote` / `ALLOW-REMOTE` in a `.npmrc`; such a line is left alone and the real key appended), comment lines are ignored, a `[section]` header is recognized only as npm does — on the UNTRIMMED line (an indented or BOM-prefixed `[sec]` is a plain top-level key) — and ends the top-level scope, quotes and inline comments are stripped, the LAST top-level assignment wins, and the value is case-sensitive. An explicit other value (`allow-remote=none` / `root` / anything but `all`) is RESPECTED and never rewritten — the pnpm `trustLockfile: false` precedent — in the project `.npmrc` AND in every other npm config layer npm would consult: an `npm_config_allow_remote` environment variable (any spelling npm normalizes; it beats every `.npmrc`, so a project write could not take effect), and — when the project file sets nothing — the user (`npm_config_userconfig` / `~/.npmrc`), global (`npm_config_globalconfig` / `/etc/npmrc`, prefix from `npm_config_prefix`, the user/builtin config, `PREFIX` or the `node` binary's install root) and builtin (npm's own `npmrc` beside the `node` binary: `/lib/node_modules/npm/npmrc`, `\node_modules\npm\npmrc` on Windows) config files — path values `${VAR}`-expanded and `~`-expanded like npm, env names case-insensitive on Windows, where a committed project line would silently override a machine / org policy. A symlinked, non-regular or unreadable `.npmrc`, or one with bare-`\r` line endings (npm splits on them, the line splice does not), is left untouched. Every variant emits the `redirect_npm_allow_remote` warning (written / would write / already set / explicit value respected — naming the project file, the env var, or the user/global/builtin config path — / opted out / unreadable or unsupported), always with the tradeoff: `allow-remote=all` lets npm install ANY url-resolved dependency, not just Socket's patched ones, while the per-entry sha512 integrity pins stay enforced; the remedy for the non-writing variants is `allow-remote=all` in `.npmrc` or `npm ci --allow-remote=all`. npm <=11 is unaffected (11 defaults to `all`, <=10 has no such setting). **Unwind (v5.0: no ledger)**: once `rollback`, `remove` or a hosted → vendored takeover has restored the last hosted entry of the root `package-lock.json` / `npm-shrinkwrap.json` to its upstream registry entry (see "Hosted unwind coverage"), a project `.npmrc` holding exactly `allow-remote=all\n` (the file hosted mode creates) is deleted; any other `.npmrc` that still has a top-level `allow-remote=all` line is left untouched and the `npm_allow_remote_left` warning says the line may be removed if nothing else needs it (v5 keeps no record of whether hosted mode added it, so it is never removed behind the user's back). The rewrite's stage file is created with the `.npmrc`'s own permission bits (a 0600 token-bearing file is never staged world-readable). **Vendored mode is unaffected**: its `file:.socket/vendor/…` resolutions are npm `file` specs, which npm gates by `allow-file` (default `all`), never `allow-remote` — verified by the real npm 12 vendored matrix. `redirect_pnpm_no_lockfile` names pnpm when installer markers exist without a lock; `redirect_pnpm_entry_vendored` identifies a vendored entry instead of reporting it missing. Supported `shrinkwrap.yaml` files are writable lockfiles, not read-only markers. -**vlt hosted-mode contract**: `scan` / `get --mode hosted` rewrite, in `vlt-lock.json`, every default-registry node of a granted `name@version` (the `''` / `npm` segment or a URL segment equal to the lock's scalar `registry`, both DepID grammars, every peer and modifier variant): slot [2] becomes the granted sha512 and slot [3] the hosted URL (appended to a 3-tuple); the DepID, flags and trailing slots, the line ending and every other byte stay. One `redirect_vlt_lock_node` ledger edit per changed node records the entry text (`"": `, no indent, comma or `\r`). `options` is never edited and `vlt.json` is only read. A lock with another `lockfileVersion` (decided on the raw JSON token), a BOM, a non-object body or a `nodes` section outside vlt's one-node-per-line layout refuses the whole lock (`redirect_vlt_lock_unsupported`). **Confirmation**: vlt drives when its install state (`node_modules/.vlt-lock.json` or `node_modules/.vlt/`) is present or no other npm-family lock is; then only `vlt-lock.json` confirms a uuid. Otherwise every lock is rewritten, `redirect_vlt_sibling_lockfiles` warns, and the other locks' rules confirm, including a dep `vlt-lock.json` merely does not wire (`redirect_vlt_entry_not_found`, `redirect_vlt_entry_vendored`). Whichever lock drives, a dep the vlt rewriter refuses (`redirect_vlt_missing_sha512`, `redirect_vlt_unsupported_lock_key`) is never confirmed by any lock, although a sibling lock may already carry its rewritten URL. **Artifact preflight**: before any takeover or write (dry runs included), each granted artifact with a default-registry instance is fetched once as vlt fetches it and must verify, else the dep is withheld (`redirect_vlt_artifact_unverifiable`, see the tag table). **Heal**: stale installed copies of Socket-owned nodes are removed so the next `vlt install` extracts the patched bytes, and `rollback` / `remove` do the same for the registry bytes (`--no-vlt-install-cleanup` keeps them; optional dependencies' copies are always kept); `redirect_vlt_reinstall_required` says what happened and what to run. The same-run `--vex` never attests a vlt package whose installed copy is stale or unchecked, whose lock a vlt release may ignore (`redirect_vlt_lockfile_version_missing`, `redirect_vlt_old_lockfile_ignored`, `redirect_vlt_scalar_registry_ignored`), or which also resolves from a non-default registry (`redirect_vlt_custom_registry_skipped`). `vlt.json` or vlt install state without `vlt-lock.json` warns `redirect_vlt_no_lockfile` instead of `redirect_npm_no_lockfile`. vlt ledgers require the socket-patch release that adds vlt support. Tested releases: `docs/testing/vlt-compatibility.md`. +**vlt hosted-mode contract**: `scan` / `get --mode hosted` rewrite, in `vlt-lock.json`, every default-registry node of a granted `name@version` (the `''` / `npm` segment or a URL segment equal to the lock's scalar `registry`, both DepID grammars, every peer and modifier variant): slot [2] becomes the granted sha512 and slot [3] the hosted URL (appended to a 3-tuple); the DepID, flags and trailing slots, the line ending and every other byte stay. `options` is never edited and `vlt.json` is only read. A lock with another `lockfileVersion` (decided on the raw JSON token), a BOM, a non-object body or a `nodes` section outside vlt's one-node-per-line layout refuses the whole lock (`redirect_vlt_lock_unsupported`). **Confirmation**: vlt drives when its install state (`node_modules/.vlt-lock.json` or `node_modules/.vlt/`) is present or no other npm-family lock is; then only `vlt-lock.json` confirms a uuid. Otherwise every lock is rewritten, `redirect_vlt_sibling_lockfiles` warns, and the other locks' rules confirm, including a dep `vlt-lock.json` merely does not wire (`redirect_vlt_entry_not_found`, `redirect_vlt_entry_vendored`). Whichever lock drives, a dep the vlt rewriter refuses (`redirect_vlt_missing_sha512`, `redirect_vlt_unsupported_lock_key`) is never confirmed by any lock, although a sibling lock may already carry its rewritten URL. **Artifact preflight**: before any takeover or write (dry runs included), each granted artifact with a default-registry instance is fetched once as vlt fetches it and must verify, else the dep is withheld (`redirect_vlt_artifact_unverifiable`, see the tag table). **Heal**: stale installed copies of Socket-owned nodes are removed so the next `vlt install` extracts the patched bytes, and `rollback` / `remove` do the same for the registry bytes (`--no-vlt-install-cleanup` keeps them; optional dependencies' copies are always kept); `redirect_vlt_reinstall_required` says what happened and what to run. The same-run `--vex` never attests a vlt package whose installed copy is stale or unchecked, whose lock a vlt release may ignore (`redirect_vlt_lockfile_version_missing`, `redirect_vlt_old_lockfile_ignored`, `redirect_vlt_scalar_registry_ignored`), or which also resolves from a non-default registry (`redirect_vlt_custom_registry_skipped`). `vlt.json` or vlt install state without `vlt-lock.json` warns `redirect_vlt_no_lockfile` instead of `redirect_npm_no_lockfile`. `rollback` / `remove` restore each hosted node's slots [2] and [3] from the npm registry, following the lock's own slot-[3] convention (see "Hosted unwind coverage"). Tested releases: `docs/testing/vlt-compatibility.md`. -**Takeover reconciliation (npm family, bun and vlt included)**: vendoring over a hosted-redirected purl (`vendor`, `scan --mode vendored`, `get --mode vendored`) first REVERTS that purl's hosted lockfile edits to their pre-redirect registry values through the per-purl redirect revert, drops the purl's record + package edits from `redirect-state.json`, and then vendors — so the vendor ledger records the PRISTINE registry fragment as its wiring `original` and `vendor --revert` lands back on registry state, never on an expiring hosted URL. The run that takes over records a `vendor_takeover_reverted_redirect` advisory event (`skipped` action beside the purl's genuine outcome; the human path prints `Warning (vendor_takeover_reverted_redirect): …`). `--dry-run` PROBES the same revert against an in-memory ledger clone instead of promising it: a clean probe reports `vendor_would_revert_redirect`, and a drifted lock or an undecidable ledger edit surfaces in the preview with the wet run's `redirect_revert_failed` code and detail (for bun, whose hosted rewrite replaces the entry's `name@version` spec, the preview first runs the Bun vendored preflight described below and then stops at the advisory instead of reading the still-hosted lock — a lock the vendored backend would refuse is previewed as the wet run's `failed `, never as `vendor_would_revert_redirect`). A purl whose hosted edits cannot be cleanly reverted fails `redirect_revert_failed` (exit 1 / `partial_failure`, nothing vendored for it, the hosted wiring left in place, the remedy in the detail). **bun** participates like every other npm-family flavor: binary `redirect_bun_lockb_package` snapshots are claimed by their recorded package identity and restore individual binary resolutions; its text `redirect_bun_lock_package` edits are claimed by the recorded line's spec — the registry spec `@`, or a hosted URL whose tarball leaf is `-.tgz` — so a sibling version's or an aliased sibling's edit is neither claimed nor a refusal, and only an edit that mentions the package without being a bun packages-entry line refuses (remedy: an unscoped `socket-patch rollback`, whose whole-ledger replay unwinds bun.lock hosted edits; never hand-edit the ledger). The same claim rule serves scoped `rollback ` / `remove ` of one of several hosted bun records (see "Hosted unwind coverage"). Hosted → vendored and vendored → hosted (`redirect_takeover_reverted_vendored` in `redirect.warnings[]`) both work in place on bun locks the target mode accepts. **Bun vendored preflight before the takeover**: `vendor` — like `scan` / `get --mode vendored`, whose pre-download preflight runs earlier — checks `bun.lock` / `bun.lockb` with the shared Bun vendored preflight BEFORE the per-purl hosted revert, so a hosted-redirected purl on a lock the vendored backend refuses (a pre-version-2 `workspace:` lock → `vendor_bun_workspace_unsupported`; a malformed or unsupported binary lock → `vendor_bun_lockb_invalid`; an unsupported text-lock version → its code) is reported `failed ` with the hosted wiring, the redirect ledger and active Bun lock byte-untouched (exit 1 / `partial_failure`): the package stays hosted-patched instead of being un-hosted and then refused. `vendor --dry-run` previews that same `failed` code (exit-code parity with the wet run, nothing written) instead of promising `vendor_would_revert_redirect`. Pinned by `tests/in_process_vendor_bun_takeover.rs` and, against real Bun, `tests/mode_migration_bun.rs`. **golang** takes over the same way: the per-purl revert drops the module's hosted `replace`, removes the socket module's go.sum lines, puts the pruned upstream go.sum lines back in go's sort order, and drops the ledger record, so the vendored `replace` is recorded over pristine go.mod/go.sum (a go.mod whose replace for the module is no longer the recorded one refuses `redirect_revert_failed`). The separate run-level `vendor_supersedes_redirect` warning covers the reconcile-only case — a live lock that already proves vendored won over a stale hosted ledger record (the vendor wiring then holds the hosted-spliced fragment as `original`) — and fires exactly once, on the run that drops the stale records. Which way the live lock points is decided by the same lockfile discovery and ledger-liveness rules `vex` gates attestations on (see "Manifest-less VEX (lockfile discovery)"), for this warning, its `redirect_supersedes_vendored` twin and `hosted_wiring_retained` alike. +**Takeover reconciliation (every hosted ecosystem, v5.0)**: vendoring over a hosted pin (`vendor`, `scan --mode vendored`, `get --mode vendored`) first RESTORES that purl's lock entries to their default upstream registry entry — the same restore `rollback` runs (core `patch::redirect::upstream::restore_upstream`; see "Hosted unwind coverage"), over the hosted pins lockfile discovery finds (v5 keeps no hosted ledger) — and then vendors, so the vendor ledger records the PRISTINE registry entry as its wiring `original` and `vendor --revert` lands back on upstream registry state, never on hosted. The run that takes over records a `vendor_takeover_reverted_redirect` advisory event (`skipped` action beside the purl's genuine outcome; detail ` was hosted; restored its upstream registry entry () before vendoring (mode takeover)`; the human path prints `Warning: …`), plus any advisory the restore raised (`npm_allow_remote_left`, …). `--dry-run` resolves the same restore without writing (registry lookups included): a pin that would restore reports `vendor_would_revert_redirect`, and one that would be refused surfaces in the preview with the wet run's `redirect_revert_failed` code and detail (for bun, whose hosted rewrite replaces the entry's `name@version` spec, the preview first runs the Bun vendored preflight described below and then stops at the advisory instead of reading the still-hosted lock — a lock the vendored backend would refuse is previewed as the wet run's `failed `, never as `vendor_would_revert_redirect`). A purl whose upstream entry cannot be restored — `--offline`, a registry that does not answer, a lock the restore refuses (see "Hosted unwind coverage"; a hosted binary `bun.lockb` pin IS restored for the takeover — its npm registry record is rebuilt natively — while `rollback` / `remove` refuse it) — fails `redirect_revert_failed` with the detail `cannot vendor over the live hosted pin: cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)` (exit 1 / `partial_failure`, nothing vendored for it, the hosted wiring left in place). The cargo backend's `hosted_redirect_live` refusal backstops a crate whose hosted residue is still in place when it is reached; its detail names `socket-patch rollback` and `git checkout -- Cargo.toml Cargo.lock`. **Bun vendored preflight before the takeover**: `vendor` — like `scan` / `get --mode vendored`, whose pre-download preflight runs earlier — checks `bun.lock` / `bun.lockb` with the shared Bun vendored preflight BEFORE the upstream restore, so a hosted purl on a lock the vendored backend refuses (a pre-version-2 `workspace:` lock → `vendor_bun_workspace_unsupported`; a malformed or unsupported binary lock → `vendor_bun_lockb_invalid`; an unsupported text-lock version → its code) is reported `failed ` with the hosted wiring and active Bun lock byte-untouched (exit 1 / `partial_failure`): the package stays hosted-patched instead of being un-hosted and then refused. `vendor --dry-run` previews that same `failed` code (exit-code parity with the wet run, nothing written) instead of promising `vendor_would_revert_redirect`. Pinned by `tests/in_process_vendor_bun_takeover.rs` and, against real Bun, `tests/mode_migration_bun.rs`. Hosted → vendored and vendored → hosted (`redirect_takeover_reverted_vendored` in `redirect.warnings[]`) both work in place on the locks the target mode accepts. **Removed in v5.0**: the run-level `vendor_supersedes_redirect` warning and its reconcile of the redirect ledger (a live lock that already proved vendored won over a stale hosted ledger record) — once the lock routes a package to `.socket/vendor/`, no hosted state is left to go stale. Which way the live lock points is decided by the same lockfile discovery rules `vex` gates attestations on (see "Manifest-less VEX (lockfile discovery)"), for `redirect_supersedes_vendored` and `hosted_wiring_retained` alike. -`scan --apply` opts JSON callers into the full discover → select → apply pipeline. Without it, `scan --json` stays read-only (discovery + the `updates` array + the `redirectState` state block below). No effect outside `--json` mode. The non-JSON path prompts the user interactively in a TTY; when stdin is NOT a TTY (CI, a pipe), `--yes` is absent, and no intent flag (`--mode`, `--apply`, `--sync`, `--vendor`, `--redirect`, `--prune`) is given, a human-mode `scan` is **report-only** (v5.0): it prints the discovery report and the existing "To apply a single patch, run: …" hint, downloads nothing, writes nothing (no `.socket/`), and exits 0. Any intent flag, `--yes`, or a TTY keeps the previous behavior (prompt in a TTY, auto-proceed otherwise). Only `scan` gained this pre-check — `rollback`/`remove`/`get`'s non-TTY auto-accept is unchanged. +### Scan modes (v5.0) -**Hosted-state visibility (`redirectState`, additive/MINOR).** Every non-hosted-mode, non-vendored-mode `scan --json` SUCCESS envelope (report-only, `--mode agent`/`--apply`/`--sync`, and the zero-discovery envelope) carries an additive top-level `redirectState` object whenever the hosted redirect ledger (`.socket/vendor/redirect-state.json`) holds ≥ 1 `records` entry: `{ mode, ledger, records: [{purl, ledgerKey, uuid}], wiringLive: [purl] }`. It is a descriptive STATE block, not a warning — a hosted-wired project's report-only scan used to be byte-identical to a never-touched project's. `mode` is the constant `"hosted"` (the mode's documented name, whatever opaque `mode` string the ledger itself carries — pre-rename ledgers say `"redirect"`) and `ledger` the ledger's repo-relative path. `records` lists every ledger record (sorted by ledger key): each entry's `purl` is CANONICALIZED (qualifiers stripped, percent-decoded — e.g. `pkg:npm/@scope/pkg@1.0.0`, `pkg:gem/nokogiri@1.13.3`) to the same spelling `wiringLive` carries, so the records↔proof join is a plain string compare, and `ledgerKey` preserves the ledger's verbatim key (percent-encoded scoped names, `?platform=` qualifiers) for consumers addressing the ledger itself. `wiringLive` is the subset of this run's *counted* purls (post-`--ecosystems`-filter) whose hosted lockfile wiring the LIVE lock still proves — the same proof, computed once per run, that feeds `hosted_wiring_retained`, and the same liveness rule `vex` applies to a redirect-ledger record (see "Manifest-less VEX (lockfile discovery)"). Consumers must treat the split as exactly that: records are the ledger's word, `wiringLive` the live lock's proof — a record with no proof means the wiring was unwound, the lock is unreadable, or the purl was not crawled/queried this run (an `--ecosystems` filter, a zero discovery), never "still live". The key is omitted when the ledger is absent or its `records` are empty (an edits-only ledger asserts no patches), and error envelopes (the `--offline` refusal, all-batches-failed) are deliberately minimal and never carry it. A malformed ledger degrades to "nothing to consult" (no block) with a stderr warning, muted by `--silent`. Hosted-mode runs carry the `redirect` sub-object instead (the run's own result; the ledger is re-persisted mid-run), and vendored-mode runs carry the takeover warnings (their reconciliation may retire records mid-run) — neither duplicates a pre-run snapshot that could go stale. +**Mode resolution (`resolve_mode_flags`, MAJOR in v5.0).** `--mode`, or one of its legacy boolean spellings (`--vendor`, `--apply`/`--sync`), picks the mode. With none of them, `scan` runs **hosted** mode — JSON and human alike; the result nests under the JSON `redirect` sub-object (see the hosted paragraph below). The one exception: a `--prune` or `--global`/`--global-prefix` scan with no mode has no project lockfile to rewire, so it is **report-only** — discovery, the table, the `updates` array and the `redirectState` block below, plus the `--prune` GC — and, in human mode, ends with the hint `To apply these patches in place, run:` / ` socket-patch scan --mode agent [PATHS]` / ` socket-patch get `. An explicit `--mode hosted` with `--global`/`--global-prefix` is a usage error (exit 2: global installs have no project lockfile to redirect). -**Agent-flow run-level warnings (additive).** An agent-mode apply (`--mode agent` / `--apply` / `--sync`, `--json`) may add a top-level `warnings[]` array of `{code, detail}` entries to the scan envelope (absent when none fired; each is also mirrored to stderr unless `--silent`). They surface cross-mode state the apply cannot change — never a status or exit-code change (hosted refusals set the precedent: exit 0 + warning). Codes (stable; new codes are additive/MINOR): `vendored_ownership_retained` — vendor-owned package(s) were skipped before download (the per-patch `skipped`/`vendored` records in `apply.patches[]` are unchanged); the detail names the purls and the migration path (`remove `, or `vendor --revert` which unwinds every vendored package, then re-run). `hosted_wiring_retained` — the hosted redirect ledger records scanned package(s) whose hosted lockfile wiring the live lock still proves (the agent run does not unwind hosted wiring — as of v5.0 that is `socket-patch rollback`'s job, or `remove ` per package); the detail names the purls and the options (stay `--mode hosted`, or migrate via `scan --mode vendored`) and never advises hand-deleting the ledger. The warning keys on ledger *records* still live at scan time — a flow that pre-reverted the redirect (retiring the records) retires the warning with them, even while the append-only `edits` (revert originals) remain. The interactive path prints the same `hosted_wiring_retained` text to stderr after an apply; the vendored counterpart is already covered by its per-package `[skip] … (vendored …)` lines. `ownership_not_restored` (v5.0; `apply` and `rollback` `warnings[]` alike) — a file WAS patched (or restored) but its ownership could not be put back to the original uid/gid (the mode is still restored last); the detail is `: : patched, but ownership could not be restored to uid N gid M: ` and the human line `Warning (ownership_not_restored): ` (stderr, muted by `--silent`); never a status or exit change. +**scan never prompts, in any mode** (v5.0): no confirm, no free-tier patch menu (it always takes the top-ranked downloadable patch; see "Which patch gets selected"), and no `Non-interactive mode detected` note. `--yes` does not change a scan. `get` (agent mode only — hosted/vendored `get` never prompts either, v5.0), `rollback`, `remove` and `--update` keep their prompts. -`scan --prune` opts into garbage collection. When set, `scan` removes manifest entries for packages no longer present in the crawl, then deletes orphan blob, diff, and package-archive files from `.socket/`. Off by default (v3.0) so a temporary uninstall doesn't silently destroy manifest state. Only entries whose ecosystem this run actually crawled are eligible: a `pkg:/` with no crawler in this build (a newer CLI's ecosystem in the committed manifest) and the runtime-gated maven/nuget crawlers with their gate off are exempt — the crawl never looked for them, so their absence is not evidence of removal (same fail-safe as the `--ecosystems` filter, which narrows the query but never the prune's installed set). The pass also reconciles vendored state (runs FIRST, under ONE apply-lock acquisition shared with the manifest prune — lock contention skips the whole pass without failing the scan; `--lock-timeout` is honored and a lock I/O error is reported rather than swallowed; the existence gate — a manifest file OR a vendor ledger file, both cheap stats; an emptied ledger is deleted on save, so its presence is its content proxy — runs BEFORE the lock, so a bare project never gets a `.socket/`; in the vendored scan arms the pass runs AFTER the vendor step): (a) ledger entries still tracked by a manifest record (manifest-mode entries written by standalone `vendor`) whose patch is gone from the manifest are reverted — `detached` entries (every `scan`/`get --mode vendored` entry, v5.0) have no manifest record to lose and are exempt from this leg; (b) EVERY ledger entry whose dependency is no longer in the lockfile graph is reverted and any manifest entry it still had dropped (v5.0: the check is about the lockfile, not the manifest, so embedded-record entries are no longer exempt; a missing or undeterminable lockfile keeps the entry, fail-safe); and (c) orphan `.socket/vendor//` dirs with no ledger entry are swept. The prune never deletes a zero-patch `.socket/manifest.json` (its `{"patches": {}}` + `setup` block stay). The JSON `gc` sub-object gains `revertedVendoredEntries` + `keptVendoredEntries` + `failedVendoredEntries` + `removedVendorOrphanDirs` (wet) / `revertableVendoredEntries` + `vendorOrphanDirs` (preview), plus two ADDITIVE wet-only keys: `skipped: {code, message}` — present exactly when the pass was skipped at the lock (`lock_held` | `lock_io`; every count is then zero) — and `warnings: [{code, detail}]` — `vendor_state_write_failed` / `manifest_write_failed` (entries were reverted but the ledger or manifest rewrite failed) and `cleanup_failed` (an orphan sweep failed mid-way). Human mode prints `GC: skipped (): .`, one `GC: .` line per warning, and `GC: failed to revert N vendored entries: …` (singular for one) for `failedVendoredEntries`. `keptVendoredEntries` lists drift-kept entries the revert deliberately preserved (`vendor_artifact_kept` — undo the drift and re-run `vendor --revert` to finish); the preview cannot see drift (backends return before the wiring replay on dry runs), so `revertableVendoredEntries` may over-promise what a wet run will actually reclaim. +**Hosted-state visibility (`redirectState`, additive/MINOR).** Every non-hosted-mode, non-vendored-mode `scan --json` SUCCESS envelope (report-only, `--mode agent`/`--apply`/`--sync`, and the zero-discovery envelope) carries an additive top-level `redirectState` object whenever the project's lockfiles pin ≥ 1 hosted patch: `{ mode, records: [{purl, uuid}], wiringLive: [purl] }`. It is a descriptive STATE block, not a warning. `mode` is the constant `"hosted"`. **v5.0 (MAJOR shape change)**: hosted mode keeps no ledger, so `records` lists the hosted pins lockfile discovery finds (one per `(purl, uuid)`, the same discovery `vex` uses: a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` origin), and the v4 `ledger` and `records[].ledgerKey` keys are gone. Each record's `purl` is CANONICALIZED (qualifiers stripped, percent-decoded — e.g. `pkg:npm/@scope/pkg@1.0.0`, `pkg:gem/nokogiri@1.13.3`) to the same spelling `wiringLive` carries, so the join is a plain string compare. `wiringLive` is the subset of those pins among this run's *counted* purls (post-`--ecosystems`-filter) — computed once per run, the same set that feeds `hosted_wiring_retained`. A record missing from `wiringLive` is still wired; it just was not crawled/queried this run (an `--ecosystems` filter, a zero discovery). The key is omitted when no lockfile pins a hosted patch (and under `--global`), and error envelopes (the `--offline` refusal, all-batches-failed) are deliberately minimal and never carry it. A pre-v5 `.socket/vendor/redirect-state.json` is not read. Hosted-mode runs carry the `redirect` sub-object instead (the run's own result), and vendored-mode runs carry the takeover warnings (their takeovers may restore pins mid-run) — neither duplicates a pre-run snapshot that could go stale. + +**Agent-flow run-level warnings (additive).** An agent-mode apply (`--mode agent` / `--apply` / `--sync`, `--json`) may add a top-level `warnings[]` array of `{code, detail}` entries to the scan envelope (absent when none fired; each is also mirrored to stderr unless `--silent`). They surface cross-mode state the apply cannot change — never a status or exit-code change (hosted refusals set the precedent: exit 0 + warning). Codes (stable; new codes are additive/MINOR): `vendored_ownership_retained` — vendor-owned package(s) were skipped before download (the per-patch `skipped`/`vendored` records in `apply.patches[]` are unchanged); the detail names the purls and the migration path (`remove `, or `vendor --revert` which unwinds every vendored package, then re-run). `hosted_wiring_retained` — the lockfiles still pin scanned package(s) to a hosted patch (the agent run does not unwind hosted wiring — as of v5.0 that is `socket-patch rollback`'s job, which restores the upstream registry entries, or `remove ` per package); the detail names the purls and the options (stay `--mode hosted`, migrate via `scan --mode vendored`, or `socket-patch rollback`). The warning keys on the hosted pins lockfile discovery finds at scan time, so a flow that restored the upstream entries retires it. The human path prints the same `hosted_wiring_retained` text to stderr after an apply; the vendored counterpart is already covered by its per-package `[skip] … (vendored …)` lines. `ownership_not_restored` (v5.0; `apply` and `rollback` `warnings[]` alike) — a file WAS patched (or restored) but its ownership could not be put back to the original uid/gid (the mode is still restored last); the detail is `: : patched, but ownership could not be restored to uid N gid M: ` and the human line `Warning: ` (stderr, muted by `--silent`); never a status or exit change. + +`scan --prune` opts into garbage collection. When set, `scan` removes manifest entries for packages no longer present in the crawl, then deletes orphan blob and diff-archive files, and every legacy package archive, from `.socket/`. Off by default (v3.0) so a temporary uninstall doesn't silently destroy manifest state. Only entries whose ecosystem this run actually crawled are eligible: a `pkg:/` with no crawler in this build (a newer CLI's ecosystem in the committed manifest) is exempt — the crawl never looked for them, so their absence is not evidence of removal (same fail-safe as the `--ecosystems` filter, which narrows the query but never the prune's installed set). The pass also reconciles vendored state (runs FIRST, under ONE apply-lock acquisition shared with the manifest prune — lock contention skips the whole pass without failing the scan; `--lock-timeout` is honored and a lock I/O error is reported rather than swallowed; the existence gate — a manifest file OR a vendor ledger file, both cheap stats; an emptied ledger is deleted on save, so its presence is its content proxy — runs BEFORE the lock, so a bare project never gets a `.socket/`; in the vendored scan arms the pass runs AFTER the vendor step): (a) ledger entries still tracked by a manifest record (manifest-mode entries written by standalone `vendor`) whose patch is gone from the manifest are reverted — `detached` entries (every `scan`/`get --mode vendored` entry, v5.0) have no manifest record to lose and are exempt from this leg; (b) EVERY ledger entry whose dependency is no longer in the lockfile graph is reverted and any manifest entry it still had dropped (v5.0: the check is about the lockfile, not the manifest, so embedded-record entries are no longer exempt; a missing or undeterminable lockfile keeps the entry, fail-safe); and (c) orphan `.socket/vendor//` dirs with no ledger entry are swept. The prune never deletes a zero-patch `.socket/manifest.json` (its `{"patches": {}}` + `setup` block stay). The JSON `gc` sub-object gains `revertedVendoredEntries` + `keptVendoredEntries` + `failedVendoredEntries` + `removedVendorOrphanDirs` (wet) / `revertableVendoredEntries` + `vendorOrphanDirs` (preview), plus two ADDITIVE wet-only keys: `skipped: {code, message}` — present exactly when the pass was skipped at the lock (`lock_held` | `lock_io`; every count is then zero) — and `warnings: [{code, detail}]` — `vendor_state_write_failed` / `manifest_write_failed` (entries were reverted but the ledger or manifest rewrite failed) and `cleanup_failed` (an orphan sweep failed mid-way). Human mode prints `GC: skipped (): .`, one `GC: .` line per warning, and `GC: failed to revert N vendored entries: …` (singular for one) for `failedVendoredEntries`. `keptVendoredEntries` lists drift-kept entries the revert deliberately preserved (`vendor_artifact_kept` — undo the drift and re-run `vendor --revert` to finish); the preview cannot see drift (backends return before the wiring replay on dry runs), so `revertableVendoredEntries` may over-promise what a wet run will actually reclaim. `scan` queries the patch API in `--batch-size` chunks. Authenticated runs POST `/v0/orgs/{slug}/patches/batch`; token-less runs POST `{proxy}/patch/batch` on the public proxy and degrade to per-package `GET /patch/by-package/:purl` requests in two cases: the deployed proxy predates the batch endpoint (legacy proxies answer the POST with their `400 "Unsupported endpoint"` catch-all), or the all-or-nothing batch validation rejects the chunk (e.g. a crawled PURL type the server doesn't recognize, such as `pkg:jsr/…` — the per-package path tolerates those individually, preserving the pre-batch scan semantics). Rate limits and over-capacity 503s surface instead of silently degrading. -**Throttling: bounded retry, then a reported failure.** Every patch-API JSON call (the batch query, the per-package patch lists, patch views and VEX record fetches, hosted package references) retries an HTTP `429` or `503` answer up to 3 times (`SOCKET_API_MAX_RETRIES=`, `0`-`10`; `0` = no retry). The wait honors `Retry-After` (delta-seconds or HTTP-date); a `Retry-After` over 30 s is not waited out — the answer is final at once — and one under the jittered first backoff step (`0`, a past date) waits that step instead. Without one it backs off 0.5 s / 1 s / 2 s (each step up to 8 s, with jitter in its upper half). All retries in one run share a 60 s wall-clock window that opens with the run's first retry: a retry whose wait would end after it closes is refused and the answer is final. Parallel requests wait in parallel, so each still gets its retries while the run adds at most about 60 s. Nothing else is retried (401/403 still drive the proxy fallback on the first answer; the public proxy's permanent `503 "Patch API is not configured"` is never retried on any path — the batch query still degrades to the per-package path at once, and a per-package lookup or patch view answering it is the same non-throttle failure it always was, so the legacy per-package path still skips that package), and a retried answer folds exactly where the first attempt's would have, so output is identical to an unthrottled run's. A request still throttled after that is a failure in the channel its siblings use: a failed batch is the human `Warning: API batch of failed: ` line and, under `--json`, a run-level `warnings[]` entry `{code: "api_batch_failed", detail: "API batch of failed: "}` (additive; `status` stays `success`, exit 0 — the other batches' packages are reported); a failed per-package patch-list query in the agent / hosted / vendored flows is the human `Warning: could not fetch details for : ` line and, under `--json`, `{code: "patch_details_failed", detail: "could not fetch details for : "}`. When every batch (or every patch-list query) fails, the existing all-failed error envelope and exit 1 apply. The error names the exhausted retry: `Rate limit exceeded (HTTP 429, gave up after 3 retries). Please try again later.` / `API request failed with status 503: (gave up after 3 retries)` (or `(Retry-After s exceeds the 30 s retry cap)` / `(the run's 60 s retry window has closed)`); with retries off it is the pre-retry text. On the token-less legacy per-package proxy path (a proxy without `POST /patch/batch`), a package still throttled (429 / over-capacity 503) after its retries fails its whole batch query, so every package in that batch goes unchecked and is reported through the batch-failure channel above (an unresolvable PURL, or a "not configured" 503, is still skipped individually). Before this, a throttled batch vanished from a `--json` envelope without a trace. Pinned by `tests/scan_api_retry_e2e.rs` and the core crate's `tests/api_retry_e2e.rs`. +**Throttling: bounded retry, then a reported failure.** Every patch-API JSON call (the batch query, the per-package patch lists, patch views and VEX record fetches, hosted package references) retries an HTTP `429` or `503` answer up to 3 times (`SOCKET_API_MAX_RETRIES=`, `0`-`10`; `0` = no retry). The wait honors `Retry-After` (delta-seconds or HTTP-date); a `Retry-After` over 30 s is not waited out — the answer is final at once — and one under the jittered first backoff step (`0`, a past date) waits that step instead. Without one it backs off 0.5 s / 1 s / 2 s (each step up to 8 s, with jitter in its upper half). All retries in one run share a 60 s wall-clock window that opens with the run's first retry: a retry whose wait would end after it closes is refused and the answer is final. Parallel requests wait in parallel, so each still gets its retries while the run adds at most about 60 s. Nothing else is retried (401/403 still drive the proxy fallback on the first answer; the public proxy's permanent `503 "Patch API is not configured"` is never retried on any path — the batch query still degrades to the per-package path at once, and a per-package lookup or patch view answering it is the same non-throttle failure it always was, so the legacy per-package path still skips that package), and a retried answer folds exactly where the first attempt's would have, so output is identical to an unthrottled run's. A request still throttled after that is a failure in the channel its siblings use: a failed batch is the human `Warning: API batch of failed: ` line and, under `--json`, a run-level `warnings[]` entry `{code: "api_batch_failed", detail: "API batch of failed: "}` (additive; `status` stays `success`, exit 0 — the other batches' packages are reported); a failed per-package patch-list query in the agent / hosted / vendored flows is the human `Warning: could not fetch details for : ` line and, under `--json`, `{code: "patch_details_failed", detail: "could not fetch details for : "}`. When every batch (or every patch-list query) fails, the existing all-failed error envelope and exit 1 apply. The error names the exhausted retry: `Rate limit exceeded (HTTP 429, gave up after 3 retries). Please try again later.` / `API request failed with status 503: (gave up after 3 retries)` (or `(Retry-After s exceeds the 30 s retry cap)` / `(the run's 60 s retry window has closed)`); with retries off it is the pre-retry text. On the token-less legacy per-package proxy path (a proxy without `POST /patch/batch`), a package still throttled (429 / over-capacity 503) after its retries fails its whole batch query, so every package in that batch goes unchecked and is reported through the batch-failure channel above (an unresolvable PURL, or a "not configured" 503, is still skipped individually). Pinned by `tests/scan_api_retry_e2e.rs` and the core crate's `tests/api_retry_e2e.rs`. -**Lockfile supplement (v3.4)**: `scan` discovery is no longer limited to installed trees. The project's lockfiles (`package-lock.json`/`npm-shrinkwrap.json`, `pnpm-lock.yaml` v9, `yarn.lock` classic + berry, `bun.lock`, `vlt-lock.json` (registry nodes, Socket-hosted pins included; vendored `file` nodes are left to the vendor ledger), `Cargo.lock`, `go.sum`, `composer.lock`, `Gemfile.lock`, `uv.lock`/`poetry.lock`/pinned `requirements.txt`) are inventoried and dependencies with NO installed copy join discovery — counts, the API lookup, the table (flagged ` [NOT INSTALLED]`, plus a stderr note), and the prune "scanned" set (a wiped node_modules no longer prunes lockfile-listed entries). JSON gains a top-level `lockfileOnlyPackages` count and an additive `notInstalled: true` on matching `packages[]` entries. `--apply` partitions lockfile-only patches out BEFORE download (calm `skipped`/`package_not_installed` records — never an error exit, never a manifest write); `--vendor` passes them through to the vendor engine's auto-fetch. Vendored-ledger entries likewise stay discoverable on a fresh clone (the committed artifact is the dependency). Global scans (`--global`) get no supplement. **Rush monorepos** (no root lockfile, `rush.json` present): the npm-lock inventory falls back to the Rush source-of-truth locks — `common/config/rush/pnpm-lock.yaml` plus every `common/config/subspaces/*/pnpm-lock.yaml` (`read_dir`-sorted, repo-relative paths preserved) — so a Rush repo's dependencies still join discovery. **Plug'n'Play layouts are an explicit refusal, not an empty inventory**: a `.pnp.*` loader means the npm packages are structurally unreachable in EVERY mode (under yarn PnP the installed-tree crawl is empty too — no `node_modules/`), so `scan` surfaces an additive top-level `warnings[]` array (`{code, detail}` objects, omitted when empty) carrying `yarn_pnp_unsupported` (same code as apply's refusal; remedy `yarn patch `) or `pnpm_pnp_unsupported` (pnpm's `node-linker=pnp` twin; pnpm remedies), plus a stderr `Warning (): …` line on the human path. Exit code and `status` are deliberately unchanged (exit 0 / `success` — the same posture as hosted refusals, which exit 0 with `redirected: 0`); the warning is the machine-readable signal that nothing was checked. Pinned by `tests/e2e_safety_yarn_pnp.rs`. +**Lockfile supplement (v3.4)**: `scan` discovery is no longer limited to installed trees. The project's lockfiles (`package-lock.json`/`npm-shrinkwrap.json`, `pnpm-lock.yaml` v9, `yarn.lock` classic + berry, `bun.lock`, `vlt-lock.json` (registry nodes, Socket-hosted pins included; vendored `file` nodes are left to the vendor ledger), `Cargo.lock`, `go.sum`, `composer.lock`, `Gemfile.lock`, `uv.lock`/`poetry.lock`/pinned `requirements.txt`) are inventoried and dependencies with NO installed copy join discovery — counts, the API lookup, the table (flagged ` [NOT INSTALLED]`, plus a stderr note), and the prune "scanned" set (a wiped node_modules no longer prunes lockfile-listed entries). JSON gains a top-level `lockfileOnlyPackages` count and an additive `notInstalled: true` on matching `packages[]` entries. `--apply` partitions lockfile-only patches out BEFORE download (calm `skipped`/`package_not_installed` records — never an error exit, never a manifest write); `--vendor` passes them through to the vendor engine's auto-fetch. Vendored-ledger entries likewise stay discoverable on a fresh clone (the committed artifact is the dependency). Global scans (`--global`) get no supplement. **Rush monorepos** (no root lockfile, `rush.json` present): the npm-lock inventory falls back to the Rush source-of-truth locks — `common/config/rush/pnpm-lock.yaml` plus every `common/config/subspaces/*/pnpm-lock.yaml` (`read_dir`-sorted, repo-relative paths preserved) — so a Rush repo's dependencies still join discovery. **Plug'n'Play layouts are an explicit refusal, not an empty inventory**: a `.pnp.*` loader means the npm packages are structurally unreachable in EVERY mode (under yarn PnP the installed-tree crawl is empty too — no `node_modules/`), so `scan` surfaces an additive top-level `warnings[]` array (`{code, detail}` objects, omitted when empty) carrying `yarn_pnp_unsupported` (same code as apply's refusal; remedy `yarn patch `) or `pnpm_pnp_unsupported` (pnpm's `node-linker=pnp` twin; pnpm remedies), plus a stderr `Warning: …` line on the human path. Exit code and `status` are deliberately unchanged (exit 0 / `success` — the same posture as hosted refusals, which exit 0 with `redirected: 0`); the warning is the machine-readable signal that nothing was checked. Pinned by `tests/e2e_safety_yarn_pnp.rs`. -**Vendor auto-fetch (v3.4)**: `vendor`/`scan --vendor` no longer fail on lockfile-resolved packages with no installed copy. Already-vendored purls stage from their committed artifact (sha256-verified against the vendor ledger — a vlt directory artifact against its file inventory, which leaves out the links vlt creates inside it; offline-safe) when the ledger entry is at the manifest record's patch uuid; a superseding uuid fetches the pristine package instead, since the older artifact holds the older patch's bytes. Otherwise the pristine artifact is fetched per the lockfile resolution and verified against the lock's recorded integrity FAIL-CLOSED before any write: npm SRI (or yarn classic's sha1 fragment; for vlt the registry node's slot [2]), yarn berry's cache-zip checksum (rebuilt from the fetched tarball; cacheKey 10c0 only), Cargo.lock sha256 over the .crate, go.sum `h1:` dirhash over the module zip, composer `dist.shasum` (sha1), Gemfile.lock `CHECKSUMS` sha256, uv.lock wheel sha256 (pure `py3-none-any` wheels only). Entries the lock cannot verify are NEVER fetched (`vendor_fetch_unverifiable` warning + the calm `package_not_installed` skip). Registry bases honor `SOCKET_NPM_REGISTRY`, `SOCKET_CRATES_REGISTRY`, `SOCKET_GOPROXY` (else `GOPROXY`, `GONOPROXY` and `GOPRIVATE` the way go reads them — see the env table); npm/yarn/composer/gem/uv lock-recorded URLs are used verbatim. `--offline` refuses the fetch with the calm skip (the detail names the lockfile resolution). The fetch stages into a private tempdir — the project tree is never touched. **Deferred fetch (v5.0):** a purl the vendor ledger already covers (its entry records the record's patch uuid and the committed artifact is on disk — a file artifact only while it hashes to the ledger's `sha256`; not under `--force`), and a lockfile-only cargo crate the registry could fetch and verify (a crates.io `Cargo.lock` entry with a checksum, or the pre-vendor resolution the ledger recovers) while the patch service is enabled, are NOT downloaded up front: the fetch runs only if the backend reaches a branch that reads the pristine tree (a drifted committed copy rebuilt locally, a service miss). An in-sync re-run therefore makes no registry request, reports no `vendor_fetched_missing`, and succeeds with no network or under `--offline`. A deferred fetch that does run records its `vendor_fetched_missing` just ahead of the package's own event; one that fails, is unverifiable, or is refused by `--offline` reports the same events the up-front fetch would have. A git, path or custom-registry cargo crate is never deferred, so it keeps `vendor_fetch_unverifiable` + `package_not_installed` and is never vendored from the service's crates.io build. **Gem, local build only** (`--vendor-source build`, or no service config): a not-installed gem the lock can verify (bundler >= 2.6 `CHECKSUMS`) and no ledger entry covers is refused `gem_spec_missing` (`failed`, the backend's own detail) BEFORE any download — a downloaded `.gem` carries no eval-able stub gemspec, so a local build can never vendor it; no `vendor_fetched_missing` precedes it, and a refusal the backend would have reached first on the fetched copy reports as `gem_spec_missing` too. Not under `--dry-run`, which still fetches and previews the gem (`vendor_fetched_missing` + `verified`). +**Vendor auto-fetch (v3.4)**: `vendor`/`scan --vendor` no longer fail on lockfile-resolved packages with no installed copy. Already-vendored purls stage from their committed artifact (sha256-verified against the vendor ledger — a vlt directory artifact against its file inventory, which leaves out the links vlt creates inside it; offline-safe) when the ledger entry is at the manifest record's patch uuid; a superseding uuid fetches the pristine package instead, since the older artifact holds the older patch's bytes. Otherwise the pristine artifact is fetched per the lockfile resolution and verified against the lock's recorded integrity FAIL-CLOSED before any write: npm SRI (or yarn classic's sha1 fragment; for vlt the registry node's slot [2]), yarn berry's cache-zip checksum (rebuilt from the fetched tarball; cacheKey 10c0 only), Cargo.lock sha256 over the .crate, go.sum `h1:` dirhash over the module zip, composer `dist.shasum` (sha1), Gemfile.lock `CHECKSUMS` sha256, uv.lock wheel sha256 (pure `py3-none-any` wheels only). Entries the lock cannot verify are NEVER fetched (`vendor_fetch_unverifiable` warning + the calm `package_not_installed` skip). Registry bases honor `SOCKET_NPM_REGISTRY`, `SOCKET_CRATES_REGISTRY`, `SOCKET_GOPROXY` (else `GOPROXY`, `GONOPROXY` and `GOPRIVATE` the way go reads them — see the env table); npm/yarn/composer/gem/uv lock-recorded URLs are used verbatim. `--offline` refuses the fetch with the calm skip (the detail names the lockfile resolution). The fetch stages into a private tempdir — the project tree is never touched. **Deferred fetch (v5.0):** a purl the vendor ledger already covers (its entry records the record's patch uuid and the committed artifact is on disk — a file artifact only while it hashes to the ledger's `sha256`; not under `--force`), and a lockfile-only npm, cargo, golang or composer package the registry would fetch and verify (a lock entry with an integrity, or the pre-vendor resolution the ledger recovers, that none of its fetcher's pre-download refusals applies to: a yarn berry cacheKey other than 10c0, a go module go fetches without a proxy, a composer entry with no dist URL) while the patch service is enabled, are NOT downloaded up front (those backends ask the service first and read the pristine tree only on a local-build fallback; pypi and gem keep the up-front fetch, which their installed-variant probe reads): the fetch runs only if the backend reaches a branch that reads the pristine tree (a drifted committed copy rebuilt locally, a service miss). An in-sync re-run therefore makes no registry request, reports no `vendor_fetched_missing`, and succeeds with no network or under `--offline`. A deferred fetch that does run records its `vendor_fetched_missing` just ahead of the package's own event; one that fails, is unverifiable, or is refused by `--offline` reports the same events the up-front fetch would have. A package whose fetch would be refused is never deferred (a git, path or custom-registry cargo crate, say), so it keeps `vendor_fetch_unverifiable` + `package_not_installed` and is never vendored from the service's registry build. **Gem, local build only** (`--vendor-source build`, or no service config): a not-installed gem the lock can verify (bundler >= 2.6 `CHECKSUMS`) and no ledger entry covers is refused `gem_spec_missing` (`failed`, the backend's own detail) BEFORE any download — a downloaded `.gem` carries no eval-able stub gemspec, so a local build can never vendor it; no `vendor_fetched_missing` precedes it, and a refusal the backend would have reached first on the fetched copy reports as `gem_spec_missing` too. Not under `--dry-run`, which still fetches and previews the gem (`vendor_fetched_missing` + `verified`). **Vendored write durability (v5.0)**: every write is atomic (stage + rename), but only the durable commit points — lockfiles, `go.mod`/`go.sum`, `pom.xml`, `nuget.config`, `package.json`, `pnpm-workspace.yaml`, `.cargo/config.toml`, the Python/Ruby manifests, `.socket/vendor/state.json` and `redirect-state.json` — are fsynced on write. The content-verified artifacts under `.socket/vendor///` (patched copies, packed/rebuilt archives and sidecars, markers) are written without an fsync and made durable by one barrier (file + directory fsync, one `F_FULLFSYNC` per device on macOS) ahead of the next commit point — and, for an artifact rebuilt in place that no commit point follows, at the end of the vendored run's commit and when the command releases the apply lock — so a crash can only lose an artifact that no durable commit point names yet, which the next run rebuilds. **Vendored group commit (v5.0)**: `vendor`, `scan --mode vendored` and `get --mode vendored` capture every lockfile / manifest / config edit and every ledger save of the run in memory (reads inside the run see them) and commit them ONCE after the per-package loop — including the packages that succeeded in a run where others failed, so a completed run leaves the same files per-package commits would. Captured: every file under the project root outside `.socket/`, plus `.socket/vendor/state.json` and `.socket/vendor/redirect-state.json`; artifacts are written directly (see the durability note). A multi-file commit goes through a roll-forward journal, `.socket/vendor/.commit-journal.json` (the new bytes of every changed file, plus the bytes each replaces and their sha256; deleted once the commit completes). **Crash semantics**: before the journal is durable, nothing is committed — the lockfiles and ledgers are the pre-run ones and the run's artifacts are unreferenced orphans; after it, the next command that takes the apply lock replays the journal before reading anything (files already at their new bytes are left alone), so a locked command never observes a half-committed run. A journal that matches neither side of some file (edited by hand since the crash) is renamed to `.socket/vendor/.commit-journal.set-aside-.json` (keeping every file's pre-commit bytes) and stderr says what was done (`Warning: an interrupted vendored run's commit could not be finished as written: …`): the edited files are never written over; when they all still carry the commit's own lines the rest of the commit is finished around them, when none of them does the files the crash had already replaced are put back to their pre-commit bytes, and otherwise nothing is applied. A journal that is unreadable, names a path outside the lockfiles and ledgers, or would write through a symbolic link is set aside with nothing applied. A replay that fails on I/O keeps the journal and fails the lock acquire (`lock_io`, naming the journal). Read-only commands that take no lock (`vex`, `list`) may observe the interrupted state until then. A re-vendor under a newer uuid removes the replaced uuid's dir only after the commit (its `vendor_stale_artifact_removed` event follows the run's per-package events), and a golang takeover removes the `.socket/go-patches/` copy only after the commit that repoints `go.mod`. A commit write failure is the top-level error `vendor_commit_failed` (exit 1; the pre-run lockfiles and ledger stay — unless putting back the files already replaced failed too, in which case the journal is kept and the next locked command finishes the commit). `repair`, `vendor --revert` and `rollback` still save per entry. -`scan --sync` is sugar for `--apply --prune` — the canonical single-flag bot invocation. `scan --json --sync --yes` discovers, applies, and reconciles state in one pass. +`scan --sync` is sugar for `--mode agent --prune` — the canonical single-flag agent-mode bot invocation. `scan --json --sync` discovers, applies, and reconciles state in one pass. + +**`scan --ecosystems` scopes the crawl (v5.0)**: without `--prune`/`--sync`, a `scan` given `--ecosystems`/`-e` runs only the named ecosystems' crawlers — everything the run counts, queries and shows (`scannedPackages`, the batch query, `packages[]`, the table, `updates[]`, `wiringLive`, the `gem_bundle_config_path_ignored` warning) was already narrowed to them, so the skipped crawls could only be filtered away. The one visible difference: `lockfileOnlyPackages` (and the human "not yet installed" note) counts only the selected ecosystems' lockfile-only entries (a skipped crawl cannot vouch for another ecosystem's uninstalled lockfile entries). A GC run (`--prune`, or `--sync`, which implies it — in every mode, hosted included) still crawls every ecosystem, because the prune judges each manifest entry against the FULL installed set (see `scan --prune` above); its output, `lockfileOnlyPackages` included, is unchanged. Without `--ecosystems` nothing changes. Pinned by `tests/scan/scan_ecosystems_scope_e2e.rs`. -**`scan --ecosystems` scopes the crawl (v5.0)**: without `--prune`/`--sync`, a `scan` given `--ecosystems`/`-e` runs only the named ecosystems' crawlers — everything the run counts, queries and shows (`scannedPackages`, the batch query, `packages[]`, the table, `updates[]`, `wiringLive`, the `gem_bundle_config_path_ignored` warning) was already narrowed to them, so the skipped crawls could only be filtered away. The one visible difference: `lockfileOnlyPackages` (and the human "not yet installed" note) counts only the selected ecosystems' lockfile-only entries — previously it also counted other ecosystems' uninstalled lockfile entries, which a skipped crawl can no longer vouch for. A GC run (`--prune`, or `--sync`, which implies it — in every mode, hosted included) still crawls every ecosystem, because the prune judges each manifest entry against the FULL installed set (see `scan --prune` above); its output, `lockfileOnlyPackages` included, is unchanged. Without `--ecosystems` nothing changes. Pinned by `tests/scan_ecosystems_scope_e2e.rs`. +**Path-scoped scans (`scan [PATHS]...`, v5.0)**: what a PATH means depends on the mode. -**Path-scoped scans (`scan [PATHS]...`, v5.0)**: optional variadic positional path globs scope DISCOVERY at the **purl level** — a package is in scope iff ANY of its crawled installed copies sits under a matching path, and a selected package is then handled with ALL its copies (scoping selects which packages are considered, never which copies). Glob semantics (shared with `rollback`'s path targets, `src/path_scope.rs`): Unix-shell globs with `require_literal_separator` — `*`/`?` never cross a `/`, `**` spans directories; a pattern matching any **ancestor** directory of the copy path also matches, so a bare `scan packages/foo` scopes the whole subtree without `/**`; relative patterns match against the copy path relativized to `--cwd`, absolute patterns against the absolute path (the ONLY way to reach paths outside the project tree, e.g. `--global` stores — a relative pattern never matches outside `--cwd`); leading `./` and trailing `/` are normalized away, matching is purely textual (no filesystem access or symlink resolution), case-sensitive except on Windows (whose filesystems are not); an unparseable or empty pattern is a usage error (exit 2). **The prune universe is never narrowed**: the path filter is applied strictly AFTER the `scanned_purls` capture (and after `--ecosystems`), so `scan PATHS --prune` prunes exactly what an unscoped `scan --prune` would — a scoped scan can never treat an out-of-scope package as uninstalled (the same fail-safe as the `--ecosystems` filter). Lockfile-only and vendor-ledger supplement records have no installed path and are EXCLUDED from a path-scoped scan, surfaced as one run-level `path_scope_excluded_supplements` warning carrying the count. A scope matching nothing is a normal empty scan — exit 0, zero packages, **no GC** (the zero-package early return fires before any GC). `PATHS` with `--mode hosted` or `--mode vendored` is a usage error (exit 2, `resolve_mode_flags`: "path targeting … applies to agent-mode and read-only scans" — their lockfile rewiring is whole-project by construction); `PATHS` with `--apply`/`--sync`/`--prune`/`--global` is fine. Every scan JSON shape (success, zero-package, and error alike) gains an additive always-present `paths` key echoing the patterns verbatim (empty array when unscoped). One-sentence duality rule: **a target that selects nothing is an error on `rollback` (exit 1) and an empty scan on `scan` (exit 0)**. +* **Hosted and vendored mode (bare `scan` included) — project directories** (`run_project_dirs`). Each PATH is a directory, or a glob (`*?[`) matching directories, relative to `--cwd`; the set is sorted and deduplicated, and each directory is scanned on its own exactly as if it were `--cwd` (its own lockfiles, ledgers and `.socket/`). With more than one directory, each run is headed `== ==` on stdout (unless `--silent`), and the exit code is the worst of the runs. Usage errors (exit 2, stderr only, before any scan): a PATH that is not a directory (`` `X` is not a directory``), a glob matching no directory (`` `X` matches no directory``), an invalid glob, and `--json` with more than one directory (`--json takes one project directory (N given); run one scan per directory`), so stdout stays one document. +* **Agent mode (and a mode-less `--prune`/`--global` report) — installed-path globs** scoping DISCOVERY at the **purl level**: a package is in scope iff ANY of its crawled installed copies sits under a matching path, and a selected package is then handled with ALL its copies (scoping selects which packages are considered, never which copies). Glob semantics (shared with `rollback`'s path targets, `src/path_scope.rs`): Unix-shell globs with `require_literal_separator` — `*`/`?` never cross a `/`, `**` spans directories; a pattern matching any **ancestor** directory of the copy path also matches, so a bare `scan packages/foo` scopes the whole subtree without `/**`; relative patterns match against the copy path relativized to `--cwd`, absolute patterns against the absolute path (the ONLY way to reach paths outside the project tree, e.g. `--global` stores — a relative pattern never matches outside `--cwd`); leading `./` and trailing `/` are normalized away, matching is purely textual (no filesystem access or symlink resolution), case-sensitive except on Windows (whose filesystems are not); an unparseable or empty pattern is a usage error (exit 2). **The prune universe is never narrowed**: the path filter is applied strictly AFTER the `scanned_purls` capture (and after `--ecosystems`), so `scan PATHS --prune` prunes exactly what an unscoped `scan --prune` would — a scoped scan can never treat an out-of-scope package as uninstalled (the same fail-safe as the `--ecosystems` filter). Lockfile-only and vendor-ledger supplement records have no installed path and are EXCLUDED from a path-scoped scan, surfaced as one run-level `path_scope_excluded_supplements` warning carrying the count. A scope matching nothing is a normal empty scan — exit 0, zero packages, **no GC** (the zero-package early return fires before any GC). `PATHS` combine with `--apply`/`--sync`/`--prune`/`--global`. Every scan JSON shape (success, zero-package, and error alike) carries an always-present `paths` key echoing the patterns verbatim (empty array when unscoped; a hosted/vendored per-directory run is unscoped, so it is `[]`). One-sentence duality rule: **a target that selects nothing is an error on `rollback` (exit 1) and an empty scan on an agent-mode `scan` (exit 0)**. `scan --vendor` swaps the in-place apply for the vendor pipeline: discover → download the selected patch records **into memory** (no manifest write) → vendor every selected dependency via the same engine as the `vendor` command (under the same lock). Vendored mode is **manifest-free (v5.0)**: `.socket/manifest.json` is never written or read by a vendored run; each ledger entry carries `detached: true` plus an embedded copy of the patch record (`record`) as its verification source, and the run's footprint is `.socket/vendor/**` only. The vendor step's scope is what discovery selected — the former "whole manifest is vendored" re-vendor on an empty discovery is retired (`repair` verifies and rebuilds committed vendored state; `scan --prune` reconciles ledger entries whose dependency left the lockfile). A package the ledger holds at an older patch uuid is still **re-vendored automatically** when discovery selects the newer patch (its old uuid dir is removed — `vendor_stale_artifact_removed`); same-uuid re-runs reuse the embedded record, skip the patch-view fetch, and are `already_vendored` skips. **Legacy manifest-mode entries**: when a vendored run vendors a purl that also has a `.socket/manifest.json` record (a project vendored by a pre-5.0 binary, or by standalone `vendor` from an agent-mode manifest), that manifest record is dropped in the same run — the ledger becomes the owner (migration write); an emptied manifest is left as `{"patches": {}}`, never deleted. The migration is reported through the run-level `warnings[]` (stderr in human mode), never as a run error: `vendor_manifest_record_migrated` (`N manifest records moved to the vendor ledger (vendored mode is manifest-free): `) or `vendor_manifest_migration_failed` (the manifest or the ledger could not be read or rewritten; the legacy records were left in place) — so a corrupt `.socket/manifest.json` no longer fails a vendored run (standalone `vendor`, the one manifest-driven writer, still fails closed on it). With `--prune`, GC runs **after** the vendor step (the step never reads the manifest, and running the sweep last lets it reclaim what the run itself orphaned — a migrated legacy record's blobs, a superseded uuid dir). JSON output gains a `download` sub-object — the detached download envelope `{found, downloaded, skipped, failed, detached: true, patches: [{purl, uuid, action: "downloaded" | "skipped" | "failed", …}], warnings?}` (no `applied` field — nothing is applied in place; `detached: true` is pinned and always present; a `downloaded` record whose purl the ledger already holds at another uuid carries the additive `oldUuid` — the re-vendor the vendor step then performs — and its human `[fetch]` line reads ` (replacing )`) — and a `vendor` sub-object (a full vendor Envelope). Patch blobs are held in memory (see "Patch sources stay in memory" under the vendor contract). `--dry-run` previews per-patch `would_vendor` | `would_revendor` (+`oldUuid`) | `already_vendored` — plus, additive, `would_refuse` (+`errorCode`, `error`) for npm purls the wet run's Bun preflight (see the `get --mode vendored` bullet below) would refuse — without network downloads or disk writes; the preview never flips status or exit (the human path — `scan` and `get` alike, through one shared printer — prints `[would-refuse] (): ` lines behind the `--silent` gate). Interactive mode prompts "Download and vendor N patches?" (singular for one). -**Vendored entries and the rest of the CLI.** Because nothing is in the manifest, vendored patches are invisible to `apply` (nothing to apply in place) but fully visible to `list` (listed from the ledger, labeled `Mode: vendored (recorded in .socket/vendor/state.json)` in human mode, exit 0 on a vendored-only project), `vex` (attested from the embedded records while a lockfile still wires the artifact — see "Manifest-less VEX"), `repair` (health-checked and rebuilt from the ledger), `scan --prune` (lockfile-driven reconcile) and `setup --check`'s patch-consistency property (consulted from the embedded records). They are exempt from standalone `vendor`'s manifest reconcile (`reconcile_dropped` never touches `detached` entries) and exit via `remove ` (which reverts them), `vendor --revert`, or `rollback`, whose vendored leg reverts every in-scope ledger entry (unscoped and identifier-scoped runs; path-scoped runs reach them only when an installed copy matches). The hidden `--detached` flag (`scan --vendor --detached`) names exactly this — the only — vendored posture and is accepted as a no-op for compatibility. +**Vendored entries and the rest of the CLI.** Because nothing is in the manifest, vendored patches are invisible to `apply` (nothing to apply in place) but fully visible to `list` (listed from the ledger, labeled `Mode: vendored (recorded in .socket/vendor/state.json)` in human mode, exit 0 on a vendored-only project), `vex` (attested from the embedded records while a lockfile still wires the artifact — see "Manifest-less VEX"), `repair` (health-checked and rebuilt from the ledger), and `scan --prune` (lockfile-driven reconcile). They are exempt from standalone `vendor`'s manifest reconcile (`reconcile_dropped` never touches `detached` entries) and exit via `remove ` (which reverts them), `vendor --revert`, or `rollback`, whose vendored leg reverts every in-scope ledger entry (unscoped and identifier-scoped runs; path-scoped runs reach them only when an installed copy matches). -`scan --mode hosted` (== `--redirect`) swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither recorded nor attested. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). Re-runs over already-rewritten output record zero new edits. **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` around its first wet write (the takeover pre-reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/` (and never quarantine: a `--dry-run` or a zero-grant wet run that finds a malformed `redirect-state.json` reports it as the hard error it is — exit 1, the repair-or-move-aside remedy — but moves nothing; only a run holding the lock moves it aside to `redirect-state.json.corrupt`); contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE the redirect ledger is read or written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON keeps the hosted shape — top-level `status: "error"`, `errorCode: "lock_held" | "lock_io"`, a string `error`, and `redirect: {mode: "hosted"}` retained (NOT the vendored `error: {code, message}` object). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: `scan --mode hosted` prints the results table and update detection like the other modes and confirms once — `Redirect N packages to the hosted patch server?` (singular for one), default yes, skipped by `--yes`/`--json`, on `--dry-run` (the engine honors the preview itself; nothing mutates), and when the detail fetch leaves nothing to redirect (that run enters the engine as a no-op — `Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/` — without prompting); without `--yes` on a non-TTY stdin the shared prompt prints `Non-interactive mode detected, proceeding automatically.` to stderr (unless `--silent`) and proceeds — before rewriting anything (parity with the agent/vendored arms and with `get --mode hosted`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine (previously `Redirected 0 packages; rewrote 0 files.`); a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). A malformed redirect ledger on a human hosted run that returns before the engine (empty discovery, nothing downloadable, a detail-fetch failure, a declined confirm) is surfaced there as the read-only `Warning: the redirect ledger … is malformed …` advisory (muted by `--silent`), never moved; the `--json` arm always enters the engine and hard-errors instead. JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang where it used to skip silently, and the ledger's `redirect_nuget_source` edit records `action: "added"` when `nuget.config` was authored from scratch (`rewritten` otherwise). Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips, and the `redirect_yarn_berry_entry` ledger edits record the lock's ON-DISK (CRLF) fragments, which the reverts match byte-exactly. A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover runs these berry gates (mixed line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) BEFORE reverting a vendored berry purl — wet and `--dry-run` alike — so a refused purl keeps its vendored wiring, ledger entry and artifact byte-identical and is skipped with the gate's code (never announced as `redirect_takeover_reverted_vendored` and then left unpatched in both modes). +`scan --mode hosted` swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither counted nor attested. **No ledger (v5.0)**: hosted mode writes ONLY the lockfile / registry-config edits — `.socket/vendor/redirect-state.json` is never written (on success or failure), and a pre-v5 one on disk is ignored (never read for planning, never quarantined, left byte-identical). The lockfiles are the only record of a hosted patch: `list`, `vex`, `rollback`, `remove`, `vendor` and `repair` all discover the hosted pins from them (a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin), and commit-ready output is just the lockfile / config changes. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). Re-runs over already-rewritten output plan from the current lock text and are idempotent (exit 0, lock unchanged). **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` around its first wet write (the takeover pre-reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/`; contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE any project file is written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON keeps the hosted shape — top-level `status: "error"`, `errorCode: "lock_held" | "lock_io"`, a string `error`, and `redirect: {mode: "hosted"}` retained (NOT the vendored `error: {code, message}` object). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: hosted `scan` prints the results table and update detection like the other modes, then rewrites without a prompt (scan never prompts); `--dry-run` previews through the engine, and a detail fetch that leaves nothing to redirect enters the engine as a no-op (`Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine; a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang. Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips (and `rollback`'s upstream restore keeps the lock's own ending). A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover runs these berry gates (mixed line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) BEFORE reverting a vendored berry purl — wet and `--dry-run` alike — so a refused purl keeps its vendored wiring, ledger entry and artifact byte-identical and is skipped with the gate's code (never announced as `redirect_takeover_reverted_vendored` and then left unpatched in both modes). The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and the Gradle build scripts read only to trigger the manual-snippet warning). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (`yarn.lock` entry only — `resolution: ::__archiveUrl=` + `yarnBerry10c0` checksum; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found`, a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). -**Gem stale-install guard (additive warning — the canonical narrative; other mentions point here)**: the gem hosted rewrite is pure Gemfile/lock text, so a gem ALREADY materialized under the project's bundle paths keeps its upstream bytes — the next `bundle install` prints `Using ` and never refetches, on **every** bundler major (live-verified 2026-08-19 on 1.17.3 / 2.7.2 / 4.0.18: bundler 4's CHECKSUMS verify at download time only, and nothing is downloaded; `bundle install --force`/`--redownload` re-install from the stale cached `.gem` instead of re-fetching — bundler 1 silently, bundler 4 with an exit-37 checksum refusal that still leaves the upstream bytes installed; the **verified** remedy is removing the installed dir + cache `.gem` + `specifications` entry, then `bundle install`). After the rewrite, a hosted run therefore probes the installed-gem discovery paths (the same ruby-crawler discovery `apply` uses, honoring `--global`/`--global-prefix` like scan's own discovery) for each confirmed gem redirect and judges the materialization against the patch record's `afterHash` file map. Judgment rules: records are found **by uuid** — this run's fetched records first, then the redirect ledger's persisted ones, so a transiently failed `/patches/view` fetch cannot retire the warning (it re-fires on every re-scan until the stale materialization is gone); a materialization with every file at `afterHash` is already patched and never warns (an agent→hosted migration stays quiet by construction), and when several confirmed variant purls resolve to one installed dir, ANY of them judging it patched keeps it quiet; staleness needs **positive evidence** — at least one record file whose bytes were actually read and hash to neither state's expectation — so missing or unreadable files never produce a warning. Warnings emit `redirect_gem_stale_install` (JSON `redirect.warnings[]` + a code-tagged stderr line) in three flavors: a PROJECT-LOCAL dir gets the verified delete-list remedy (installed dir, cache `.gem`, `specifications` entry — plus the project's committed `vendor/cache/.gem` when present and not proven to be the patched artifact, since bundler installs from `vendor/cache` in preference to fetching); a SHARED gem-env home gets a caveat that the home is shared machine-wide and prefers migrating the project to a local bundle path over deleting shared files; and a committed `vendor/cache` archive whose sha256 differs from the patched artifact's warns standalone even with no installed dir at all (a fresh checkout with a committed stale cache re-materializes the upstream bytes forever). A stale-flagged purl is additionally **excluded from the same run's `--vex` `assume_applied` set** — the envelope must never attest a CVE its own warning says is live; the purl falls back to normal installed-tree verification (a patched install still attests, a stale one is omitted). The probe is read-only (nothing is deleted) and skipped on `--dry-run` — deliberately explicit, since nothing was rewritten but the ledger fallback could otherwise judge an already-redirected project. Exit code and `status` are unchanged (warning-only, the hosted-refusal posture); a same-run `--vex` may still fail on "nothing to attest" per the embedded-VEX contract. +**Gem stale-install guard (additive warning — the canonical narrative; other mentions point here)**: the gem hosted rewrite is pure Gemfile/lock text, so a gem ALREADY materialized under the project's bundle paths keeps its upstream bytes — the next `bundle install` prints `Using ` and never refetches, on **every** bundler major (live-verified 2026-08-19 on 1.17.3 / 2.7.2 / 4.0.18: bundler 4's CHECKSUMS verify at download time only, and nothing is downloaded; `bundle install --force`/`--redownload` re-install from the stale cached `.gem` instead of re-fetching — bundler 1 silently, bundler 4 with an exit-37 checksum refusal that still leaves the upstream bytes installed; the **verified** remedy is removing the installed dir + cache `.gem` + `specifications` entry, then `bundle install`). After the rewrite, a hosted run therefore probes the installed-gem discovery paths (the same ruby-crawler discovery `apply` uses, honoring `--global`/`--global-prefix` like scan's own discovery) for each confirmed gem redirect and judges the materialization against the patch record's `afterHash` file map. Judgment rules: records are found **by uuid** among this run's fetched records (v5.0: hosted mode persists no records, so a purl whose `/patches/view` fetch failed this run is not judged; the warning re-fires on every re-scan whose fetch succeeds, until the stale materialization is gone); a materialization with every file at `afterHash` is already patched and never warns (an agent→hosted migration stays quiet by construction), and when several confirmed variant purls resolve to one installed dir, ANY of them judging it patched keeps it quiet; staleness needs **positive evidence** — at least one record file whose bytes were actually read and hash to neither state's expectation — so missing or unreadable files never produce a warning. Warnings emit `redirect_gem_stale_install` (JSON `redirect.warnings[]` + a code-tagged stderr line) in three flavors: a PROJECT-LOCAL dir gets the verified delete-list remedy (installed dir, cache `.gem`, `specifications` entry — plus the project's committed `vendor/cache/.gem` when present and not proven to be the patched artifact, since bundler installs from `vendor/cache` in preference to fetching); a SHARED gem-env home gets a caveat that the home is shared machine-wide and prefers migrating the project to a local bundle path over deleting shared files; and a committed `vendor/cache` archive whose sha256 differs from the patched artifact's warns standalone even with no installed dir at all (a fresh checkout with a committed stale cache re-materializes the upstream bytes forever). A stale-flagged purl is additionally **excluded from the same run's `--vex` `assume_applied` set** — the envelope must never attest a CVE its own warning says is live; the purl falls back to normal installed-tree verification (a patched install still attests, a stale one is omitted). The probe is read-only (nothing is deleted) and skipped on `--dry-run` — deliberately explicit, since nothing was rewritten. Exit code and `status` are unchanged (warning-only, the hosted-refusal posture); a same-run `--vex` may still fail on "nothing to attest" per the embedded-VEX contract. -**Pipenv hosted redirect (`Pipfile.lock`, pipfile-spec 6)**: every category other than `_meta` (`default`, `develop`, and Pipenv 2022+ named categories) that pins the package at the patched version is rewritten to the hosted reference — `{"file" | "path": "#sha256=", "hashes": ["sha256:"]}` with `markers`/`extras` preserved and `version`/`index` dropped; `_meta` (the Pipfile content hash) and the Pipfile itself are never touched, so `pipenv install --deploy`/`sync`/`verify` keep passing. The reference KEY depends on the installing Pipenv: releases 7–11 only install `path` references, 2018 and later `file` ones (0–6 write pipfile-spec < 6 and are refused). The release is probed once per command with `pipenv --version`, resolved on ABSOLUTE `PATH` entries only (a relative entry would run a `pipenv` planted in the scanned repository; `.bat`/`.cmd` shims are found through `PATHEXT` on Windows), only when a pypi patch actually targets an entry of the lock, and `SOCKET_PIPENV_MAJOR=` pins the answer without spawning anything. An unknown installer selects `file` and warns `redirect_pipenv_installer_unknown` only when the lock was rewritten. **Refusal scope**: a pin/source CONFLICT (another version pinned, a foreign `file`/`path` source, a VCS/editable dependency) refuses the whole dependency atomically across categories as `redirect_pipenv_refused` AND vetoes the sibling Python rewriters (requirements.txt / uv.lock / pyproject) for that patch — the project's Pipenv install could not pick the patch up, so a half-redirected checkout is refused; anything else (no entry for the package, an old pipfile-spec, an unparseable lock, a digest-less patch) is `redirect_pipenv_skipped` and leaves the siblings alone (a stale Pipfile.lock in a uv/Poetry/requirements project must not block them). The veto applies to a LIVE lock only: a `Pipfile.lock` with no `Pipfile` beside it is abandoned, so its conflict refuses that file but never the siblings. Hash enforcement at install time is split by era — the `#sha256=` URL fragment is what Pipenv 2023+ verifies, the `hashes` list what 2018–2022 verify, Pipenv 11 either — so both are load-bearing. **Pipenv stale-install guard**: Pipenv never reinstalls a release that is already present (`pipenv install`, `install --deploy` and `sync` all exit 0 and keep the installed bytes — measured on 11.10.4, 2018.11.26 and 2026.8.0, hosted and vendored), so after the rewrite the run probes the Python crawler's site-packages (VIRTUAL_ENV, `./.venv`, `./venv`, Pipenv's out-of-tree `WORKON_HOME` venv; `--global`/`--global-prefix` honoured) for each confirmed Pipfile.lock redirect with the same rules as the gem guard (records by uuid with the ledger fallback, PATCHED = `verify_patch_record` Ok, STALE needs positive evidence, read-only, skipped on `--dry-run`, stale purls excluded from the same-run `--vex` `assume_applied` set) and the Python stale-install guard (`redirect_pypi_stale_install`, see above) names the site-packages dir and the Pipenv-specific verified remedy: `pipenv run pip uninstall -y && pipenv sync` (or `pipenv --rm && pipenv sync`) — NOT `pipenv uninstall`, which rewrites the Pipfile and re-locks the patch away. The vendored backend emits the twin `pypi_pipenv_stale_install` (`skipped` warning event). **Rollback**: `redirect_pipenv_entry` edits replay per entry, compared as parsed JSON (a whole-file CRLF/LF conversion or a Pipenv re-serialization that kept our reference and hashes is not drift; the original is spliced back in the live file's line ending); an entry a relock removed retires the edit; a relock (`pipenv lock`, `update`, `install ` before 2024) regenerates the entry to registry shape on every Pipenv major and is NOT drift — the edit retires and the user's fresh resolution stands (vendored twin: `vendor_lock_entry_relocked`); a foreign `file`/`path` reference still refuses the pypi group. A Pipfile names no project, so a same-run `--vex` on a Pipenv project needs `--vex-product` (or a git remote) to detect a product purl. **Discovery**: `Pipfile.lock` is part of the lockfile inventory (every category's `==` pins, with the lock's digest set as `Sha256AnyOf` integrity so a lock-only checkout can be vendored by fetching the pure wheel through PyPI's JSON API — only when `_meta.sources` name the public index; a private-index lock stays discovery-only and never reaches pypi.org), and Socket's own hosted / vendored references stay discoverable as the package they replace, so a re-scan of an already-redirected or already-vendored lock-only checkout re-confirms it (`--vex` attests, vendored reports `already_vendored`) instead of finding nothing. +**Pipenv hosted redirect (`Pipfile.lock`, pipfile-spec 6)**: every category other than `_meta` (`default`, `develop`, and Pipenv 2022+ named categories) that pins the package at the patched version is rewritten to the hosted reference — `{"file" | "path": "#sha256=", "hashes": ["sha256:"]}` with `markers`/`extras`/`index` kept exactly as Pipenv wrote them (present or absent: whether Pipenv records `index` depends on its release, the Pipfile spelling and the locking environment, so only the entry itself knows) and `version` dropped; `_meta` (the Pipfile content hash) and the Pipfile itself are never touched, so `pipenv install --deploy`/`sync`/`verify` keep passing. The reference KEY depends on the installing Pipenv: releases 7–11 only install `path` references, 2018 and later `file` ones (0–6 write pipfile-spec < 6 and are refused). The release is probed once per command with `pipenv --version`, resolved on ABSOLUTE `PATH` entries only (a relative entry would run a `pipenv` planted in the scanned repository; `.bat`/`.cmd` shims are found through `PATHEXT` on Windows), only when a pypi patch actually targets an entry of the lock, and `SOCKET_PIPENV_MAJOR=` pins the answer without spawning anything. An unknown installer selects `file` and warns `redirect_pipenv_installer_unknown` only when the lock was rewritten. **Refusal scope**: a pin/source CONFLICT (another version pinned, a foreign `file`/`path` source, a VCS/editable dependency) refuses the whole dependency atomically across categories as `redirect_pipenv_refused` AND vetoes the sibling Python rewriters (requirements.txt / uv.lock / pyproject) for that patch — the project's Pipenv install could not pick the patch up, so a half-redirected checkout is refused; anything else (no entry for the package, an old pipfile-spec, an unparseable lock, a digest-less patch) is `redirect_pipenv_skipped` and leaves the siblings alone (a stale Pipfile.lock in a uv/Poetry/requirements project must not block them). The veto applies to a LIVE lock only: a `Pipfile.lock` with no `Pipfile` beside it is abandoned, so its conflict refuses that file but never the siblings. Hash enforcement at install time is split by era — the `#sha256=` URL fragment is what Pipenv 2023+ verifies, the `hashes` list what 2018–2022 verify, Pipenv 11 either — so both are load-bearing. **Pipenv stale-install guard**: Pipenv never reinstalls a release that is already present (`pipenv install`, `install --deploy` and `sync` all exit 0 and keep the installed bytes — measured on 11.10.4, 2018.11.26 and 2026.8.0, hosted and vendored), so after the rewrite the run probes the Python crawler's site-packages (VIRTUAL_ENV, `./.venv`, `./venv`, Pipenv's out-of-tree `WORKON_HOME` venv; `--global`/`--global-prefix` honoured) for each confirmed Pipfile.lock redirect with the same rules as the gem guard (records by uuid from this run's fetch, PATCHED = `verify_patch_record` Ok, STALE needs positive evidence, read-only, skipped on `--dry-run`, stale purls excluded from the same-run `--vex` `assume_applied` set) and the Python stale-install guard (`redirect_pypi_stale_install`, see above) names the site-packages dir and the Pipenv-specific verified remedy: `pipenv run pip uninstall -y && pipenv sync` (or `pipenv --rm && pipenv sync`) — NOT `pipenv uninstall`, which rewrites the Pipfile and re-locks the patch away. The vendored backend emits the twin `pypi_pipenv_stale_install` (`skipped` warning event). **Rollback** (v5.0, upstream restore): each hosted entry gets its registry shape back — `"version": "=="`, the entry's own `index` carried back unchanged (refused unless it — and the Pipfile's explicit `index`, if any — names a PyPI source in `_meta.sources`), and every release file's sha256 from PyPI's JSON API (`SOCKET_PYPI_JSON_API`), sorted by filename as Pipenv records them; an entry that pins another version beside the hosted reference is refused with the `git checkout` remedy (see "Hosted unwind coverage"). A Pipfile names no project, so a same-run `--vex` on a Pipenv project needs `--vex-product` (or a git remote) to detect a product purl. **Discovery**: `Pipfile.lock` is part of the lockfile inventory (every category's `==` pins, with the lock's digest set as `Sha256AnyOf` integrity so a lock-only checkout can be vendored by fetching the pure wheel through PyPI's JSON API — only when `_meta.sources` name the public index; a private-index lock stays discovery-only and never reaches pypi.org), and Socket's own hosted / vendored references stay discoverable as the package they replace, so a re-scan of an already-redirected or already-vendored lock-only checkout re-confirms it (`--vex` attests, vendored reports `already_vendored`) instead of finding nothing. -**Mode ledgers (contract surfaces).** Each committable mode persists its state at a stable repo-relative path; external tools (and the depscan backend's GitHub-app PR flows) read and write these files, so path + schema are part of the contract: +**Mode ledgers (contract surfaces).** Vendored mode persists its state at a stable repo-relative path; external tools (and the depscan backend's GitHub-app PR flows) read and write it, so path + schema are part of the contract. Hosted mode (v5.0) persists nothing but its lockfile / config edits: * `.socket/vendor/state.json` — the **vendored**-mode ledger (see "Ownership, state, and reversal" below): wiring edits with verbatim pre-vendor originals, artifact fingerprints, and the embedded patch `record` — for every entry written by `scan`/`get --mode vendored` beside `detached: true` (the record is that entry's only source), and for standalone `vendor` fed by an agent-mode manifest as a fallback copy without `detached` (the manifest record stays authoritative while the manifest covers the entry, by ledger key or base purl; `vex`, `list` and `setup --check` fall back to the embedded copy when it does not, `repair` only with no manifest at all). Entries written before 5.0 by standalone `vendor` carry no `record`; readers tolerate its absence. **Schema version 2 (v5.0)**: the `new` of a whole-file wiring record (kinds `maven_pom_repository`, `nuget_config_source`, `python_lock_document`, `python_script_metadata`, `hatch_document`) of 1 KiB or more, when its `original` is a string, is stored as an edit of that same record's `original`: `{"snapshot": "", "ops": [[start, len] | "inserted text", …]}` (the text is the ops concatenated in order: a `[start, len]` byte range copied from the `original`, a string inserted as is), and the ledger's `version` is `2`; the `original` stays a plain string, no other record kind is touched, and a ledger without such a record keeps the version-1 bytes. Both versions are read; a version-2 edit is rebuilt and checked against its hash (a mismatch, a missing `original`, an out-of-range copy, or any other `{"snapshot": …}` value is `vendor_state_unreadable`), so every consumer sees the same full texts as with an inline version-1 ledger. Records are self-contained, so an older socket-patch re-saving a version-2 ledger (it keeps `original` / `new` verbatim and drops unknown fields) loses nothing. -* `.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; v5.0: a byte-identical re-save is skipped, which still satisfies the rule); `records` maps PURL → the full manifest `PatchRecord`, one of `vex`'s record sources for redirected patches with no manifest entry (a record attests only while a lockfile still wires its hosted patch — see "Manifest-less VEX" below). 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`). +* `.socket/vendor/redirect-state.json` — the **pre-v5 hosted**-mode ledger (`RedirectState` in `socket-patch-core/src/patch/redirect/state.rs`: `{ version, mode, edits[], records{} }`). **Retired in v5.0**: no command writes it, and `scan` / `get --mode hosted` ignore it. It is read for migration only — `list` and `vex` take a record from it for a hosted pin with the same purl and uuid (the lockfiles still decide what is hosted; its `edits` are never replayed), and a malformed one is only the `redirect_ledger_corrupt` warning there — and `rollback` / `remove` delete it once no lockfile pins a hosted patch any more (a `rollback` in a project whose ONLY state is this file removes it and exits 0, JSON `legacyRedirectLedgerRemoved: true`). A project scanned in hosted mode by v5 commits only its lockfile / config edits. -**get --mode and installed narrowing (v3.6).** `get --mode hosted|vendored` consumes the resolved patch(es) through the SAME engines as `scan --mode hosted|vendored`, so for the same selected (purl, uuid) set the on-disk result is identical by construction — this is the per-advisory selector hosted/vendored previously lacked (the old workaround, `get --save-only` then `vendor`, still works but is superseded). **Agent mode (v5.0 lock + residue rules)**: the download phase runs under `<.socket>/apply.lock` and hands the guard to the nested apply, so download → manifest write → apply is one lock window (the nested apply never re-acquires and inherits every caller flag — `--lock-timeout` and `--verbose` included); a failed acquire is `{status: "error", errorCode: "lock_held" | "lock_io", error}` on get's legacy envelope, exit 1, before any fetch (a read-only `.socket/` fails here, naming the lock path). `.socket/` and `.socket/blobs/` are created only when a record is actually persisted — an all-skipped or all-failed run leaves no `.socket/` on a fresh project — and a same-uuid `get ` re-run rewrites neither the manifest nor the blobs. Semantics: +**get --mode and installed narrowing (v3.6).** `get --mode hosted|vendored` consumes the resolved patch(es) through the SAME engines as `scan --mode hosted|vendored`, so for the same selected (purl, uuid) set the on-disk result is identical by construction — the per-advisory selector for hosted/vendored (`get --save-only` then `vendor` still works). **Agent mode (v5.0 lock + residue rules)**: the download phase runs under `<.socket>/apply.lock` and hands the guard to the nested apply, so download → manifest write → apply is one lock window (the nested apply never re-acquires and inherits every caller flag — `--lock-timeout` and `--verbose` included); a failed acquire is `{status: "error", errorCode: "lock_held" | "lock_io", error}` on get's legacy envelope, exit 1, before any fetch (a read-only `.socket/` fails here, naming the lock path). `.socket/` and `.socket/blobs/` are created only when a record is actually persisted — an all-skipped or all-failed run leaves no `.socket/` on a fresh project — and a same-uuid `get ` re-run rewrites neither the manifest nor the blobs. Semantics: -* **Hosted** (`get GHSA-… --mode hosted`): resolves the advisory, then hands the selected (purl, uuid) pairs to scan's hosted engine — reference grants, cross-mode takeover pre-revert, lockfile rewrite, `redirect-state.json` ledger (merge-never-clobber), gem stale-install probe, warnings, confirmation rules (cargo via `confirmed_cargo_uuids`, golang via `confirmed_golang_uuids` only) all identical to `scan --mode hosted`, and (v5.0) under the same `apply.lock` acquisition — taken around the first wet write, never on `--dry-run` or when nothing would be written; a failed acquire folds as top-level `errorCode: "lock_held" | "lock_io"` + string `error` (exit 1), and `--dry-run` under a held lock still exits 0. **No manifest write, no blobs** — the ledger is the persistence. JSON: get's legacy envelope gains the same nested `redirect` sub-object as scan's (`{mode:"hosted", redirected, rewrittenFiles, skipped, warnings, dryRun}`); the top-level shape is `{status, found, patches:[], warnings?}` — `downloaded`/`applied` are absent (nothing is downloaded into `.socket/`). Exit codes follow scan's hosted semantics: skipped grants and rewriter warnings never flip the exit; infra errors (reference fetch, corrupt/unwritable ledger, file writes) exit 1. Human prompt: `Redirect N packages to the hosted patch server?` (singular for one; get keeps its confirm gate, `--yes`/`--json`/non-TTY auto-accept as usual; as of v5.0 human `scan --mode hosted` prompts too — see the hosted section above). -* **Vendored** (`get GHSA-… --mode vendored`): the download phase is scan's vendored posture — **manifest-free (v5.0)**: the selected records are fetched into memory (`download_patch_records`; blobs held in memory; nothing under `.socket/` is written; the nested apply never runs), then scan's vendor step runs under the apply lock over exactly the selected records, like `scan --mode vendored` (no whole-manifest scope and no `[note]` about other records — that blast radius is retired with the manifest; a legacy manifest record for a vendored purl is migrated out of `.socket/manifest.json` the same way scan does it). JSON: get's envelope takes the detached download envelope's shape — `{status, found, downloaded, skipped, failed, detached: true, patches: [{purl, uuid, action: "downloaded" | "skipped" | "failed", …}], warnings?}` (`applied` is absent; `detached: true` is pinned; a `downloaded` record for a purl the vendor ledger holds at another uuid carries the additive `oldUuid`, derived from the ledger — the human `[fetch]` line reads ` (replacing )`) — and gains the nested `vendor` Envelope exactly like scan's `result["vendor"]`; a vendor-step error folds the partial envelope + `{status:"error", error:{code,message}}` in (a pre-failure takeover reconcile may have already mutated the ledger — its events must reach the consumer). Exit: download failures or vendor `has_errors` → `partial_failure`/1. Human prompt: `Download and vendor N patches?`; `--dry-run` prints `[dry-run] Would download and vendor N patches. No changes made.` on both identifier paths (uuid and search). Telemetry mirrors scan's vendored arms (`track_outcomes_for_vendor` / `track_patch_vendor_failed`). **Bun vendored preflight (additive)** — shared by `get --mode vendored` on both its paths and `scan --mode vendored`: before ANY patch download, and only when the selection holds a `pkg:npm/` purl, the download phase reads `bun.lock`/`bun.lockb` once (`preflight_vendor`) and, when the vendor backend would refuse the project — a malformed, unreadable or unsupported `bun.lockb` → `vendor_bun_lockb_invalid`; an unreadable `bun.lock` → `vendor_lockfile_missing`; a `lockfileVersion` other than 0/1/2 or a non-canonical `packages` grammar → `vendor_lockfile_version_unsupported`; `workspace:` packages in a lock below version 2 → `vendor_bun_workspace_unsupported` — every `pkg:npm/` result becomes `{action:"failed", errorCode:, error:}` with NO fetch (the patch view is never requested) and no patch record; other ecosystems' results are untouched. **Search path** (`get --mode vendored`) and `scan --mode vendored`: the records ride `patches[]` / `download.patches[]` with `downloaded: 0`, the download phase writes nothing under `.socket/` (v5.0 — a pre-existing `.socket/manifest.json`, including a record seeded for another purl, is left byte-untouched; previously the run re-serialized the manifest), the vendor step still runs over the remaining records (no event for the refused purl), exit `partial_failure`/1. **uuid path** (`get --mode vendored`): the uuid lookup is the only fetch; the run exits 1 BEFORE the vendor step with exactly `{status:"error", found:1, downloaded:0, skipped:0, failed:1, error:{code, message}, patches:[{purl, uuid, action:"failed", errorCode, error}]}` (the `error` OBJECT is the vendored-mode error shape of the vendor-step fold-in above) and writes nothing — no `.socket/` on a fresh project; human mode prints `Error (): ` on stderr. **Already-vendored exemption**: a purl is exempt from the workspace refusal only when every instance of its `name@version` in `bun.lock` is already a `.socket/vendor/npm/…` local tuple (any uuid; the digest-less 2-tuple counts) — the engine's own criterion — so in-sync re-runs, `repair`, and a superseding patch uuid on a project vendored before it grew a workspace member all flow to the engine (re-pinning an already-local tuple adds no workspace-relative exposure); a wiped ledger alone is not a refusal (the engine path decides). UUID equality in the ledger alone never exempts a purl: `rollback --preserve-state` retains its record after unwiring. Dry-run refusal takes priority over `already_vendored`. **Unreadable vendor ledger**: a `.socket/vendor/state.json` the preflight cannot read or parse is itself the refusal — `vendor_state_unreadable` with the io/parse detail, fail-closed (nothing is exempt) — on the uuid path, the search / `scan` path and the `--dry-run` preview alike; never a Bun lock code. **`--silent`** is "errors only" and never mutes the refusal: the code-tagged `[error] (): ` (per-patch paths) / `Error (): …` (uuid path) line stays on stderr with an empty stdout. **`--dry-run`** previews the refusal as the additive `would_refuse` action (see `--dry-run` below). Agent-mode `get --save-only` is NOT preflighted (record-only intent has no consumption precondition). Pinned by `tests/in_process_vendor_bun.rs` (exact uuid-path envelope, seeded-manifest survival, `--silent`, `--dry-run`) and `tests/scan_vendor_e2e.rs`. +* **Hosted** (`get GHSA-… --mode hosted`): resolves the advisory, then hands the selected (purl, uuid) pairs to scan's hosted engine — reference grants, cross-mode takeover pre-revert, lockfile rewrite (no ledger, v5.0), gem stale-install probe, warnings, confirmation rules (cargo via `confirmed_cargo_uuids`, golang via `confirmed_golang_uuids` only) all identical to `scan --mode hosted`, and (v5.0) under the same `apply.lock` acquisition — taken around the first wet write, never on `--dry-run` or when nothing would be written; a failed acquire folds as top-level `errorCode: "lock_held" | "lock_io"` + string `error` (exit 1), and `--dry-run` under a held lock still exits 0. **No manifest write, no blobs, no ledger** — the lockfile edits are the persistence. JSON: get's legacy envelope gains the same nested `redirect` sub-object as scan's (`{mode:"hosted", redirected, rewrittenFiles, skipped, warnings, dryRun}`); the top-level shape is `{status, found, patches:[], warnings?}` — `downloaded`/`applied` are absent (nothing is downloaded into `.socket/`). Exit codes follow scan's hosted semantics: skipped grants and rewriter warnings never flip the exit; infra errors (reference fetch, file writes) exit 1. Human prompt: `Redirect N packages to the hosted patch server?` (singular for one; `--yes`/`--json`/non-TTY auto-accept as usual). This confirm is get's alone: `scan` never prompts. +* **Vendored** (`get GHSA-… --mode vendored`): the download phase is scan's vendored posture — **manifest-free (v5.0)**: the selected records are fetched into memory (`download_patch_records`; blobs held in memory; nothing under `.socket/` is written; the nested apply never runs), then scan's vendor step runs under the apply lock over exactly the selected records, like `scan --mode vendored` (no whole-manifest scope and no `[note]` about other records — that blast radius is retired with the manifest; a legacy manifest record for a vendored purl is migrated out of `.socket/manifest.json` the same way scan does it). JSON: get's envelope takes the detached download envelope's shape — `{status, found, downloaded, skipped, failed, detached: true, patches: [{purl, uuid, action: "downloaded" | "skipped" | "failed", …}], warnings?}` (`applied` is absent; `detached: true` is pinned; a `downloaded` record for a purl the vendor ledger holds at another uuid carries the additive `oldUuid`, derived from the ledger — the human `[fetch]` line reads ` (replacing )`) — and gains the nested `vendor` Envelope exactly like scan's `result["vendor"]`; a vendor-step error folds the partial envelope + `{status:"error", error:{code,message}}` in (a pre-failure takeover reconcile may have already mutated the ledger — its events must reach the consumer). Exit: download failures or vendor `has_errors` → `partial_failure`/1. Human prompt: `Download and vendor N patches?`; `--dry-run` prints `[dry-run] Would download and vendor N patches. No changes made.` on both identifier paths (uuid and search). Telemetry mirrors scan's vendored arms (`track_outcomes_for_vendor` / `track_patch_vendor_failed`). **Bun vendored preflight (additive)** — shared by `get --mode vendored` on both its paths and `scan --mode vendored`: before ANY patch download, and only when the selection holds a `pkg:npm/` purl, the download phase reads `bun.lock`/`bun.lockb` once (`preflight_vendor`) and, when the vendor backend would refuse the project — a malformed, unreadable or unsupported `bun.lockb` → `vendor_bun_lockb_invalid`; an unreadable `bun.lock` → `vendor_lockfile_missing`; a `lockfileVersion` other than 0/1/2 or a non-canonical `packages` grammar → `vendor_lockfile_version_unsupported`; `workspace:` packages in a lock below version 2 → `vendor_bun_workspace_unsupported` — every `pkg:npm/` result becomes `{action:"failed", errorCode:, error:}` with NO fetch (the patch view is never requested) and no patch record; other ecosystems' results are untouched. **Search path** (`get --mode vendored`) and `scan --mode vendored`: the records ride `patches[]` / `download.patches[]` with `downloaded: 0`, the download phase writes nothing under `.socket/` (v5.0 — a pre-existing `.socket/manifest.json`, including a record seeded for another purl, is left byte-untouched), the vendor step still runs over the remaining records (no event for the refused purl), exit `partial_failure`/1. **uuid path** (`get --mode vendored`): the uuid lookup is the only fetch; the run exits 1 BEFORE the vendor step with exactly `{status:"error", found:1, downloaded:0, skipped:0, failed:1, error:{code, message}, patches:[{purl, uuid, action:"failed", errorCode, error}]}` (the `error` OBJECT is the vendored-mode error shape of the vendor-step fold-in above) and writes nothing — no `.socket/` on a fresh project; human mode prints `Error (): ` on stderr. **Already-vendored exemption**: a purl is exempt from the workspace refusal only when every instance of its `name@version` in `bun.lock` is already a `.socket/vendor/npm/…` local tuple (any uuid; the digest-less 2-tuple counts) — the engine's own criterion — so in-sync re-runs, `repair`, and a superseding patch uuid on a project vendored before it grew a workspace member all flow to the engine (re-pinning an already-local tuple adds no workspace-relative exposure); a wiped ledger alone is not a refusal (the engine path decides). UUID equality in the ledger alone never exempts a purl: `rollback --preserve-state` retains its record after unwiring. Dry-run refusal takes priority over `already_vendored`. **Unreadable vendor ledger**: a `.socket/vendor/state.json` the preflight cannot read or parse is itself the refusal — `vendor_state_unreadable` with the io/parse detail, fail-closed (nothing is exempt) — on the uuid path, the search / `scan` path and the `--dry-run` preview alike; never a Bun lock code. **`--silent`** is "errors only" and never mutes the refusal: the code-tagged `[error] (): ` (per-patch paths) / `Error (): …` (uuid path) line stays on stderr with an empty stdout. **`--dry-run`** previews the refusal as the additive `would_refuse` action (see `--dry-run` below). Agent-mode `get --save-only` is NOT preflighted (record-only intent has no consumption precondition). Pinned by `tests/vendor/in_process_vendor_bun.rs` (exact uuid-path envelope, seeded-manifest survival, `--silent`, `--dry-run`) and `tests/scan_vendor_e2e.rs`. -**Lock-text refusals before the download (v5.0)** — shared by `get --mode vendored` on both its paths and `scan --mode vendored`, after the Bun preflight above and the ledger's `already vendored` skip: a `pkg:npm/` result in a **pnpm, yarn classic or yarn berry** project, or a `pkg:cargo/` result, that its vendor backend refuses on the project's lock and manifest text alone is refused BEFORE its patch view is fetched — the pnpm / classic / berry gates the backend runs before it reads the package (coordinates, the lock and manifest reads and their line-ending / version / `cacheKey` / `.yarnrc.yml` gates, override and `resolutions` conflicts, the lock entry present and rewritable) and cargo's `locked_version_mismatch` (only when it is the crate's FIRST refusal; an in-tree `cargo vendor` copy still refuses in the loop as `already_vendored_in_tree`). **Scope:** only a package the vendor loop would hand to its backend is refused early — one installed on disk (the loop's own qualified-aware resolver plus the npm identity lookup), or one the lockfile inventory resolves to a verifiable registry source (a lock entry with an integrity, or the ledger-recovered pre-vendor resolution — exactly the entry the pristine fetch would use). A package absent from the lock and not installed never reached its backend and is untouched: its view is fetched, it downloads, and the vendor loop skips it `skipped` / `package_not_installed` as in v4.x (so cargo's `locked_version_mismatch` is refused early only for a crate installed at the unlocked version). The result becomes `{action:"failed", errorCode:, error:}` in `download.patches[]` / `patches[]` with the backend's exact code and detail, no view and no pristine fetch, no patch record, and therefore no vendor event: compared with v4.x, `download.downloaded` drops and `download.failed` rises by the number of such packages, `vendor.summary.failed` and `vendor.events` lose their `failed` events, and a lockfile-only package among them loses its `vendor_fetched_missing` event (it is never fetched). Exit code and top-level `status` are unchanged (`partial_failure`/1); the nested `vendor.status` becomes `success` when those refusals were the vendor step's only failures (observed on the depscan fixture: 3 refusals, `partialFailure` → `success`), and when every selected package is refused this way the human `scan --vendor` arm prints `Nothing was vendored: N patches failed (see above).`. **Precedence:** the lock-text refusal is decided before the view, so it wins over every view-derived outcome — a package that would also have been a paid-access 403 (`[PAID]`/no access), a failed view fetch, or a no-applicable-files skip reports the lock refusal instead (the Bun refusal and the ledger's `already vendored` skip still come first). The human `[error] (): ` line is printed during the download instead of the vendor step's failure line (the interactive human `scan --vendor` arm's pre-prompt baseline check still fetches the views it verifies; only the download, the pristine fetch and the vendor step skip the package there). A purl the hosted redirect ledger claims keeps the loop's refusal (its takeover revert rewrites the lock the gates read), as does every purl when that ledger is malformed; other flavors (package-lock, pnpm-legacy, bun) and ecosystems are untouched, and `--dry-run` is unchanged. `vendor` (manifest-driven, no view fetch) keeps its per-package `failed` events but no longer fetches the pristine source of a lockfile-only package it refuses this way — the source is deferred to the backend, which refuses before reading it (no `vendor_fetched_missing` event and no registry request; a refused package whose registry is unreachable reports the gate's code instead of `vendor_fetch_failed`); only a package the lock resolves to a verifiable source is deferred, and one it does not resolve keeps its `package_not_installed` skip. Pinned by `tests/scan_vendor_e2e.rs` (`exact_download_plan`: scan and exact-purl get, pnpm and cargo scope), `tests/e2e_yarn_legacy_cachekey_refusal_build.rs` and `tests/vendor_rerun_no_network_e2e.rs`. +**Lock-text refusals before the download (v5.0)** — shared by `get --mode vendored` on both its paths and `scan --mode vendored`, after the Bun preflight above and the ledger's `already vendored` skip: a `pkg:npm/` result in a **pnpm, yarn classic or yarn berry** project, or a `pkg:cargo/` result, that its vendor backend refuses on the project's lock and manifest text alone is refused BEFORE its patch view is fetched — the pnpm / classic / berry gates the backend runs before it reads the package (coordinates, the lock and manifest reads and their line-ending / version / `cacheKey` / `.yarnrc.yml` gates, override and `resolutions` conflicts, the lock entry present and rewritable) and cargo's `locked_version_mismatch` (only when it is the crate's FIRST refusal; an in-tree `cargo vendor` copy still refuses in the loop as `already_vendored_in_tree`). **Scope:** only a package the vendor loop would hand to its backend is refused early — one installed on disk (the loop's own qualified-aware resolver plus the npm identity lookup), or one the lockfile inventory resolves to a verifiable registry source (a lock entry with an integrity, or the ledger-recovered pre-vendor resolution — exactly the entry the pristine fetch would use). A package absent from the lock and not installed never reached its backend and is untouched: its view is fetched, it downloads, and the vendor loop skips it `skipped` / `package_not_installed` as in v4.x (so cargo's `locked_version_mismatch` is refused early only for a crate installed at the unlocked version). The result becomes `{action:"failed", errorCode:, error:}` in `download.patches[]` / `patches[]` with the backend's exact code and detail, no view and no pristine fetch, no patch record, and therefore no vendor event: compared with v4.x, `download.downloaded` drops and `download.failed` rises by the number of such packages, `vendor.summary.failed` and `vendor.events` lose their `failed` events, and a lockfile-only package among them loses its `vendor_fetched_missing` event (it is never fetched). Exit code and top-level `status` are unchanged (`partial_failure`/1); the nested `vendor.status` becomes `success` when those refusals were the vendor step's only failures (observed on the depscan fixture: 3 refusals, `partialFailure` → `success`), and when every selected package is refused this way the human `scan --vendor` arm prints `Nothing was vendored: N patches failed (see above).`. **Precedence:** the lock-text refusal is decided before the view, so it wins over every view-derived outcome — a package that would also have been a paid-access 403 (`[PAID]`/no access), a failed view fetch, or a no-applicable-files skip reports the lock refusal instead (the Bun refusal and the ledger's `already vendored` skip still come first). The human `[error] (): ` line is printed during the download instead of the vendor step's failure line (the human (non-`--silent`) `scan --vendor` arm's baseline pre-check still fetches the views it verifies; only the download, the pristine fetch and the vendor step skip the package there). A purl the lockfiles pin hosted keeps the loop's refusal (its takeover restore rewrites the lock the gates read); other flavors (package-lock, pnpm-legacy, bun) and ecosystems are untouched, and `--dry-run` is unchanged. `vendor` (manifest-driven, no view fetch) keeps its per-package `failed` events but no longer fetches the pristine source of a lockfile-only package it refuses this way — the source is deferred to the backend, which refuses before reading it (no `vendor_fetched_missing` event and no registry request; a refused package whose registry is unreachable reports the gate's code instead of `vendor_fetch_failed`); only a package the lock resolves to a verifiable source is deferred, and one it does not resolve keeps its `package_not_installed` skip. Pinned by `tests/scan_vendor_e2e.rs` (`exact_download_plan`: scan and exact-purl get, pnpm and cargo scope), `tests/e2e_yarn_legacy_cachekey_refusal_build.rs` and `tests/vendor/vendor_rerun_no_network_e2e.rs`. * **Installed-version narrowing** (all modes, `get`'s search path): a CVE/GHSA fan-out returns one patch record per patched VERSION; get keeps only versions present here and emits calm `skipped` records (`errorCode: "package_not_installed"`) for the rest — never an error exit. Presence = installed on disk (qualified-aware resolver) ∪ already tracked in the manifest (record maintenance keeps working on hosts without an installed copy); hosted/vendored modes additionally count lockfile-resolved deps and vendor-ledger purls (mirroring scan's discovery supplements, including their `--global` gate). **Exempt** (no narrowing): UUID identifiers, exact-versioned PURL identifiers (explicit intent), `--save-only` runs (record-only has no installation precondition — the fresh-clone record→vendor flow keeps working), `--all-releases`, and the package-name path (already installed-derived). When EVERY found patch is filtered out, get exits 0 with the additive status **`not_installed`** (`{status:"not_installed", found:N, downloaded:0, applied:0, patches:[], warnings?}`) — never `no_match`, which remains pinned to the fuzzy package-name path. PnP layouts are surfaced, not misreported: yarn-PnP npm results skip with `errorCode: "yarn_pnp_unsupported"` in every mode; pnpm-PnP skips carry `pnpm_pnp_unsupported` in agent/vendored modes; hosted mode — the refusal's own remedy — keeps ONLY the versions the raw `pnpm-lock.yaml` text actually resolves (boundary-anchored probe over the v5/v6/v9 key spellings, so a large fan-out never requests grants for every version ever patched), labels a JUDGED miss `package_not_installed` exactly like a non-PnP project (the layout blocked nothing — the lock was read and the version isn't resolved), and reserves the layout code for an unreadable lock (no judgment possible). When EVERY narrowed-out result is a PnP refusal, the human terminal names the layout instead of claiming "not installed" and never advises `--all-releases` (which cannot make PnP patchable); the JSON status stays `not_installed` — consumers dispatch on the per-record `errorCode`. Hosted mode also runs the per-release VARIANT filter (`filter_to_installed_releases`) on its search path before requesting grants — agent/vendored runs get it inside the download engines — with the same keep-all-plus-warning fallbacks (surfaced as `(release_narrowing)`-prefixed strings in `warnings[]`). An ecosystem this binary has no crawler for is likewise never judged: its results are KEPT (absence from a crawl that never looked carries no information — the same fail-safe as scan's prune GC). The human `Found N patches:` listing shows only the patches whose package version survived the narrowing (the narrowing is judged over every result, so an installed package's paid fix a free user cannot download still lists as `[PAID] (no access)`, while skip records and counts cover only accessible patches), sorted by PURL in natural version order (`4.17.2` before `4.17.10`); the narrowed-out ones are summarized on stderr in one line per reason (`Skipped N patches for M package versions not installed here (use --all-releases to include them).`), and `--verbose` adds one `[skip] ()` line per skipped version after that summary, in natural version order. When the candidates hold more patches than were selected and the pick was made without a menu (a paid user's auto-pick, `--yes`, a non-TTY run), a `Selected:` block names the patch (purl, tier, short uuid, advisories) that will be installed before the prompt. Machine output (the prompt count, the JSON envelope) uses the kept set, unchanged. The finer per-release variant narrowing (`filter_to_installed_releases`) is unchanged and still runs inside the download engines (and before an agent-mode `--dry-run` preview, so the preview names only the variants a wet run would fetch). -* **Deliberate divergences from scan** (documented, not drift): get keeps its `selection_required` JSON posture for free multi-patch PURLs (scan auto-picks); get has no `--vex` (an ambient `SOCKET_VEX` is ignored by get's modes), no `--detached` (moot — `get --mode vendored` is manifest-free by construction), no `--prune`; get does not run scan's pre-confirm vendor baseline annotation; and an all-narrowed-out run exits `not_installed` without entering the vendor step (heal-after-wipe re-vendoring stays `scan --mode vendored`'s job). Agent-mode `get` honors `--dry-run` too (v5.x; it used to download, save and apply anyway): the search and uuid paths classify each selected patch against the manifest (read-only; an unreadable manifest fails closed like the wet run) and stop before the prompt, the download, any `.socket/` write and the apply — human `[would-add]` / `[would-update] … (replacing )` / `[skip] … (already in manifest)` lines then `[dry-run] Would download and apply N patches. No changes made.`; JSON `{status:"success", dryRun:true, found, downloaded:0, skipped, applied:0, patches:[{purl, uuid, action:"would_add"|"would_update"(+oldUuid)|"skipped"}, ], warnings?}`, exit 0. +* **Deliberate divergences from scan** (documented, not drift): agent-mode get keeps its `selection_required` JSON posture for free multi-patch PURLs (scan and, v5.0, hosted/vendored get auto-pick); get has no `--vex` (an ambient `SOCKET_VEX` is ignored by get's modes), no `--prune`; get does not run scan's pre-vendor baseline annotation; and an all-narrowed-out run exits `not_installed` without entering the vendor step (heal-after-wipe re-vendoring stays `scan --mode vendored`'s job). Agent-mode `get` honors `--dry-run` too (v5.0): the search and uuid paths classify each selected patch against the manifest (read-only; an unreadable manifest fails closed like the wet run) and stop before the prompt, the download, any `.socket/` write and the apply — human `[would-add]` / `[would-update] … (replacing )` / `[skip] … (already in manifest)` lines then `[dry-run] Would download and apply N patches. No changes made.`; JSON `{status:"success", dryRun:true, found, downloaded:0, skipped, applied:0, patches:[{purl, uuid, action:"would_add"|"would_update"(+oldUuid)|"skipped"}, ], warnings?}`, exit 0. -`--dry-run` previews what `apply` / `rollback` / `scan --apply` / `repair` / `remove` — and `get` in every mode (hosted/vendored since v3.6, agent since v5.x) — would do without mutating disk. `get --mode hosted --dry-run` flows through the hosted engine's dry-run contract (no lock, no `.socket/`, no ledger write, no lockfile writes, `redirect.dryRun: true`); `get --mode vendored --dry-run` emits the same ledger-classification preview as scan's (`would_vendor` / `already_vendored` / `would_revendor`+`oldUuid` under the nested `vendor` key — plus, additive, `would_refuse` + `errorCode` + `error` for npm purls the wet run's Bun preflight would refuse: an in-sync `already_vendored` entry is exempt, as is a `would_revendor` entry whose `bun.lock` instances are all already local tuples; a purl the lock still resolves from the registry is refused like a fresh one, and the preview stays exit 0 / `status: "success"` with nothing written) before any download, and both skip the confirm prompt (nothing to confirm). 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"). `rollback --dry-run` (v5.0) previews every leg — the in-place restore verification, the vendored unwire (`Would revert/unwire vendoring for …`), the hosted unwind (the redirect engines resolve every inverse and drift check exactly like a wet run, flush nothing to disk, and claim the IN-MEMORY ledger clone exactly like a wet run — so the composed preview, per-purl reverts then whole-ledger replay, sees the same intermediate state a wet run would; the ON-DISK ledger is untouched), the manifest removals (simulated in memory), and the blob/archive GC — with no writes and no prompt. +`--dry-run` previews what `apply` / `rollback` / `scan --apply` / `repair` / `remove` — and `get` in every mode (hosted/vendored since v3.6, agent since v5.0) — would do without mutating disk. `get --mode hosted --dry-run` flows through the hosted engine's dry-run contract (no lock, no `.socket/`, no lockfile writes, `redirect.dryRun: true`); `get --mode vendored --dry-run` emits the same ledger-classification preview as scan's (`would_vendor` / `already_vendored` / `would_revendor`+`oldUuid` under the nested `vendor` key — plus, additive, `would_refuse` + `errorCode` + `error` for npm purls the wet run's Bun preflight would refuse: an in-sync `already_vendored` entry is exempt, as is a `would_revendor` entry whose `bun.lock` instances are all already local tuples; a purl the lock still resolves from the registry is refused like a fresh one, and the preview stays exit 0 / `status: "success"` with nothing written) before any download, and both skip the confirm prompt (nothing to confirm). 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"). `rollback --dry-run` (v5.0) previews every leg — the in-place restore verification, the vendored unwire (`Would revert/unwire vendoring for …`), the hosted upstream restore (every pin is resolved exactly like a wet run — registry lookups included, so a pin the wet run would refuse is previewed as that refusal — and nothing is flushed to disk), the manifest removals (simulated in memory), and the blob/archive GC — with no writes and no prompt. 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. @@ -166,6 +189,130 @@ The hidden alias `--no-apply` on `get --save-only` is **part of the contract** **Python stale-install guard**: after a hosted redirect, `scan` / `get` use the Python crawler to inspect every matching installed package, including Poetry's out-of-tree virtualenvs and `--global-prefix`. A readable file that differs from the patch's `afterHash` emits `redirect_pypi_stale_install` in JSON `redirect.warnings[]` and human stderr. The probe changes no installed files, re-runs on idempotent scans, and falls back to persisted patch records when fresh record fetching fails. Missing/unreadable files alone do not prove staleness; lock-only checkouts stay quiet. Dry runs skip the probe. Same-run VEX excludes positively stale Python packages (qualifier-insensitive), even with `--vex-no-verify` or a healthy copy in another interpreter; if nothing remains to attest, the command exits 1 with `no_applicable_patches`. Reinstall from the rewritten lock in the affected interpreter and verify with `socket-patch vex`. +### socket.yml patch policy (v5.0) + +A repository can **narrow** what `scan` patches with a `patches` block in its root `socket.yml` (the Socket scanner's config file; `version: 2` keeps every other consumer working — they strip or ignore the block). Usage guide: [repository patch policy](../../docs/configuration.md#repository-patch-policy). + +**Grammar.** Every key is optional; camelCase, like the rest of socket.yml. + +```yaml +version: 2 # required once a patches block exists (integer 2 or string "2") +projectIgnorePaths: ["examples/**"] # the scanner's key; socket-patch honors it too +patches: + enabled: true # bool, default true; false = report only, nothing is written + includePaths: ["/services/payments/"] # gitignore list; absent = every project + ignorePaths: ["/legacy/"] # gitignore list, evaluated after the built-in defaults + ecosystems: [npm, pypi] # any --ecosystems name (npm pypi cargo gem golang maven composer nuget deno) + packages: ["pkg:npm/lodash"] # allowlist in the --package grammar + ignorePackages: ["pkg:npm/left-pad"] # denylist in the --package grammar + minSeverity: high # critical|high|medium|moderate|low (moderate = medium) + maxNewPatches: 5 # integer 0..=4294967295; the per-run cap of `--max-new-patches` +``` + +- Deny wins: `ignorePackages` beats `packages`, ignore paths beat `includePaths`. An empty allowlist (`includePaths: []`, `ecosystems: []`, `packages: []`) is an error ("use `enabled: false`"), never "all". +- Package specs are exactly `--package`'s: a name (full or last segment, case-insensitive) or a purl with or without a version; qualifiers ignored. A bare name matches across ecosystems (`core` matches `@babel/core`), so prefer purls. Invalid: empty, or `pkg:` without a type and a name. +- `minSeverity` judges the patch by the worst severity across the advisories it fixes (the per-package records scan fetches, never the batch summary). With a floor set, a patch of unknown severity is skipped (`minSeverity: low` therefore still skips those). The floor restricts which patch may **win** a package: a lower-ranked patch above the floor can still win. + +**Precedence (flags narrow, never widen).** List filters intersect: `--ecosystems`, `--package` and PATH arguments narrow the file's lists further. Scalars go flag > env > file > default: `--min-severity ` > `SOCKET_MIN_SEVERITY` > `patches.minSeverity` > no floor. `--no-socket-yml` / `SOCKET_NO_SOCKET_YML` skips the file entirely (the built-in default ignores still apply). An empty env value is unset; a malformed flag or env value is a usage error (exit 2). + +**Paths.** Path lists are gitignore patterns with the npm `ignore` package's semantics (the backend's `projectIgnorePaths` matcher): case-insensitive, anchored at the repo root, a leading or middle `/` anchors, a bare name matches at any depth, a trailing `/` matches directories only, `!` negates, the last match wins, and a negation cannot re-include anything under an ignored directory (evaluation walks top-down). Backslash is gitignore's escape character, not a separator. Patterns with a `..` segment, a drive letter, a NUL byte, or over 1024 bytes are rejected. + +- They are matched against a project root's **marker files**, repo-relative: the lockfiles in the root's directory (`package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`, `bun.lock(b)`, `vlt-lock.json`, `rush.json`, `uv.lock`, `poetry.lock`, `pdm.lock`, `Pipfile.lock`, `requirements.txt`, `*.py.lock`/`pylock*.toml`, `Cargo.lock`, `go.mod`, `go.sum`, `composer.lock`, `Gemfile.lock`, `gems.locked`, plus the Maven/NuGet markers). A root is ignored iff **every** marker is ignored; with `includePaths`, it is included iff **any** marker matches; a disk root with no lockfile uses its manifests (`package.json`, `pyproject.toml`, `setup.py`, `Cargo.toml`, `composer.json`, `Gemfile`, `pom.xml`, `build.gradle`) instead, and one with neither is matched as its directory. So `/package-lock.json`, `**/yarn.lock` and `examples/**` mean what they mean to the scanner; `includePaths: ["/*", "!/*/"]` targets only the repo-root project. +- Evaluation order (one combined list): the **built-in defaults** `test/ tests/ fixtures/ __fixtures__/ testdata/`, then `projectIgnorePaths`, then `patches.ignorePaths`. Re-include a default with a negation (`ignorePaths: ["!/e2e/tests/"]`). The defaults apply only to **discovered** roots: hosted/vendored PATH-glob matches and roots the in-memory engine detects. A root you name — `--cwd`, a literal PATH, an in-memory `projectRoots` entry — skips them (the other lists still apply). `node_modules .git .socket .yarn vendor` stay structural excludes of in-memory root detection; no policy negates them. +- A workspace member that shares its root's lockfile is part of that root's project: exclude it with `ignorePackages`, not paths. +- A hosted/vendored PATH that resolves outside the repository root is a usage error (exit 2): one policy per invocation. + +**Lookup.** The repo root is the nearest ancestor of `--cwd` (inclusive) holding `.git` (a directory, a file for worktrees and submodules, or a symlink to either), not walking past a `GIT_CEILING_DIRECTORIES` entry or into the home directory (unless `--cwd` is it), and, on Unix, only when `.git` belongs to the current user, to root, or to the user `sudo` ran for (`SUDO_UID`); a root process trusts every owner, since CI containers commonly run as root over a checkout owned by another uid (otherwise warning `socket_yml_repo_untrusted` and `--cwd` is the root). No `.git`: the root is `--cwd`. Only `/socket.yml` and `/socket.yaml` are read, matched by exact directory-entry name (`Socket.yml` is not read: warning `socket_yml_name_case`); nested files never are. A symlinked file is followed only to a regular file inside the repo root. `--global` / `--global-prefix` scans read no file. + +**In memory (two-phase).** The host fetches the tree's root `socket.yml` / `socket.yaml` first and passes each to `selectHostedScanPaths` as `policyFiles: [{path, text} | {path, missing: true}]` (and `noSocketYml` when the session will bypass it). `text` must be a lossless UTF-8 decode (Node `buffer.toString('utf8')`; `TextDecoder` drops a BOM). Selection applies the full path policy (defaults, `projectIgnorePaths`, `patches` lists, negations), so a negated default-ignored root is fetched and patched as on disk; an excluded root is not streamed and is reported in `ignoredSample` with its `policy_*` reason (the session's `policy.filtered[]` lists only roots it received). Unless `noSocketYml`, a listed policy file not passed, passed `missing`, symlinked or invalid makes selection return `policyError` with nothing selected. Selection returns `policyPaths` and `policySha256` (`null` with no file, an empty file, or `noSocketYml`); the host streams the same text and passes both to the session, with the same `noSocketYml`. The session fails `socket_yml_invalid` when the policy it reads differs from `policySha256`, including a bypassed session given a digest. + +**Validation (fail closed).** Because the file only narrows, a file that cannot be honored never means "no policy". Checked in order: file access (a regular file after resolving, at most 64 KiB, read from the opened handle), encoding (UTF-8; a BOM is stripped and CRLF is fine; UTF-16 and NUL bytes are errors), YAML 1.2 syntax (duplicate keys, a non-mapping top level, nesting deeper than 32 and a second document are errors), a top-level key that looks like a misspelled `patches` (equal to `patch`/`patches` ignoring case, or within two edits of it and starting `pat`/`pac`, e.g. `patchs`), a top-level merge key (`<<`) or aliased key (either could carry a `patches` block other YAML readers apply), the version gate (`patches` requires `version: 2`), then the keys. Inside `patches` and `projectIgnorePaths`, anchors, aliases, merge keys (`<<`) and custom tags are errors (aliases elsewhere are never expanded). An unknown key under `patches` is an error with a did-you-mean hint and "a newer socket-patch may support it". Wrong types are errors — no coercion (`"false"` is not a bool; YAML 1.2, so `no` is a string) — as are an unknown severity, an out-of-range `maxNewPatches`, an invalid pattern or spec, a list over 1000 entries and an entry over 1024 bytes. Every error names the file and the key path (`patches.minSeverity`). `projectIgnorePaths` is validated strictly when a `patches` block exists (a single string is coerced to a one-element list); without one, a malformed value only warns `socket_yml_ignored_value` and is ignored, and it is honored whatever the `version`. An empty or comment-only file counts as no file. When both `socket.yml` and `socket.yaml` exist, both are validated; if their `projectIgnorePaths` and `patches` are equal as parsed values `socket.yml` is used, otherwise the run fails with `socket_yml_ambiguous`. + +**Error output.** Before any request or write, `scan` exits **1** with scan's error object plus an additive `errorCode` (`socket_yml_invalid` or `socket_yml_ambiguous`): `{"status": "error", "error": "socket.yml: patches.minSeverty: unknown key … (fix the file, or pass --no-socket-yml to ignore it)", "errorCode": "socket_yml_invalid", …}` with every count at zero; no `policy` block. Human output: `Error (socket_yml_invalid): …` on stderr. The in-memory engine reports `policyError: {code, detail}` (the detail without the CLI remedy) with no root processed and no file changed. + +**The trust boundary holds.** No key names an endpoint, a credential, an org, a mode, a download format or a safety switch — such keys are unknown keys and fail validation. Every key only removes candidates or (`maxNewPatches`) delays them; none can add a package or bypass the tier filter, the agent partition, reference grants, containment checks or any refusal. + +**Narrowing never removes.** The policy runs after the `--prune` universe is captured, so `--prune` still judges the full crawl. A package that already carries a recorded patch (the merged recorded view: manifest > hosted lockfile pins > vendor ledger) and is now excluded by paths, ecosystems, packages or `enabled: false` is **retained**: never handed to the hosted rewriters, the vendor engine or agent apply, never upgraded, left byte-identical, and listed under `policy.retained[]` with `upgradeAvailable`. A recorded package whose patches all fall below a new floor keeps its recorded patch, and the floor never replaces a recorded patch that outranks every admitted one (a recorded merged patch stays). Removing a patch is only ever `rollback` / `remove`, or the dependency leaving the lockfile. + +**`enabled: false`.** Discovery and the table still run; nothing is written (the `--prune` GC is skipped too); every candidate is reported `policy_disabled` (recorded ones as retained); warning `patches_disabled`; exit 0. + +**Commands.** `scan` (hosted, vendored, agent; wet and `--dry-run`), the in-memory engine and `hosted-bundle` honor the policy. `get` is explicit intent: it ignores the policy and warns `policy_bypassed` (in `warnings[]`, and on stderr) when socket.yml would have skipped the package; an invalid file never fails `get`, it only drops the warning. `apply`, `list`, `vex`, `rollback`, `remove`, `repair` and `vendor` ignore it. + +**`policy` JSON block** (additive, MINOR; on every successful `scan --json` result, and session-level on the in-memory result): + +```json +"policy": { + "source": "file", + "path": "socket.yml", + "sha256": "…", + "enabled": true, + "minSeverity": {"value": "high", "source": "file"}, + "counts": {"filtered": 3, "retained": 1}, + "filtered": [ + {"purl": "pkg:npm/qs@6.5.2", "uuid": null, "project": "services/legacy", + "reason": "policy_path_excluded", "detail": "/legacy/ (patches.ignorePaths)"} + ], + "retained": [ + {"purl": "pkg:npm/lodash@4.17.20", "project": "", "recordedUuid": "…", + "reason": "policy_package_ignored", "detail": "lodash (patches.ignorePackages)", "upgradeAvailable": true} + ] +} +``` + +- `source`: `none` (no file, an empty file, `--global`, or only a case variant; `path`/`sha256` null), `file` (the file used and the SHA-256 of its bytes), `bypassed` (`--no-socket-yml`). +- `minSeverity.source`: `flag` | `env` | `file` | `default`; `value` null = no floor (`moderate` reads as `medium`). +- `project`: the repo-relative root directory (`""` for the repo root). +- `filtered[]`: `uuid` is null for a package filtered before any patch lookup (path, ecosystem and package reasons — those packages are never queried); a root filtered as a whole is one entry with `purl: null`. A severity entry names the top-ranked patch the floor withheld. `retained[]`: recorded packages the filters hold in place. +- Reason codes (stable): `policy_disabled`, `policy_path_excluded`, `policy_path_not_included`, `policy_ecosystem`, `policy_package_not_listed`, `policy_package_ignored`, `policy_severity` (detail `low < high`, `unknown < high`). In-memory `ProjectResult.skipped[]` carries the post-lookup ones (severity, disabled) with the same codes. +- Every string copied from the file (patterns, specs, key names) is truncated to 200 characters with control characters stripped. +- Warnings ride scan's top-level `warnings[]` (`{code, detail}`): `socket_yml_ignored_value`, `socket_yml_name_case`, `socket_yml_repo_untrusted`, `patches_disabled`. +- Human output: one line after the table when anything was filtered or held, e.g. `Policy (socket.yml): 3 skipped by filters, 1 patched package held.`, then every skipped project and every critical/high patch the severity floor or `enabled: false` held back, by name (a policy must not hide those silently; path, ecosystem and package filters run before any patch lookup, so their severity is unknown); `--verbose` lists every entry. A report-only `--json` run (`--prune` or `--global` with no mode) fetches patch details only when a floor or `enabled: false` could withhold something, so its `filtered[]` matches the human output. +- `filtered[]` and `retained[]` are sorted by project, then purl; purls use the canonical spelling (qualifiers stripped, percent-decoded). +- Exit code is unchanged by filtering. + +### Per-run limit on new patches (`scan --max-new-patches`, v5.0) + +`scan --max-new-patches ` (env `SOCKET_MAX_NEW_PATCHES`; socket.yml `patches.maxNewPatches`) paces a rollout: each run adds at most N patches to packages that had none, the most critical first, and defers the rest to the next run. It applies to `scan` in hosted, vendored and agent mode, wet and `--dry-run`, and to the in-memory engine (napi `maxNewPatches`, `hosted-bundle`); `get` is explicit intent and ignores it. Usage guide: [gradual rollout](../../docs/configuration.md#gradual-rollout). + +**Classification.** After per-package selection, each selected `(project, purl)` row is compared with the project's **recorded view** — the merged manifest > hosted lockfile pins > vendor ledger that `updates[]` reads: + +| Class | Rule | Capped | The writer gets | +|---|---|---|---| +| ALREADY | the recorded uuid is the selected one, or the selection does not supersede it | no | the **recorded** uuid (re-confirmed idempotently, never swapped) | +| UPGRADE | the selection supersedes the recorded uuid (`ranking::search_result_supersedes`), or the recorded uuid is no longer offered | no | the selected uuid | +| NEW | nothing recorded for the base purl in this project | **yes** | the selected uuid, if admitted | + +Supersession is judged on the by-package records the selection itself uses, so a scan with or without a cap never replaces an applied patch with an equal sibling (the tier and uuid tiebreaks and a missing date never count). When the lockfiles of a project pin a purl to several uuids, the recorded uuid is the selected one if it is among them, else the smallest. A hosted pin on a patch server that discovery does not recognize (an origin missing from `--patch-server-url`) still counts as ALREADY when a lockfile names the selected uuid, so the cap can never stall on it. + +**Eligibility.** A NEW row is eligible only if every check the mode can decide without writing passes: the tier filter; the agent partition (vendor-owned and not-installed packages); the vendored Bun / vlt preflight; a `granted` hosted reference with a usable purl and url; the vlt artifact preflight; the vendored-to-hosted takeover refusals; wheel metadata; and a hosted rewrite whose confirmation probe shows a lockfile edit that pins it. Ineligible rows keep their own skip reasons and never hold a slot. References are fetched for every row before the budget is spent, and `--dry-run` fetches them too, so a dry run makes exactly the wet run's decisions. + +**Unit and order.** The budget counts distinct **base purls** (ecosystem + name + version, qualifiers stripped, percent-decoded; qualifier twins are one package): admitting one admits all of its eligible rows and costs one slot. Eligible NEW base purls are ranked by, ascending: in-flight first (in-memory `inFlightPatches` only); severity of the selected patch (critical, high, medium, low, unknown — the worst advisory it fixes); advisory count, descending; ecosystem name; base purl (bytewise); uuid. The order is total and has no time-dependent key (publish dates still pick the patch within a package, never the package order). + +**Budget scope.** Disk: one budget per invocation. The project directories a hosted or vendored scan's PATHs name are visited in sorted order; each spends what is left in rank order, and a base purl admitted in an earlier directory is admitted free in a later one. `scan --json` takes one directory, so a CI job per directory gets N per directory. In memory: one budget across every project root (roots are collected, planned once, then applied). The two therefore rank a multi-root repo differently, by design. + +**Incomplete data.** With a finite cap, a failed batch query, or a failed detail query for a package with no recorded patch, admits no NEW row that run (they are all deferred) and adds warning `rollout_incomplete_lookup`: a missing package must not let lower-ranked ones take its slot. ALREADY and UPGRADE rows proceed as usual. A failed hosted reference lookup that could only affect NEW rows of a capped run is warning `rollout_reference_failed` (the rows are deferred) instead of a run failure. + +**Convergence.** The limit is stateless: run k lands the top N, run k+1 finds them recorded and lands the next N, so M waiting patches take at most ceil(M/N) *committed* runs. A newly published or re-scored more severe patch moves ahead of the queue (intended: most critical first), and low-severity patches can wait while more severe ones keep arriving. A CI job that does not commit the scan's changes never advances: there a cap means "only the top N, every run" — commit the changes (or use a PR bot), or set no cap in such jobs. A write failure after admission still spends its slot (no backfill within a run). Known limits: a dependency that moves to a new version is NEW again (its hosted pin stays with the old lock entry); a qualifier twin that lands on a later run joins its package as ALREADY / UPGRADE, uncapped. + +**Output.** Every successful `scan --json` result carries an additive top-level `rollout` block (MINOR), zero counts when the run planned nothing (report-only and empty scans): + +```json +"rollout": { + "maxNewPatches": {"value": 5, "source": "flag"}, + "counts": {"new": 5, "deferred": 9, "upgrade": 1, "already": 12}, + "deferred": [ + {"purl": "pkg:npm/minimist@1.2.5", "uuids": ["…"], "severity": "critical", + "advisoryCount": 1, "projects": [""], "rank": 6} + ] +} +``` + +`maxNewPatches.value` is `null` for unlimited; `source` is `flag`, `env`, `file`, `default` or `cap` (the in-memory `maxNewPatchesCap` tightened it; the in-memory `maxNewPatches` option reports `flag`). `counts.new` / `counts.deferred` count base purls, `counts.upgrade` / `counts.already` count `(project, purl)` rows. `deferred[]` is in rank order: `purl` is the base purl, `uuids` the distinct selected uuids across its rows, `projects` the repo-relative project directories (`""` is the scanned directory), `rank` 1-based among eligible NEW base purls. Deferred rows are never written, downloaded or vendored: hosted mode mirrors each into `redirect.skipped[]` as `{purl, uuid, reason: "rollout_deferred", detail}`, the in-memory engine lists them in `ProjectResult.deferred[]` (`{purl, uuid, severity, rank}`) and `skipped[]`, and agent / vendored mode leave them out of `apply.patches[]` / `vendor`. Warnings (`rollout_incomplete_lookup`, `rollout_reference_failed`) go to the top-level `warnings[]`. Exit codes are unchanged: deferring is not a failure. + +Human output adds, when a cap is set, `Rollout: 3 of 9 new patches applied (maxNewPatches=3 from --max-new-patches); 0 upgrades, 0 already applied.` (with several project directories: `…, shared by this run's directories, 1 left)`) and next steps naming the deferred patches (`6 new patches deferred; commit these changes and run scan again to apply the next 3.`, `Next up: minimist@1.2.5 (critical), …`; a dry run says `would be deferred`, and an incomplete lookup says so instead). Hosted mode prints them on stdout after its own next steps, unindented; agent and vendored mode under a `Next steps:` heading. The `rollout` block is left out of error envelopes (`status: "error"`). + +CI recipe: `socket-patch scan --json --max-new-patches 5 | jq '.rollout.counts.deferred'`. + ### Embedded VEX (`apply --vex` / `scan --vex` / `vendor --vex`) `--vex ` folds OpenVEX 0.2.0 generation into `apply`, `scan`, and `vendor`: on a successful run the command writes the document to `` using the same engine as the standalone `vex` command. The `--vex-*` flags mirror `vex`'s `--product` / `--no-verify` / `--doc-id` / `--compact` knobs (namespaced to avoid colliding with the host command), and reuse the standalone env vars (`SOCKET_VEX_PRODUCT`, etc.). They are inert unless `--vex` is set. @@ -173,12 +320,12 @@ The hidden alias `--no-apply` on `get --save-only` is **part of the contract** Contract details: * **Always written to the file** — never stdout — so the document never races the command's own `--json` output. -* **Fail-the-command**: if `--vex` was requested but generation fails (product PURL undetectable, nothing to attest in the manifest / ledgers / lockfiles, all patches omitted, a corrupt ledger, unwritable path), the command exits non-zero **even when the apply/scan itself succeeded**. In `--json` mode the failure surfaces in the envelope's `error` (`apply`) / top-level `error` (`scan`), with a stable code (`product_undetected`, `no_applicable_patches`, `write_failed`, …). -* **Built from the post-run state** — the manifest, both `.socket/vendor` ledgers and the project's lockfile references (see "Manifest-less VEX" below) — and verified against on-disk state (unless `--vex-no-verify`; the wiring gates apply either way). Generated for real applies and read-only `scan` alike; `--dry-run` skips generation on every host command (nothing was changed, and a preview must not write an attestation — `scan --json` marks it `vex: {skipped: true, reason: "dry_run"}`). +* **Fail-the-command**: if `--vex` was requested but generation fails (product PURL undetectable, nothing to attest in the manifest / vendor ledger / lockfiles, all patches omitted, a corrupt vendor ledger, unwritable path), the command exits non-zero **even when the apply/scan itself succeeded**. In `--json` mode the failure surfaces in the envelope's `error` (`apply`) / top-level `error` (`scan`), with a stable code (`product_undetected`, `no_applicable_patches`, `write_failed`, …). +* **Built from the post-run state** — the manifest, the `.socket/vendor/state.json` ledger and the project's lockfile references, with hosted records fetched from the API (see "Manifest-less VEX" below) — and verified against on-disk state (unless `--vex-no-verify`; the wiring gates apply either way). Generated for real applies and read-only `scan` alike; `--dry-run` skips generation on every host command (nothing was changed, and a preview must not write an attestation — `scan --json` marks it `vex: {skipped: true, reason: "dry_run"}`). * **JSON success surface**: `apply` adds a top-level `vex` object to its envelope; `scan` adds a top-level `vex` key to its result. Both carry `{ path, statements, format: "openvex-0.2.0" }`. -* `apply`'s no-manifest early exit (the `noManifest` success no-op; v5.0: its human line is `No patch manifest found; nothing to apply.` — it names the missing `.socket/manifest.json`, not the folder, since `.socket/` may legitimately hold setup files or vendored state) and `vendor`'s (`No manifest found, nothing to vendor.`) still generate the document from the lockfiles and `.socket/vendor` ledgers (manifest-less VEX: hosted / vendored checkouts carry no manifest). Nothing referenced anywhere keeps the calm exit 0 (a stale document at the path is removed; `--json` carries any discovery diagnostics in `warnings[]`); any other VEX failure fails the command with exit 1 — including a run whose only candidates are omitted `record_unavailable` (an `--offline` run over a lockfile-wired checkout with no local records), so an ambient `SOCKET_VEX` there fails the install. `--dry-run` skips generation on both, and so does `apply --check` — it stays read-only and offline-safe, leaving the output path untouched. `scan` has no such early exit: with no manifest and nothing wired anywhere its `--vex` fails with `manifest_not_found`. +* `apply`'s no-manifest early exit (the `noManifest` success no-op; v5.0: its human line is `No patch manifest found; nothing to apply.` — it names the missing `.socket/manifest.json`, not the folder, since `.socket/` may legitimately hold vendored state) and `vendor`'s (`No manifest found, nothing to vendor.` — a project with hosted pins ejects instead, v5.0) still generate the document from the lockfiles and the vendor ledger (manifest-less VEX: hosted / vendored checkouts carry no manifest). Nothing referenced anywhere keeps the calm exit 0 (a stale document at the path is removed; `--json` carries any discovery diagnostics in `warnings[]`); any other VEX failure fails the command with exit 1 — including a run whose only candidates are omitted `record_unavailable` (an `--offline` run over a lockfile-wired checkout with no local records), so an ambient `SOCKET_VEX` there fails the install. `--dry-run` skips generation on both, and so does `apply --check` — it stays read-only and offline-safe, leaving the output path untouched. `scan` has no such early exit: with no manifest and nothing wired anywhere its `--vex` fails with `manifest_not_found`. * **Stale-doc removal (v3.5)**: a run that ends in a VEX error removes a recognizably-OpenVEX file (JSON whose `@context` names openvex.dev) already sitting at the output path — a pipeline reusing one path can never ship yesterday's attestation for a now-unpatched tree. Unrelated files at the path are never touched; a mid-write partial that no longer parses as JSON is left for downstream parsers to reject loudly. -* **Additive warnings (v3.5)**: `product_not_iri` (the `--product`/`--vex-product` override is neither a `pkg:` purl nor an absolute IRI; honored verbatim, warned) and `vendored_tree_out_of_sync` (a healthy vendored attestation stands on the committed artifact + lock wiring while the PRESENT installed tree hash-mismatches the patched bytes — run the package manager's install; the attestation itself is unchanged). Both ride stderr in human mode and `warnings[]` in the standalone `vex --json` envelope. Same channel for `product_multiple_manifests` (auto-detect found several project manifests and names the one it used), `vex_stale_doc_removed` (the stale-doc removal above happened), the manifest-less plan's advisories — `vex_wiring_conflict` (the lockfiles wire a package to different patches: which files, which uuids, how to fix it), `vex_record_superseded` (a recorded patch replaced by the lockfile-wired one), `vex_claim_unwired` (a ledger claim whose patch the lockfiles still mention, but not as wiring), `vex_record_offline` / `vex_record_not_found` / `vex_record_fetch_failed` (why a lockfile-wired patch has no record — the detail behind a `record_unavailable` skip) and `api_auth_fallback` (the authenticated API refused the credentials and the public proxy served free patches only; `get` / `scan`'s warning text) — and, standalone only, `org_looks_like_path` (`-o`/`--org` given a file-shaped value — `-O` is `--output`). The standalone error envelope carries `warnings[]` too. An embedded `--vex` that fails also folds each omitted patch into the host command's `warnings[]` as `vex_omitted` (`: ()` — standalone `vex` lists them as `skipped` events), and `--silent` lists them as `omitted: ()` lines under the error. A corrupt `.socket/vendor/state.json` or `redirect-state.json` is no longer degraded with a warning: every form of vex fails with `vendor_ledger_corrupt` / `redirect_ledger_corrupt` (see the error-code table). +* **Additive warnings (v3.5)**: `product_not_iri` (the `--product`/`--vex-product` override is neither a `pkg:` purl nor an absolute IRI; honored verbatim, warned) and `vendored_tree_out_of_sync` (a healthy vendored attestation stands on the committed artifact + lock wiring while the PRESENT installed tree hash-mismatches the patched bytes — run the package manager's install; the attestation itself is unchanged). Both ride stderr in human mode and `warnings[]` in the standalone `vex --json` envelope. Same channel for `product_multiple_manifests` (auto-detect found several project manifests and names the one it used), `vex_stale_doc_removed` (the stale-doc removal above happened), the manifest-less plan's advisories — `vex_wiring_conflict` (the lockfiles wire a package to different patches: which files, which uuids, how to fix it), `vex_record_superseded` (a recorded patch replaced by the lockfile-wired one), `vex_claim_unwired` (a ledger claim whose patch the lockfiles still mention, but not as wiring), `vex_record_offline` / `vex_record_not_found` / `vex_record_fetch_failed` (why a lockfile-wired patch has no record — the detail behind a `record_unavailable` skip) and `api_auth_fallback` (the authenticated API refused the credentials and the public proxy served free patches only; `get` / `scan`'s warning text) — and, standalone only, `org_looks_like_path` (`-o`/`--org` given a file-shaped value — `-O` is `--output`). The standalone error envelope carries `warnings[]` too. An embedded `--vex` that fails also folds each omitted patch into the host command's `warnings[]` as `vex_omitted` (`: ()` — standalone `vex` lists them as `skipped` events), and `--silent` lists them as `omitted: ()` lines under the error. A corrupt `.socket/vendor/state.json` is no longer degraded with a warning: every form of vex fails with `vendor_ledger_corrupt` (see the error-code table). A malformed pre-v5 `redirect-state.json` is, as of v5.0, only the `redirect_ledger_corrupt` warning (hosted mode keeps no ledger; the file is an optional migration record source). ### VEX provenance markers (contract) @@ -188,24 +335,24 @@ Every VEX statement's impact string records which patch-application mode persist |---|---|---| | `Patched via Socket patch ` | agent | installed-tree file hashes vs the manifest's `afterHash` | | `Patched via Socket patch (vendored)` | vendored | the committed `.socket/vendor/` artifact (no install hook needed) | -| `Patched via Socket patch (redirected)` | hosted | the lockfile's hosted integrity pin; in-run `scan --mode hosted --vex` attests from the redirect ledger WITHOUT hash verification (the JSON `vex` summary carries `verified: false`), while a post-install `socket-patch vex` re-proves the lockfile wiring and hash-verifies the installed copy the build consumes — or, with nothing installed, attests a discovered lockfile reference from its integrity pin (see "Manifest-less VEX") | +| `Patched via Socket patch (redirected)` | hosted | the lockfile's hosted integrity pin; in-run `scan --mode hosted --vex` attests from the patch records THIS RUN fetched (v5.0: held in memory; hosted mode persists none) WITHOUT hash verification (the JSON `vex` summary carries `verified: false`), while a post-install `socket-patch vex` re-proves the lockfile wiring and hash-verifies the installed copy the build consumes — or, with nothing installed, attests a discovered lockfile reference from its integrity pin (see "Manifest-less VEX") | `vendored` and `redirected` are disjoint in practice (the modes conflict); if a PURL somehow appears in both sets, `vendored` wins. -**Patch hosts (manifest-less VEX).** A hosted lockfile reference counts only when it points at Socket's patch server or the operator's `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin. A redirect-ledger record whose recorded wiring names its patch on any OTHER host — a staging patch server used without `--patch-server-url`, or a look-alike host — is judged by the ledger's own recorded wiring: with verification on it attests only an installed tree that hashes to the record (nothing installed is `package_not_found`, pristine bytes `not_applied`), but `--no-verify` / `--vex-no-verify` trusts the records by definition and attests it `(redirected)`, because the wiring gate cannot tell a staging host from a hostile one. Pass `--patch-server-url` for a non-production patch server, and do not combine `--no-verify` with lockfiles you do not trust. +**Patch hosts (manifest-less VEX).** A hosted lockfile reference counts only when it points at Socket's patch server or the operator's `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin. A hosted URL on any OTHER host — a staging patch server used without `--patch-server-url`, or a look-alike host — is not a hosted pin at all (v5.0: there is no redirect ledger to vouch for it), so `vex` does not attest it, and `list`, `rollback`, `remove`, `vendor` and `repair` do not see it either. Pass `--patch-server-url` for a non-production patch server. (A pre-v5 redirect ledger still on disk can keep such a record in play under the pre-v5 rule: judged by the ledger's own recorded wiring, attesting only an installed tree that hashes to the record unless `--no-verify` / `--vex-no-verify` trusts it; do not combine `--no-verify` with lockfiles you do not trust.) ### Manifest-less VEX (lockfile discovery) -`vex` and every embedded `--vex` attest hosted and vendored patches without `.socket/manifest.json`, and without the `.socket/vendor` ledgers too, by reading the wiring out of the project's lockfiles and package-manager configs. This covers a depscan-opened PR, a clone of a repo that never committed its ledgers, and a `scan --mode hosted` checkout. The merge lives in `commands/vex_sources.rs`; discovery lives in `socket-patch-core/src/vex/discover/`. +`vex` and every embedded `--vex` attest hosted and vendored patches without `.socket/manifest.json`, and without the vendor ledger too, by reading the wiring out of the project's lockfiles and package-manager configs. This covers a depscan-opened PR, a clone of a repo that never committed its vendor ledger, and every `scan --mode hosted` checkout (v5.0 hosted mode keeps no ledger at all: its records come from the API). The merge lives in `commands/vex_sources.rs`; discovery lives in `socket-patch-core/src/vex/discover/`. **Inputs.** Four sources feed one record view: 1. `.socket/manifest.json`. A missing file counts as empty. -2. The redirect ledger's `records`. -3. The vendor ledger entries' embedded `record`s. -4. Lockfile discovery. +2. The vendor ledger entries' embedded `record`s. +3. Lockfile discovery — the only source of hosted references (v5.0). +4. Hosted records: the ones an in-run `scan --mode hosted --vex` fetched this run, and — for migration only — a pre-v5 `.socket/vendor/redirect-state.json`'s `records` (a malformed one is the `redirect_ledger_corrupt` WARNING, v5.0, and is simply not consulted). Anything else is fetched from the API (see **Record resolution**). -Discovery is read-only, never touches the network, and never fails the run: a malformed file becomes a diagnostic. It reads files at `--cwd`, the root where the ledgers are read, and it does so under `--global` / `--global-prefix` as well, because discovery is what gates the ledgers (below). It reads only root files (no nested workspace-member locks) except where noted, and it reads **every** supported file that is present. There is no precedence chain: the hosted rewriter edits every candidate it finds, so a lock that another lock "shadows" can still carry wiring. Every value is committed, tamperable data, so each one is validated fail-closed: canonical uuid grammar, path-safe coordinates, root-anchored `.socket/vendor/` paths, and the patch-host allowlist. +Discovery is read-only, never touches the network, and never fails the run: a malformed file becomes a diagnostic. It reads files at `--cwd`, the root where the vendor ledger is read, and it does so under `--global` / `--global-prefix` as well, because discovery is what gates the ledger entries (below). It reads only root files (no nested workspace-member locks) except where noted, and it reads **every** supported file that is present. There is no precedence chain: the hosted rewriter edits every candidate it finds, so a lock that another lock "shadows" can still carry wiring. Every value is committed, tamperable data, so each one is validated fail-closed: canonical uuid grammar, path-safe coordinates, root-anchored `.socket/vendor/` paths, and the patch-host allowlist. | Ecosystem | Files read | Hosted reference | Vendored reference | Hosted pin (`integrity_required`) | |---|---|---|---|---| @@ -225,26 +372,26 @@ Discovery is read-only, never touches the network, and never fails the run: a ma Recognition rules that hold for every ecosystem: -* **Patch hosts.** A hosted reference counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin, with no userinfo. The uuid is the URL's LAST canonical-uuid path segment, because grant tokens may themselves be uuid-shaped. The Go module prefix is fixed. `socket-patch-` registry / repository / source names count only through a pin. For a redirect-ledger record on any other host, see **Patch hosts** above. +* **Patch hosts.** A hosted reference counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin, with no userinfo. The uuid is the URL's LAST canonical-uuid path segment, because grant tokens may themselves be uuid-shaped. The Go module prefix is fixed. `socket-patch-` registry / repository / source names count only through a pin. For a URL on any other host, see **Patch hosts** above. * **Pins, not definitions.** A registry, index or source *definition* alone (cargo `[registries]`, nuget ``, pom ``, uv index tables, `.npmrc`) never makes a reference, because it survives a reverted pin. Sections the package manager ignores are not read: npm's v2 `dependencies` mirror, a `.cargo/config.toml` shadowed by `.cargo/config`. A Socket pin inside a maven `` is diagnosed, never a reference. * **Contested locks.** When one lock wires a package to a patch and another lock resolves the same `name@version` from a non-Socket source, the build's bytes depend on which package manager runs. The reference is then dropped with a `patched_ref_unattributable` diagnostic naming both files. This applies across npm / pnpm / yarn / bun and across uv / pylock / poetry / pdm / Pipfile.lock / requirements. PEP 723 script locks neither contest nor are contested. -* **Lockless pins.** With no lock to name a version, a `Cargo.toml` pin (every declaration on `socket-patch-`, that registry defined on the patch host for the same uuid) or an exclusive nuget exact-id mapping is never a reference on its own. It still keeps a redirect-ledger record live for a version the pin admits. +* **Lockless pins.** With no lock to name a version, a `Cargo.toml` pin (every declaration on `socket-patch-`, that registry defined on the patch host for the same uuid) or an exclusive nuget exact-id mapping is never a reference on its own, so v5.0 does not attest it (nor does `list` show it, or `rollback` / `remove` restore it — restore those files from version control). Only a pre-v5 redirect-ledger record naming a version the pin admits keeps it live. The same holds for a gem wired only in the `Gemfile` (the pre-bundler-2.6 mixed state, lock not converged). -**Record resolution.** A candidate's record must carry the patch uuid the lockfile actually **wires**. It is taken from the first source that has one: the manifest (matched qualifier-insensitively), the redirect ledger's `records`, then the vendor ledger's embedded records. If none has it and the run is online, `vex` fetches the patch view by uuid from the patch API. The fetch uses `get`'s API client: the public proxy when no token is configured, and a one-shot 401/403 fallback to the proxy (free patches only). At most 10 fetches run concurrently. Fetched records stay in memory: `vex` never writes the manifest. A candidate still has no record under `--offline`, after a transport error or a 404, or when the patch is refused (paid without an entitled token); it is then omitted as `record_unavailable`, and the run is not aborted. A record whose uuid or package disagrees with the wiring is omitted as `record_mismatch`. The informational `socket-patch.vendor.json` marker is never a record source. When the lockfile wires a package to patch U, a manifest or ledger record for that package under another uuid is superseded, and a human-mode `Note:` says so. +**Record resolution.** A candidate's record must carry the patch uuid the lockfile actually **wires**. It is taken from the first source that has one: the manifest (matched qualifier-insensitively), the hosted records above (this run's, then a pre-v5 ledger's), then the vendor ledger's embedded records. If none has it and the run is online, `vex` fetches the patch view by uuid from the patch API — for a v5 hosted checkout this is the normal path. The fetch uses `get`'s API client: the public proxy when no token is configured, and a one-shot 401/403 fallback to the proxy (free patches only). At most 10 fetches run concurrently. Fetched records stay in memory: `vex` never writes the manifest. A candidate still has no record under `--offline`, after a transport error or a 404, or when the patch is refused (paid without an entitled token); it is then omitted as `record_unavailable`, and the run is not aborted. A record whose uuid or package disagrees with the wiring is omitted as `record_mismatch`. The informational `socket-patch.vendor.json` marker is never a record source. When the lockfile wires a package to patch U, a manifest or ledger record for that package under another uuid is superseded, and a human-mode `Note:` says so. -**Verification basis.** `(vendored)` and `(redirected)` patches bypass the Property 7 ecosystem filter, because their wiring is the persistence. With no manifest there is no `setup.manual`, and none is needed. +**Verification basis.** | Wiring | Evidence (verify mode) | Marker | |---|---|---| | Vendored: a lockfile/config wires a `.socket/vendor` artifact, or a live vendor ledger entry | The **committed artifact** is hashed against the record's `afterHash`. The ledger entry is used when it names the wired artifact (it carries the dir-artifact inventory); otherwise an entry is synthesized from the reference. A present installed tree with different bytes only warns `vendored_tree_out_of_sync`. | `(vendored)` | -| Hosted: a discovered patch-host reference, or a live redirect-ledger record | The installed copies the build **consumes** through the hosted wiring are hash-verified when any exist: the Go replacement module, never the pristine `M@v` in the module cache; the Socket-registry cargo source dir; maven's suffixed version. Installed evidence wins: `hash_mismatch` / `not_applied` are omitted. With **nothing installed**, a discovered reference whose lock pins the artifact (or whose format's rewriter never writes a pin) attests from that pin, which is the same evidence as in-run `scan --mode hosted --vex`. A ledger-only record, or a reference whose required pin is missing, stays `package_not_found`. So do purls that `--ecosystems` kept out of the crawl, because "not installed" has to mean the crawler looked. | `(redirected)` | +| Hosted: a discovered patch-host reference (or a live pre-v5 redirect-ledger record) | The installed copies the build **consumes** through the hosted wiring are hash-verified when any exist: the Go replacement module, never the pristine `M@v` in the module cache; the Socket-registry cargo source dir; maven's suffixed version. Installed evidence wins: `hash_mismatch` / `not_applied` are omitted. With **nothing installed**, a discovered reference whose lock pins the artifact (or whose format's rewriter never writes a pin) attests from that pin, which is the same evidence as in-run `scan --mode hosted --vex`. A pre-v5 ledger-only record, or a reference whose required pin is missing, stays `package_not_found`. So do purls that `--ecosystems` kept out of the crawl, because "not installed" has to mean the crawler looked. | `(redirected)` | | Agent: a manifest record with no live hosted/vendored wiring | The installed tree, unchanged | none | **Liveness gates.** These gates run before hashing, and `--no-verify` / `--vex-no-verify` skips only the hashing, never the gates: * A **vendor ledger entry** attests only while some lockfile or config still wires its artifact. Otherwise it is omitted as `vendor_unwired`. The exception is a hosted takeover: the same package with a live redirect record falls through to that hosted claim. -* A **redirect ledger record** attests only while a lockfile still wires its hosted patch. Otherwise it is omitted as `redirect_unwired`. The exception is a manifest-owned purl, which falls back to agent-mode verification. The purls that an in-run `scan --mode hosted --vex` itself confirmed count as live. -* **Discovery is authoritative** for every patch uuid that a file it read *mentions*: the accepted references alone decide. A mention an extractor rejected keeps nothing alive, whatever raw text survives. That covers an orphaned berry entry, an inert Go replace, a reverted cargo pin, a uv lock its `pyproject.toml` does not confirm, a shadowed maven pin, a contested lock, a commented-out line and an unparseable lock. Only for a uuid that no read file mentions (formats no extractor reads, patch hosts outside the allowlist) does the ledger's own recorded wiring decide. Even then, only files that PIN the resolution count, never a leftover registry definition. +* A **hosted record** (this run's, or a pre-v5 ledger's) attests only while a lockfile still wires its hosted patch. Otherwise it is omitted as `redirect_unwired`. The exception is a manifest-owned purl, which falls back to agent-mode verification. The purls that an in-run `scan --mode hosted --vex` itself confirmed count as live. +* **Discovery is authoritative** for every patch uuid that a file it read *mentions*: the accepted references alone decide. A mention an extractor rejected keeps nothing alive, whatever raw text survives. That covers an orphaned berry entry, an inert Go replace, a reverted cargo pin, a uv lock its `pyproject.toml` does not confirm, a shadowed maven pin, a contested lock, a commented-out line and an unparseable lock. Only for a uuid that no read file mentions (formats no extractor reads, patch hosts outside the allowlist) does a ledger's own recorded wiring decide. Even then, only files that PIN the resolution count, never a leftover registry definition. * **Wiring conflict.** When the lockfiles wire one package to two or more different patches, every candidate for that package is omitted as `wiring_conflict`, with a note naming the patches. A reverted lockfile plus a leftover ledger or artifact therefore stops attesting, even under `--no-verify`. @@ -262,136 +409,48 @@ Human mode also prints `Note:` lines: superseded records, fetch failures, `--off **Output.** A manifest-less run honors every `vex` output convention: `--output -` (or `-O -`) prints the document to stdout; `--dry-run` still discovers, fetches records and verifies, but writes nothing and leaves a previous document at the path alone (`[dry-run] Would write …`, `dryRun: true`); an embedded `--vex` under `--dry-run` skips generation with the shared `Skipping VEX generation (--dry-run: nothing was …).` line. -## Setup command contract - -`setup` wires a repository for **automatic patching**: after the ecosystem's own install/build step -runs, locally-installed dependencies are re-patched to match the Socket manifest (`.socket/manifest.json`) -with no further human action. It does this by installing an ecosystem-native hook (see the support -matrix below). `setup --check` verifies that state; `setup --remove` reverts it. - -The properties below are the public contract. Each is backed by a test under -`crates/socket-patch-cli/tests/setup_*.rs`; properties not yet fully implemented are called out -explicitly and guarded by a deliberately-failing (RED) test that encodes the intended behavior — these -are the executable spec for follow-up work, **not** regressions. Changing any property below is governed -by the [semver policy](#semver-policy) (scoping `setup` by `--ecosystems` and strengthening `--check`, -in particular, are behavior changes that gate a version bump when implemented). - -1. **Idempotent.** Re-running `setup` on an already-configured repo changes nothing: status - `already_configured`, `updated: 0`, every manifest byte-identical. *(Implemented.)* - -2. **Ecosystem-scoped.** `setup`, `setup --check`, and `setup --remove` honor the global - `--ecosystems` filter and act on only the named ecosystems; with no filter they act on every - detected ecosystem. *(Intended; **not yet implemented** — `setup` currently ignores `--ecosystems` - and always processes every detected ecosystem (npm + python + gem). RED-guarded.)* - -3. **Consistency after install.** Once an ecosystem is set up, its locally-installed dependencies are - re-patched to match the manifest after **any** of: a dependency added, updated, or removed; **or** a - new patch added to the manifest. The re-patch is carried by the ecosystem's install hook (npm - `postinstall`/`dependencies`, the Python `.pth` startup hook, the gem Bundler plugin) which runs - `socket-patch apply` after the ecosystem's installer finishes, so patch state always reconverges with - the manifest. *(Implemented for npm/pypi/gem via the support matrix. Cargo and Go have no `setup` - hook — see "Cargo and Go: apply-only, no setup" below.)* - -4. **`check` proves a correctly-patched state.** `setup --check` reports `configured` only when the - in-scope ecosystems are *actually in a correctly patched state* — install hooks present **and** - on-disk patch consistency verified (the `apply --check` invariant: every manifest file's hash matches - `afterHash`). *(Implemented — `run_check` appends a `patch` entry per installed-but-drifted PURL via - `append_patch_consistency_entries`; uninstalled packages and zero-file records are not drift. - v5.0: vendored patches are consulted from the vendor ledger's embedded `record`s and verified - against the committed artifact — a manifest-less vendored project is checked the same way.)* - -5. **In-repo and committable.** `setup` writes only inside the working tree: `package.json`, - `pyproject.toml`/`requirements.txt`, `composer.json` (the `post-install-cmd`/`post-update-cmd` - hooks), the `Gemfile` + the generated `.socket/bundler-plugin/{plugins.rb,socket-patch.gemspec}` - and `.socket/.gitignore` (one line ignoring the machine-local stamp), and `.socket/manifest.json` - only when `--exclude` persists an exclusion (property 9). Every artifact is git-committable. - `setup --check` writes nothing, and an already-configured `setup` writes nothing unless - `--exclude` is passed explicitly. The `--exclude` persistence (v5.0) runs AFTER discovery and - the confirm prompt, as a read-modify-write under `<.socket>/apply.lock` (`setup` joins the - `--lock-timeout` contenders): a held or unopenable lock, or a manifest that cannot be read or - written, is reported as a `not persisting --exclude: — ` warning — never exit 1 — - and a byte-identical exclude list neither locks nor rewrites. `--check` (property 4) reads the - vendor ledger even without a manifest; a ledger it cannot read or parse is surfaced as a - `Warning: Unreadable vendor state (…)` line (muted by `--silent`) plus a `vendor_ledger` `files[]` - entry with `status: error` — verdict `error`, exit 1 — never as a `configured` verdict. It never writes outside - `--cwd` — no `$HOME`, no global `site-packages` (the Python `.pth` wheel is installed later by the - user's package manager, not by `setup`; the gem patch stamp is written by the plugin at - `bundle install` time, not by `setup`, at `.socket/gem-plugin-stamp` — machine-local, hence the - `.gitignore` line; the legacy stamp under `Bundler.bundle_path` is deleted by the plugin). These - files are **setup-owned residue**: `rollback`/`remove` never undo `setup`, so `.socket/.gitignore`, - `.socket/bundler-plugin/` and `gem-plugin-stamp` survive a full reversal (see the residue rule - under the rollback contract). *(Implemented — `crates/socket-patch-core/src/setup/gem/mod.rs`.)* - -6. **Clone-portable.** Because all setup state is committed files, a fresh checkout on another host — - CI, a deploy, a teammate's machine — inherits the setup state unchanged; `setup --check` passes on - the clone with no re-run required. *(Implemented; a consequence of properties 5 + 1.)* - -7. **Reflected in VEX.** A patch contributes a `not_affected` statement to the repo's OpenVEX document - only for ecosystems that are **actually set up** — or explicitly declared **manual** (below) — or - **vendored** (a `socket-patch vendor`ed package needs no install hook by construction: the package - manager itself installs the patched artifact, so its purls bypass this filter) — or **hosted** (a - live lockfile redirect is likewise its own persistence; manifest-less lockfile references are always - vendored or hosted, so they never need `setup.manual`). Patches for an - ecosystem that is neither set up, declared manual, vendored, nor hosted produce no VEX statement. *(Implemented — - `generate_vex` filters `applied` to ecosystems returned by `commands/setup::configured_ecosystems` - (on-disk hook presence) ∪ the manifest's `setup.manual`, in addition to the existing `--ecosystems` - filter and on-disk verification. Applies in both verify and `--no-verify` modes.)* - - **Manual declaration.** Users who run `socket-patch apply` by hand (e.g. in a CI step) declare an - ecosystem as `manual` so VEX still attests its patches even though the auto-install hook is - intentionally not wired. This is the normal path for **cargo** and **golang** (apply-only, no - `setup` hook). Home: the `setup.manual` array (a list of ecosystem `cli_name`s — `pypi`, `cargo`, - `golang`, …) in `.socket/manifest.json`. *(Implemented for the read/attest path; a `setup` flag to - populate it is a future nicety — today it's hand-authored in the manifest.)* - -8. **Graceful, exact remove.** `setup --remove` (optionally per-ecosystem via `--ecosystems`) restores - the repo to its exact pre-setup state: manifests byte-for-byte, sibling scripts/dependencies - preserved, keys that became empty dropped. Afterward `setup --check` reports needs-configuration - again. For gem projects it also removes the plugin dir, the stamp and its `.gitignore` line, and - (v5.0) prunes an emptied `.socket/` (non-recursive `remove_dir` — a `.socket/` still holding a - manifest, blobs, vendored state or a user-authored `.gitignore` is kept), so a project that never - ran `apply` is back to its pre-setup tree. *(Implemented for the manifest edits — npm - `package.json` and Python deps round-trip byte-for-byte. `package.json` is re-serialized in its - own layout — BOM, indent, line ending and trailing-newline shape (v5.0) — so a Windows manifest - (yarn berry pretty-prints it with CRLF) keeps CRLF through `setup` and `setup --remove`.)* - -9. **Nested workspaces, with exclude.** Setup applies to every subproject below the repo root: npm / - yarn / pnpm / bun workspace members are all discovered and configured (pnpm is root-package-only by - design, because workspace-member `postinstall` scripts fail under pnpm's strict module isolation). - Selected paths may be **excluded**, and the exclusion is **persisted in `.socket/manifest.json`** so - `check`, `apply`, and any clone all honor it. *(Implemented — nested-workspace discovery plus the - `--exclude` flag, persisted as the `setup.exclude` array in `.socket/manifest.json` and honored by - discovery + `check` (a fresh clone inherits it without re-passing the flag). Excludes apply to npm - workspace members; the repo root is never excludable.)* - - **Nested workspaces (implemented).** A workspace member that is itself a workspace root is recursed - into and has its own members configured. `find_workspace_packages` re-reads each discovered - member's own `workspaces` field (bounded depth). Guarded by the nested-workspace pins in - `tests/setup_invariants.rs`. - -### Per-ecosystem setup support - -`setup` installs an automatic-repatch hook for the four ecosystems with a usable post-install / -startup hook (npm, pypi, gem, composer — every ecosystem is built in unconditionally; there are no -ecosystem feature gates). The remaining ecosystems are **apply-only**: `socket-patch apply` patches them on demand, but -there is no hook for `setup` to install, so `setup` is a `no_files` no-op for them. These are exactly -the ecosystems for which property 7's **manual** declaration is intended (so their hand-applied patches -still show up in VEX). - -| Ecosystem | Hook `setup` installs | Repatch trigger | Notes | -|---|---|---|---| -| npm / yarn / pnpm / bun / vlt | `scripts.postinstall` + `scripts.dependencies` | `npm/pnpm install` (+ `install `); vlt: every install that changes the graph (`vlt install`, `install `, `vlt ci`), never a no-op install | pnpm and vlt: root package only. vlt gets npm's `npx` hook (never `vlx`) and is detected by `vlt-lock.json`, `vlt.json`, `node_modules/.vlt-lock.json` or a `node_modules/.vlt/` directory in `--cwd` (before the pnpm markers; an ancestor `vlt.json` is ignored). vlt < 1.0.0-rc.13 never runs a root `postinstall`: `setup` still wires it and warns `vlt_root_scripts_not_run`. A failing hook aborts and rolls back the whole `vlt install`, so `apply --silent` exits 0 when there is nothing to do (no manifest); a manifest whose only patch targets a package that is not installed exits 1 and aborts the install, as it fails `npm install` | -| pypi | `socket-patch[hook]` dependency → `.pth` startup hook | Python interpreter startup after installed-set change | manifest = `pyproject.toml` (uv/poetry/pdm/hatch) or `requirements.txt` (pip) | -| gem | managed `plugin "socket-patch"` block in the `Gemfile` → committed in-tree Bundler plugin under `.socket/bundler-plugin/` | every `bundle install` (cached + fresh: load-time digest gate + `after-install-all` hook) | the plugin is `path:`-sourced (a `git:` dir source is uncloneable — the generated dir is not a git repo — and fails `bundle install`); the dir must be committed so clones/CI have it; CLI must be on `PATH`. Phase 2 (follow-up) switches to a published `socket-patch-bundler` gem | -| composer | `socket-patch apply` appended to `composer.json`'s `post-install-cmd` + `post-update-cmd` script events | every `composer install` / `composer update` | CLI must be on `PATH` | -| cargo · golang | **none** (apply-only) | — | see "Cargo and Go: apply-only, no setup" below; candidates for the **manual** declaration | -| nuget · maven · deno | **none** (apply-only) | — | `setup` reports `no_files`; candidates for the **manual** declaration | +## Human output conventions (v5.0) + +Human (non-`--json`) output is not a stable interface, but these rules hold: + +* Warning lines carry no machine code: `Warning: ` (and `GC: skipped: .`); error lines keep theirs (`Error (): …`) so a `--silent` run stays grep-able. The stable codes stay in the JSON envelope (`warnings[].code`, `error.code`, `errorCode`). +* Hosted mode is called "hosted" in human text (`Switched N packages to hosted patches; rewrote M files.`); JSON keys and codes keep their `redirect*` names. +* npm's `allow-remote` notice prints as one line (`Note: set \`allow-remote=all\` in .npmrc …` or `Warning: npm >=12 refuses the hosted patches until …`); the full `redirect_npm_allow_remote` detail is in `--json` and under `--verbose`. +* Hosted and vendored runs that change the project end with one shared `Next steps:` block (commit, reinstall + `socket-patch vex`, then any extra step). +* A declined prompt prints `Cancelled; no changes made.`; the paid-plan upsell is `Upgrade to a paid Socket plan to access all patches: https://socket.dev/pricing`. +* `-h` lists about eight common options per command; `--help` lists all of them. `scan --apply` / `--vendor` are hidden (still accepted). + +## Agent mode in CI (v5.0: `setup` removed) + +**Removed in v5.0 (MAJOR):** the `setup` subcommand and every install hook it wired (npm +`postinstall`/`dependencies` scripts, the `socket-patch[hook]` Python `.pth` wheel, the Bundler +plugin under `.socket/bundler-plugin/`, the Composer script hook). `socket-patch setup` is now an +unknown subcommand (clap usage error, exit `2`). Hooks a previous release committed keep calling +`socket-patch apply`, which still exists, so they keep working until you delete them; remove them by +hand (the `postinstall`/`dependencies` entries, the `socket-patch[hook]` dependency, the managed +`plugin "socket-patch"` Gemfile block + `.socket/bundler-plugin/`, the composer +`post-install-cmd`/`post-update-cmd` entries). The `socket-patch-hook` wheel and the +`socket-patch-bundler` gem are no longer published. + +Prefer hosted or vendored mode: their lockfile (and `.socket/vendor/`) edits are the persistence, so +no Socket Patch install hook is needed. Agent mode (`scan --mode agent`, `get --mode agent`, `apply`) patches the installed tree +in place, which the next package-manager install reverts; wire it into CI yourself: + +```sh +socket-patch scan --mode agent # once, locally: record patches in .socket/manifest.json (commit it) +# in CI, after every dependency install: +socket-patch apply # re-apply the committed manifest +``` + +`vex` attests an agent-mode patch whenever verification finds it applied (v5.0: the old "Property 7" +filter, which dropped patches for ecosystems with no configured install hook unless declared in +`setup.manual`, is gone together with `setup`; the `ecosystem_not_setup` omission code is retired). A +manifest's legacy `setup` object (`manual`, `exclude`) still parses and round-trips but is ignored. -#### Cargo and Go: apply-only, no setup +### Cargo and Go in agent mode -Cargo and Go have **no `setup` hook** — a one-click, auto-repatch-on-build setup isn't possible for -them, so `setup` skips both (it makes no manifest edits for either as a *setup* action; the `go.mod` -`replace` that local-mode `apply` writes is an *apply*-time redirect, not setup state). Patch them -with `socket-patch apply` directly (manually or from a per-project install script), and declare them -in `setup.manual` for VEX attestation. +Hosted and vendored mode need no per-install step for any ecosystem. In agent mode, cargo and Go +are patched by `socket-patch apply` like every other ecosystem: - **cargo** — `apply` patches the crate **in place** wherever the crawler finds it: the project `vendor/` directory or the shared registry cache (`$CARGO_HOME/registry/src/...`). The @@ -402,26 +461,25 @@ in `setup.manual` for VEX attestation. - **golang** — `apply` writes a project-local **patched copy** under `.socket/go-patches/@/` and a `go.mod` `replace` directive pointing at it; `go build` links the copy (the module cache is `go.sum`-verified, so in-place patching can't build). Commit `go.mod` + `.socket/go-patches/` + your - `.socket/` patches so a clone builds the patched bytes with no further setup. `socket-patch apply + `.socket/` patches so a clone builds the patched bytes with no further step. `socket-patch apply --check` is a read-only audit of the committed redirect. ### Monorepo / multi-project discovery model -How `setup` (and the underlying `scan`/`apply` crawlers) find subprojects differs by ecosystem, and +How the `scan`/`apply` crawlers find subprojects differs by ecosystem, and the model is **not uniform** today: - **Workspace-aware (walk members):** npm / yarn / pnpm / bun / vlt (`workspaces` / `pnpm-workspace.yaml` / vlt.json `workspaces` — a glob, a list, or named groups of either — or vlt <= 0.0.0-12's `vlt-workspaces.json`; vlt's declaration wins over the others and vlt never reads package.json - `workspaces`). One repo-root invocation discovers and configures every member (pnpm and vlt: the - root package only — vlt runs the root hook once per install, even one started from a member; `setup --remove` also clears a vlt member that still carries a hook, as releases before vlt workspace support wired every member). *Single level only* — see property - 9's nested-workspace gap. + `workspaces`). One repo-root invocation discovers every member. A member that is itself a + workspace root is recursed into (bounded depth). - **cwd-only (single project):** gem, pypi, composer. The crawler inspects only the project rooted at `--cwd` (pypi looks at `$VIRTUAL_ENV`, `/.venv` / `venv`, then a Poetry project's out-of-tree virtualenv(s) under Poetry's `virtualenvs.path`; composer at the vendor tree); it does **not** descend into sibling subprojects. A monorepo with several independent lockfiles in subdirectories (`backend/Gemfile.lock` + `frontend/Gemfile.lock`, multiple `.venv`, multiple `go.mod` / `composer.json`) is handled by invoking the tool **once per subproject** (`--cwd` each), as a - per-directory install hook would. + per-directory CI step would. *Gem install roots (a refinement of "cwd-only", not an exception to the one-project model):* the crawler probes the project's Bundler install roots in **bundler's own precedence order** — the app @@ -435,14 +493,14 @@ the model is **not uniform** today: path, as bundler itself ignores it). The skip is surfaced per the run-warning conventions: a `gem_bundle_config_path_ignored` entry in the run-level `warnings[]` of `scan`/`apply` `--json` envelopes (detail names the config value and the env-`BUNDLE_PATH` remedy), and one stderr - `Warning (gem_bundle_config_path_ignored): …` line on the human path, gated on `!--silent` + `Warning: …` line on the human path, gated on `!--silent` (`--silent` = errors only). Explicit env/config roots only count when `--cwd` holds a Bundler manifest/lockfile. When the default `vendor/bundle` root holds no store, the gem homes `gem env` reports are appended (default gems like rexml/json only ever live there). When several roots hold **coexisting physical copies of one `gem@version`** (bundler-2's scoped store beside bundler-1's flat store), `apply`/`rollback` patch/restore **every copy** — one summary event per copy, mirroring npm's multi-copy fan-out — while single-representative consumers (`get`, `vendor`, - `setup`, `vex`) use the highest-precedence copy. + `vex`) use the highest-precedence copy. *Copy classes (additive to the multi-copy vocabulary):* a copy under a **bundle-path store** (config/env/default root) is PRIMARY — a variant mismatch or write failure there fails the run, @@ -472,75 +530,6 @@ is patched identically to a direct one. Both halves are pinned in back breadth-first into nested `node_modules` for still-unresolved PURLs; a root-level install always wins, pinned by `find_by_purls_prefers_root_copy_over_nested_duplicate`). -### JSON output shapes (`setup`, `setup --check`, `setup --remove`) - -`setup` predates the v3.0 unified envelope and emits its own three shapes. They are stable as of v3.0; -consumers may rely on these keys. All three share a `files[*]` entry shape; `kind` is one of -`package_json`, `pth`, `gemfile`, `gem_plugin`, `composer`, `patch` (`--check` property 4: a manifest -or ledger patch not applied on disk, `needs_configuration`), `vendor_ledger` (`--check`: a -`.socket/vendor/state.json` that cannot be read or parsed, `error`), `gem_plugin_registration` (the last is -`setup --remove`-only: clearing bundler's machine-local `.bundle/plugin` registration of the wired -plugin — emitted only when a registration existed; `status: error` carries the -`bundler plugin uninstall socket-patch` remedy when it could not be cleared safely). - -**`setup`:** - -```jsonc -{ - "status": "success" | "already_configured" | "dry_run" | "partial_failure" | "error" | "no_files", - "updated": 0, - "alreadyConfigured": 0, - "errors": 0, - "packageManager": "npm" | "pnpm" | "vlt", // always emitted; defaults to "npm", only meaningful when npm files were found ("vlt" is additive) - "pythonPackageManager":"pip" | "uv" | "poetry" | "pdm" | "hatch", // present only when Python detected - "dryRun": true, // only on status=dry_run - "wouldUpdate": 0, // only on status=dry_run - "warnings": [ "..." ], // only when non-empty (e.g. lockfile refresh; "vlt_root_scripts_not_run: ") - "files": [ - { "kind": "package_json", "path": "...", "status": "updated" | "already_configured" | "error", - "error": null | "..." } - ] -} -``` - -**`setup --check`** (read-only; never writes — exit `0` only when all in-scope manifests are configured -and none errored): - -```jsonc -{ - "status": "configured" | "needs_configuration" | "error" | "no_files", - "configured": 0, - "needsConfiguration": 0, - "errors": 0, - "files": [ - { "kind": "...", "path": "...", "status": "configured" | "needs_configuration" | "error", - "error": null | "..." } - ] -} -``` - -**`setup --remove`:** - -```jsonc -{ - "status": "success" | "not_configured" | "dry_run" | "partial_failure" | "error" | "no_files", - "removed": 0, - "notConfigured": 0, - "errors": 0, - "dryRun": true, // only on status=dry_run - "wouldRemove": 0, // only on status=dry_run - "warnings": [ "..." ], // only when non-empty - "files": [ - { "kind": "...", "path": "...", "status": "removed" | "not_configured" | "error", - "error": null | "..." } - ] -} -``` - -**Exit codes** (all three): `0` when nothing errored and the operation was satisfiable (including -`no_files` and `not_configured`); `1` on any per-file error, partial failure, or — for `--check` — any -manifest that needs configuration. `setup --check --remove` is a clap usage error (exit `2`). - ## Vendor command contract `vendor` is `apply`'s committable sibling: instead of patching installed packages in place @@ -551,6 +540,40 @@ machines with **no socket-patch installed and no Socket API access** (registry a unvendored dependencies may still be needed). Every mechanism below was validated against the real package managers (`spikes/PHASE0-FINDINGS.txt`). +**Eject a hosted project (v5.0)**: standalone `vendor` with NO manifest in a project whose lockfiles +pin hosted patches (not under `--global`; `--ecosystems` narrows the pins) takes its patch set from +those pins — each pin's purl plus the patch uuid in its hosted URL — fetches each record from the +patch API (`GET …/patches/view/`, the same client and public-proxy fallback as `get`), vendors +into `.socket/vendor/` exactly like `scan --mode vendored`, and rewires each package from hosted to +vendored (the upstream registry entry is restored first, so a later `vendor --revert` returns the +project to upstream, never to hosted). The human output opens with +`Ejecting N hosted package(s) into .socket/vendor/...` (`Would eject …` under `--dry-run`); the +JSON is the vendor envelope. It needs no installed `node_modules` / site-packages: the sources are +fetched from the upstream registry, so a fresh hosted checkout ejects. + +The eject is ONE planned transition, all-or-nothing: (1) every record is fetched first — a failed +(or 404) view fetch is a `failed` event with `errorCode: "patch_fetch_failed"` and the run stops +with `status: "error"`, `errorCode: "eject_refused"`, exit 1, nothing touched; (2) the upstream +restore of every pin is resolved against staged copies — a refused pin (offline registry, a +non-derivable field; a binary `bun.lockb` record is rebuilt like the takeover's) is `eject_refused` with the `git checkout -- ` +remedy, nothing touched; (3) `--dry-run` stops here and reports each pin as an `applied` event with +reason `eject_planned` — no file is written and no `.socket/` is created; (4) the wet run snapshots +every file the eject may touch under one `apply.lock`, restores upstream, then vendors. If any +package then fails, the snapshot is put back — the project stays hosted exactly as before, with the +`eject_rolled_back` warning and `partial_failure`, exit 1; if putting the snapshot back itself fails, +the error is `eject_rollback_failed` naming the files to `git checkout`. The eject does not emit the +per-purl `vendor_takeover_reverted_redirect` warning (the restore is its own planned step). +`--offline` (or `SOCKET_OFFLINE`) refuses the eject up front with `offline_eject_unavailable` — +records and registry entries cannot be fetched offline — making zero network requests (dry run +included). A hosted wiring that discovery cannot attribute (a lock mentioning a recognized hosted +uuid it rejected, or a pin with no lockfile) is refused with `hosted_wiring_contested` (exit 1, +nothing touched) rather than ejecting a partial set; `rollback`, `remove` and `list` refuse the same +way (`list` degrades to a warning when it can still list). The grant token of an attributed pin's +own URL is never contested wiring where the same file also names that pin's patch uuid (a uv +pin's paired `pyproject.toml` `[tool.uv.sources]` entry, a vlt pin in a `vlt-lock.json` whose pins +are withheld from the lock basis); any other unattributed uuid still is. `--vex` works as on the manifest-driven +path. Without hosted pins the no-manifest no-op below is unchanged. + **Prebuilt vendor artifacts (`--vendor-source`)**: by default (`auto`) `vendor` first tries to DOWNLOAD the already-built patched artifact + integrity from the patch.socket.dev vendoring service, and silently falls back to building it locally on any non-fatal miss. `service` requires the service @@ -591,7 +614,7 @@ Verbose `vendor_artifact_reused`). Service round trips are retried on transport after 2 consecutive exhausted fetches the rest of the run skips the service (`auto` builds locally, `service` refuses). -**golang service leg staging (v5.0)**: the module zip is downloaded, extracted and `h1:`-verified in a `.socket-stage` sibling and swapped into place only afterwards; a failed re-download of a WIRED, present copy keeps the copy and its `replace` directive (previously both were torn down), while a missing copy still drops the dangling directive. +**golang service leg staging (v5.0)**: the module zip is downloaded, extracted and `h1:`-verified in a `.socket-stage` sibling and swapped into place only afterwards; a failed re-download of a WIRED, present copy keeps the copy and its `replace` directive, while a missing copy still drops the dangling directive. 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:` @@ -641,37 +664,41 @@ into memory via the patch-view endpoint. A vendored project's `.socket/` holds o by an agent-mode manifest, or as the `{"patches": {}}` husk left after a legacy record migrated into the ledger). -**Vendored artifact repair (v3.5)**: `repair` health-checks every ledger entry — per-file +**Vendored artifact repair (v5.0)**: `repair` health-checks every ledger entry — per-file afterHashes inside the artifact plus, for file-shaped artifacts (`.tgz`/`.whl`), the whole file against the ledger's recorded sha256 (the rewired lock integrity references those exact bytes) — -and REBUILDS missing/corrupt artifacts through the normal vendor backends. The wired hot paths -rebuild the artifact only: lockfiles stay byte-identical and the ledger entry is not re-recorded -(the first run's entry holds the only pre-vendor originals). Pristine sources follow the same -ladder as vendor: the installed copy first (works under `--offline`), then a lockfile-verified -registry fetch, then the pre-vendor registry fragment recovered from the ledger's wiring -`original`s (`recover_lock_entry`) — always integrity-verified fail-closed, and the rebuilt -artifact is re-verified against the recorded fingerprint before the run counts it (`rebuilt` -event; a mismatch removes the artifact and fails with `vendor_artifact_rebuild_failed`). -Lockfile references to `.socket/vendor///...` with NO ledger coverage (the ledger was -deleted wholesale) are RECONSTRUCTED: the uuid comes from the path (the recovery rule above), the -record from the manifest — or the patch API, yielding an entry with the record embedded (the same -`detached: true` + `record` shape every `scan`/`get --mode vendored` entry has) -— and a fresh ledger entry is persisted with the rebuilt artifact's fingerprint. When nothing is -installed and the ledger is gone, npm-family reconstruction has one more rung: the REWIRED -lockfile still records the integrity of the packed vendored tarball, so the pristine copy is -fetched (unverified, conventional registry URL, `SOCKET_NPM_REGISTRY` honored) and the -deterministically REBUILT artifact must reproduce that wired integrity — a tampered pristine -source changes the rebuilt bytes and fails closed (`vendor_artifact_rebuild_failed`, nothing -kept). Reconstructed entries carry no pre-vendor wiring originals, so a later `--revert` degrades -to the documented `vendor_lock_entry_drifted` guidance (re-resolve with the package manager). Because of this -phase, `repair` no longer errors with `manifest_not_found` when the project has a vendor ledger -or vendor-path lockfile references — it runs the vendored phase alone. A **hosted-only** project -(no manifest, no vendor ledger, no vendored references — only `.socket/vendor/redirect-state.json`) -is a no-op: `repair` exits 0 with a `redirect_only_project` skip pointing at `scan --mode hosted` -(hosted redirects have no local artifacts to repair), rather than the `manifest_not_found` error a -bare directory still gets. Step 1's source download -likewise skips vendored-in-sync manifest entries (their content lives in the committed artifact), -so repairing a vendored project never re-litters `.socket/blobs`. `--dry-run` previews +and RE-VENDORS missing/corrupt artifacts through the same vendored backend `vendor`, `scan --mode +vendored` and `get --mode vendored` use. The artifact therefore comes from the same place a fresh +vendor gets it: under the default `--vendor-source auto` the patch service's prebuilt artifact is +downloaded again, with a local build from a lockfile-verified pristine source as the fallback +(and the only source under `--offline` / `--vendor-source build`). The wired hot paths rebuild +the artifact only: lockfiles stay byte-identical and the ledger entry keeps its recorded +pre-vendor originals. The re-vendored artifact is verified against the ledger fingerprint before +the run counts it (`rebuilt` event; a mismatch removes the artifact and fails with +`vendor_artifact_rebuild_failed`). The check is always against the ORIGINAL ledger entry: a source +that produces other bytes (a service archive re-packed since vendoring) is never committed — its +wiring and ledger entry are put back and repair falls back to the deterministic local build. A +corrupt artifact's afterHash-verified members are harvested as patch content (so `--offline` +repairs it from the installed copy) before it is moved aside for the rebuild, and put back when +nothing replaced it. + +**The ledger is not rebuilt from lockfiles (v5.0).** A lockfile reference to +`.socket/vendor///...` with NO ledger entry (state.json deleted or never committed) +fails with `vendor_ledger_missing` (an artifact-level `failed` event carrying `uuid` and +`details.{ecosystem,path}`; exit 1) — the pre-vendor originals a revert needs cannot be recovered +from the rewired lockfile. Recovery: restore `.socket/vendor/state.json` from version control and +re-run `repair`, or restore the lockfile (`git checkout -- `) and re-vendor. Earlier +releases re-synthesized such entries (`details.ledgerRestored`); ledgers they wrote keep working. + +Because of this phase, `repair` does not error with `manifest_not_found` when the project has a +vendor ledger or vendor-path lockfile references — it runs the vendored phase alone. A +**hosted-only** project (no manifest, no vendor ledger, no vendored references — only hosted pins +in its lockfiles, v5.0, or a pre-v5 `.socket/vendor/redirect-state.json`) is a no-op: `repair` +exits 0 with a `redirect_only_project` skip pointing at `scan --mode hosted` (hosted pins have no +local artifacts to repair), rather than the `manifest_not_found` error a bare directory still +gets. Step 1's source download skips +vendored manifest entries and lockfile-referenced uuids (their content lives in the committed +artifact), so repairing a vendored project never re-litters `.socket/blobs`. `--dry-run` previews (`details.wouldRebuild`); `--offline` rebuilds only from fully local sources and fails per-entry otherwise; `vendor`/`scan --vendor` re-runs get the same rebuild for wired-but-broken artifacts (`vendor_artifact_rebuilt` warning) and recover registry resolutions for missing committed @@ -716,7 +743,7 @@ to **six flavors**. | npm / bun (`bun.lock`, lockfileVersion 0, 1 or 2 — `vendor_lockfile_version_unsupported` otherwise) | (same tarball) | `bun.lock` only: the packages entry's registry 4-tuple → local 3-tuple with recomputed `sha512`; the entry's `{deps}` meta, the lock's version line and its line endings are preserved. A lock holding `workspace:` packages is refused `vendor_bun_workspace_unsupported` unless lockfileVersion is 2 — Bun 1.2–1.3 resolve a workspace member's local-tarball path relative to the MEMBER (ENOENT on our root-relative path), 1.4 relative to the lockfile, and a committed version-2 lock is the only proof every consumer runs Bun ≥ 1.4 (a deliberate over-approximation: a package declared only by the workspace root would install on version 1 too). The gate fires only on a run that would WRITE a new local tuple, so in-sync re-runs, `already_vendored` skips and `repair` rebuilds on such a lock pass. The detail names the version and the remedy: delete `bun.lock` and re-lock with Bun ≥ 1.4 (an in-place `bun install` keeps the existing lockfileVersion), or `--mode hosted`. Native binary support is described in the next row. `scan`/`get --mode vendored` apply all four refusals BEFORE downloading (see the `get --mode vendored` bullet). Bun 1.1.39–1.3.9 re-save the local tuple WITHOUT its `sha512` on any later lock re-save (`bun add`, `bun install` after a manifest change); the digest-less 2-tuple is recognised as the same wiring — an in-sync re-run stays `already_vendored` and re-pins the digest on disk (no new wiring record) when the committed artifact still holds the bytes the lock was written from — otherwise, as for any stale tuple of ours, the line is re-pinned and the fresh entry carries the new fingerprint — `repair` rebuilds through it, and `vendor --revert` / `rollback` restore the registry line over it (a 2-tuple at ANOTHER uuid is still `vendor_lock_entry_drifted`) | `bun install --frozen-lockfile`, cold cache (the local tarball's sha512 is enforced by Bun ≥ 1.3.10; 1.1.39–1.3.9 install it unverified — the committed artifact is the protection there) | | npm / bun binary (`bun.lockb`, native binary format 1, 2 or 3) | (same tarball) | Rewrite matching binary package resolutions and integrity in place; preserve topology and unrelated metadata, update binary offsets and the package metadata hash. Text `bun.lock` takes precedence. `bun_lockb_package` wiring snapshots recover pristine registry metadata for repair and support per-package revert and hosted ↔ vendored migration. Binary discovery and rewrites require no installed Bun runtime. Malformed or unsupported content refuses `vendor_bun_lockb_invalid` before download or takeover. | Frozen installs with the original compatible Bun reader; see `docs/testing/bun-compatibility.md` for the release matrix and historical runtime integrity limits. | | npm / vlt (`vlt-lock.json`, lockfileVersion 0 or 1 — A0 locks without a version and every other version refuse `vendor_lockfile_version_unsupported`; flavor `vlt`) | patched package **directory** `.socket/vendor/npm//[@scope/]-/node_modules//` (the extra `node_modules/` level lets a package `require()` its own name), its `package.json` without `devDependencies`, plus `/.gitignore` (re-includes the payload against the project's ignores, ignores vlt's links inside it) and `/.gitattributes` (`-text`) | direct dependencies of the root or a workspace member only: the lock node becomes a `file` node for the directory, its importer edges and outgoing edges are re-keyed, and each importer's `package.json` spec becomes `file:`; every moved entry lands where vlt's serializer puts it. A node whose only extra is one peer context (`ṗ:N`, `peer.N`, `peer.<16 hex>`: from vlt 1.0.8 a root dependency with resolved peers, from rc.15 a workspace member's) becomes a `file` node without the extra, as vlt writes `file:` dependencies, keeping its peer edges; revert restores the extra-bearing DepID. Refused before any write: transitive targets (`vendor_vlt_transitive_unsupported`), two or more instances of one `name@version` or a modifier extra, importer `peer` edges, foreign registries, a git, remote-tarball or local-directory node of the same package name (vlt records no version for it), a package `vlt build` would build in place (`vendor_vlt_build_scripts_unsupported`), a name declared in several dependency fields (`vendor_lock_entry_unsupported`), a spec that disagrees with the lock (`vendor_vlt_lock_out_of_sync`), a payload git would ignore (`vendor_artifact_gitignored`), a purl vendored under another flavor (`vendor_flavor_changed`); era-A locks warn `vendor_vlt_legacy_lockfile`; an optional dependency (or any dependency node_modules still links to its installed upstream copy) gets `vendor_vlt_reinstall_required` | fresh checkout, `vlt ci` with cold caches: the patched bytes load and `vlt-lock.json` stays byte-identical, also through a warm and a cold `vlt install --frozen-lockfile` (checked on 1.2.0, 1.0.10, 1.0.4, 1.0.0-rc.32 and 1.0.0-rc.14, and on every release by `docs/testing/vlt-compatibility.md`); no-op installs, `vlt install `, `uninstall` and `vlt update` keep the direct dependency vendored. `vendor --revert` restores the registry node, edges and specs, keeping what vlt re-laid since, and refuses on drift | -| cargo | crate dir `-/` (no `.cargo-checksum.json`) | (v5.0) `[patch.crates-io]` path entry in the **workspace-root `Cargo.toml`** (the manifest beside the `Cargo.lock` it detaches — never `.cargo/config*`) **+** Cargo.lock surgery (the `[[package]]` entry's `source`/`checksum` removed and its `version` set to the copy's TAGGED version `+socket.` — `+.socket.` when the version already has build metadata — with every lock reference that spells the old version rewritten, formats v1–v4; the copy's own `Cargo.toml` version carries the same tag, so the patched crate sees it in `CARGO_PKG_VERSION`; revert restores the lock byte for byte). Key: always the Socket-owned `-socket-` with `package = ""` (the full uuid hex when that key is taken), never the bare crate name — cargo lets a config-file `[patch]` item (project, ancestor directory or `$CARGO_HOME`) replace the manifest item with the same key whatever its version, so keys any of those configs use are avoided and a re-run moves an entry off a now-shadowed key; two versions of one crate are wired side by side. Pre-v5 wiring in `.cargo/config.toml` / `.cargo/config` is moved into `Cargo.toml` by a re-run (`vendor`, `scan`/`get --mode vendored`) or `repair` (`cargo_wiring_migrated` note; the ledger's `cargo_patch_entry` record then names `Cargo.toml`); a detached lock entry left unwired by the pre-v5 multi-version overwrite is re-wired the same way (`cargo_wiring_restored`); every revert removes both spellings | `cargo build --locked --offline` on a fresh checkout — single-version manifest `[patch]` also builds with no network on cargo older than 1.56 (the old config-file wiring's floor); two vendored versions of ONE crate need `--offline` on cargo 1.56 and a populated registry index (or network access) on older cargo such as 1.41, which loads the index to tell them apart. Note: path deps build **without** `--cap-lints allow` | +| cargo | crate dir `-/` (no `.cargo-checksum.json`) | (v5.0) `[patch.crates-io]` path entry in the **workspace-root `Cargo.toml`** (the manifest beside the `Cargo.lock` it detaches — never `.cargo/config*`) **+** Cargo.lock surgery (the `[[package]]` entry's `source`/`checksum` removed and its `version` set to the copy's TAGGED version `+socket.` — `+.socket.` when the version already has build metadata — with every lock reference that spells the old version rewritten, formats v1–v4; the copy's own `Cargo.toml` version carries the same tag, so the patched crate sees it in `CARGO_PKG_VERSION`; revert restores the lock byte for byte). Key: always the Socket-owned `-socket-` with `package = ""` (the full uuid hex when that key is taken), never the bare crate name — cargo lets a config-file `[patch]` item (project, ancestor directory or `$CARGO_HOME`) replace the manifest item with the same key whatever its version, so keys any of those configs use are avoided and a re-run moves an entry off a now-shadowed key; two versions of one crate are wired side by side. Pre-v5 wiring in `.cargo/config.toml` / `.cargo/config` is moved into `Cargo.toml` by a re-run (`vendor`, `scan`/`get --mode vendored`) or `repair` (`cargo_wiring_migrated` note; the ledger's `cargo_patch_entry` record then names `Cargo.toml`); a detached lock entry left unwired by the pre-v5 multi-version overwrite is re-wired the same way (`cargo_wiring_restored`); every revert removes both spellings | `cargo build --locked --offline` on a fresh checkout — single-version manifest `[patch]` also builds with no network on cargo older than 1.56 (the old config-file wiring's floor); two vendored versions of ONE crate need cargo 1.45 or newer (`--offline` from an empty CARGO_HOME is enough there); older cargo fails closed whatever the index state, and a project that does not pin cargo ≥ 1.45 (`rust-version` or toolchain file) gets the `cargo_multi_version_old_cargo` warning. Note: path deps build **without** `--cap-lints allow` | | golang | module dir `@/` | `go.mod` `replace => ./.socket/vendor/golang//@` | `go build` with `GOPROXY=off` + empty `GOMODCACHE` (directory replaces bypass go.sum entirely; survives `go mod tidy`) | | composer | package dir `/@/`; the copy's `.gitignore` / `.hgignore` are emptied and its `.gitattributes` `export-ignore` rules dropped, because Composer's path mirror skips the files they match (`vendor_composer_mirror_filters_neutralized`; a patch that rewrites one of them is refused `vendor_composer_mirror_filter_conflict`). Re-runs heal copies vendored before this | `composer.lock` only: entry's `dist` → `{type: "path", url, reference: ""}`, `source` removed, `transport-options: {symlink: false}` added. `content-hash` unaffected; `composer.json` untouched | `composer install` (from the lock alone, real copy not symlink, works under `--network none`). Composer 1 does not reinstall an already-installed package whose dist changed: remove `vendor//` first. `composer update ` reverts it. See `docs/testing/composer-compatibility.md` | | gem | gem dir `-/` + gemspec materialized from `specifications/` | **Gemfile + Gemfile.lock pair**: the `gem` line gains `path:` (or a managed block for transitive deps); the lock's spec block moves GEM→PATH and the DEPENDENCIES entry becomes ` (= )!`, in bundler's exact canonical form | `bundle install` (normal **and** `BUNDLE_FROZEN=true`), byte-stable lock. Lock-only edits are a silent unpatch — hence the mandatory pair | @@ -734,8 +761,8 @@ Ecosystems with no vendor backend (jsr) refuse per-purl with Bun's binary `bun.lockb` is supported natively, including lockfile-only discovery, vendoring, hosting, repair and migration between those modes. A lock-less tool marker (a `[tool.uv]`/`[tool.poetry]`/ `[tool.pdm]` table or a `Pipfile` without its lock) refuses `_no_lockfile` unless a -`requirements.txt` fallback exists. PURLs of **compiled-out** ecosystems are invisible to `vendor` -exactly as they are to `apply` (the binary cannot parse them). +`requirements.txt` fallback exists. PURLs of ecosystems this binary has no backend for (e.g. a newer +CLI's ecosystem in the committed manifest) are invisible to `vendor` exactly as they are to `apply`. ### Checksum coverage @@ -773,8 +800,8 @@ worse, lets a warm cache silently serve unpatched bytes): same committed-file trust class as the manifest; artifact verification still re-hashes against its afterHashes and the uuid-in-path cross-checks); standalone `vendor` fed by an agent-mode manifest embeds `record` too, as a fallback copy, but never `detached` — the manifest record stays - authoritative while the manifest covers the entry (ledger key or base purl); `vex`, `list` and - `setup --check` read the fallback copy only when it does not, `repair` only with no manifest at all. + authoritative while the manifest covers the entry (ledger key or base purl); `vex` and `list` + read the fallback copy only when it does not, `repair` only with no manifest at all. * **Re-vendor carries originals forward**: re-vendoring under a newer patch uuid rewrites the previous run's own wiring (`original: None` from the backend — it must never record a dangling `.socket/vendor/` pointer as pre-vendor state); the engine merges the TRUE pre-vendor originals @@ -796,7 +823,8 @@ worse, lets a warm cache silently serve unpatched bytes): `already_vendored` skips). Manifest-tracked entries whose patches were dropped from the manifest are auto-reverted at the start of the next `vendor` run (`vendor_reconciled` events); `detached` entries have no manifest record and are exempt. Standalone `vendor` (no flags) is fed - by `.socket/manifest.json` only: with no manifest it is a clean exit-0 no-op whose human line names + by `.socket/manifest.json` — or, with no manifest, by the lockfiles' hosted pins (the eject + above); with neither it is a clean exit-0 no-op whose human line names the missing manifest — `No manifest found, nothing to vendor.`, or, when the vendor ledger holds entries, `No manifest to vendor from; N vendored entr(y is|ies are) tracked in the ledger — `socket-patch repair` verifies (it|them).` — and it never re-vendors from the ledger. This no-op @@ -822,52 +850,22 @@ worse, lets a warm cache silently serve unpatched bytes): 0) — NOT `not_found`, which stays reserved for identifier-matches-nothing. `remove`'s default GC also extends (v5.0, additive) from blobs-only to blobs + diff archives + package archives (parity with rollback/repair/`scan --prune`; GC errors warn and continue, repair's posture). -* **remove unwinds hosted redirects (v5.0)**: an identifier matching hosted records in the - redirect ledger unwinds those redirects too — per-purl for the supported ecosystems (cargo + - npm-family), via the whole-ledger reverse replay when the identifier covers EVERY record (the - same eligibility rule as `rollback`). A hosted-only match works with no manifest at all - (mirroring the manifest-less vendored escape). Unsupported-ecosystem hosted targets fail closed - BEFORE the manifest mutation with top-level `hosted_revert_unsupported` (exit 1; remedy: - unscoped `socket-patch rollback`, or re-run `scan --mode hosted`); a failed unwind or ledger - persist is `hosted_revert_failed` (exit 1, manifest not modified). Successful unwinds ride the - envelope as `removed`/`hosted_reverted` events (bypassing `summary.removed`, like - `vendor_reverted`). `--skip-rollback` leaves hosted wiring untouched; `--preserve-state` still - unwinds — hosted has no preservable local state (a stderr note says the records were dropped). -* **rollback reverts vendored and hosted state by default (v5.0, MAJOR — was: excluded)**: the - agent leg still excludes vendor-owned purls from IN-PLACE restore (their patch lives in the - committed artifact, not the installed tree, so before-blob restoration is meaningless), but a - v5.0 `rollback` then unwires those purls through its vendored leg and unwinds hosted redirects - through its hosted leg — `remove ` and `vendor --revert` are no longer the only exits - from vendored/hosted state. The JSON `vendored: []` array's meaning NARROWS accordingly (MAJOR): - it now lists only vendor-owned purls the run did NOT act on (today: the corrupt-vendor-ledger - skip — reserved-empty in v5.0, since naming skipped purls needs the very ledger that failed - to load); acted-on entries land in the new `vendoredReverted`/`vendoredPreserved`/`vendoredKept` - arrays. An identifier matching only vendored purls is still a success, not `not_found`. See - [Rollback command contract](#rollback-command-contract-v50). -* **apply yields to vendor — every ecosystem**: a purl recorded in the ledger is skipped by - `apply` with reason `vendored`, even when the installed tree is absent entirely (never - `package_not_installed`; a vendored variant also accounts for its qualified release-variant - siblings). Golang especially — apply never repoints a vendor-owned `replace` back at - `.socket/go-patches/` — and `apply --check` excludes vendored modules from its drift audit. -* **scan skips vendored purls before download** (plain `--apply`/`--sync`): the manifest is never - moved past the vendored uuid (that would break VEX verification with `vendor_uuid_mismatch` - until a vendor run). The skip rides `apply.patches[]` as `skipped`/`vendored`; a newer available - patch still surfaces in `updates[]` — the signal to run `scan --vendor`. In `--json` mode the - run additionally carries one top-level `vendored_ownership_retained` warning naming the skipped - purls and the migration path (see "Agent-flow run-level warnings"), so consumers need not dig - into `apply.patches[]` to learn the mode did not change; exit code and status are unaffected. `scan --prune` exempts - vendored purls from the crawl-based manifest prune (an absent installed copy is their NORMAL - state) but reconciles vendored state via the lockfile instead — see the `--prune` section. An - explicit `get` is allowed to move the manifest past the vendored uuid and warns - (`warnings[]` + stderr) that a `vendor` run must refresh the artifact — while - `get … --mode vendored` (v3.6) re-vendors at the new uuid in the same run instead - of warning (the vendor step immediately resolves the drift the warning describes). -* **Old-binary skew caveat**: EVERY `scan`/`get --mode vendored` entry is now detached-shaped, so a - `socket-patch` binary that predates the `detached` flag (pre-4.0) running `vendor` against such a - checkout cannot see the flag and will reconcile-revert every vendored entry; a 4.x binary honors - the flag but drives its own re-vendor from the manifest and finds nothing to do. Pin the CLI - version in CI when mixing generations. The ledger schema itself stays parseable both ways - (additive optional fields). + Package archives (`.socket/packages/`) are legacy in v5.0: nothing writes or reads them, so + every GC sweep removes the whole directory. +* **remove restores hosted pins (v5.0)**: an identifier matching hosted pins in the lockfiles + (purl or patch uuid; v5 keeps no hosted ledger) restores each matched pin to its default + upstream registry entry — the same restore as `rollback` (see "Hosted unwind coverage"), for every + ecosystem. A hosted-only match works with no manifest at all (mirroring the manifest-less + vendored escape). A pin the restore refuses (`--offline`, a registry that does not answer, + `bun.lockb`, …) or a failed write is the top-level `hosted_revert_failed` error BEFORE the + manifest mutation (exit 1, manifest not modified; message `could not restore to its + upstream registry entry: … (`git checkout -- `)`). v4's `hosted_revert_unsupported` is no + longer emitted. Successful restores ride the envelope as `removed`/`hosted_reverted` events + (beside a manifest entry they bypass `summary.removed`, like `vendor_reverted`; for a hosted-only + match the restore IS the removal and they count); once no hosted pin is left, a pre-v5 + `redirect-state.json` is deleted. `--skip-rollback` leaves hosted wiring untouched (and is refused + for a hosted-only match); `--preserve-state` still restores — hosted has no preservable local + state (a stderr note says the lockfile pins now resolve upstream). ### Caveats (documented behavior, not bugs) @@ -907,47 +905,57 @@ worse, lets a warm cache silently serve unpatched bytes): ## Rollback command contract (v5.0) -> **Semver note.** v5.0 changes `rollback`'s DEFAULT behavior (a default-value/behavior change → **MAJOR** per the [semver policy](#semver-policy)) and narrows the meaning of the existing `vendored: []` JSON key (**MAJOR**). Every new envelope key, flag, and warning code below is additive on top of that. +> **Semver note.** v5.0 changes `rollback`'s DEFAULT behavior (a default-value/behavior change → **MAJOR** per the [semver policy](#semver-policy)) and narrows the meaning of the existing `vendored: []` JSON key (**MAJOR**). Every new envelope key, flag, and warning code below is additive on top of that. **Hosted leg (v5.0)**: hosted mode keeps no ledger, so the hosted leg restores each hosted pin to its default upstream registry entry (re-resolved from the registry) instead of replaying recorded fragments; a pin that cannot be restored is refused with the `git checkout -- ` remedy. -`rollback` and `scan` are now the batch-level duals — `scan` moves the project toward "fully patched", `rollback` toward "fully unpatched" — the way `get` and `remove` are the single-patch duals. `rollback` needs no `--mode`: it infers what to undo from the three state stores (`.socket/manifest.json` = agent/in-place, `.socket/vendor/state.json` = vendored, `.socket/vendor/redirect-state.json` = hosted). +`rollback` and `scan` are now the batch-level duals — `scan` moves the project toward "fully patched", `rollback` toward "fully unpatched" — the way `get` and `remove` are the single-patch duals. `rollback` needs no `--mode`: it infers what to undo from three sources (`.socket/manifest.json` = agent/in-place, `.socket/vendor/state.json` = vendored, and the hosted pins lockfile discovery finds in the project's lockfiles = hosted — v5.0 hosted mode keeps no ledger). ### Targets -`rollback [TARGET]...` — zero or more targets, unioned. `pkg:` tokens are PURLs (base purl matches every release variant; qualified purl exact), other identifier-shaped tokens are UUIDs, and only **path-shaped** tokens (separator, glob metachar `*?[`, `./` prefix, or absolute) are path globs — see the per-subcommand args table for the safety rationale. Identifier matching runs across ALL THREE stores; an identifier matching nothing anywhere is the familiar exit-1 error. Path globs use the same matcher as `scan [PATHS]` (ancestor rule, `require_literal_separator`, absolute-only outside `--cwd`, Windows case-insensitive): installed copies of every candidate purl are discovered and purls with ≥ 1 matching copy are selected. Scoping sentences (shared with scan): +`rollback [TARGET]...` — zero or more targets, unioned. `pkg:` tokens are PURLs (base purl matches every release variant; qualified purl exact), other identifier-shaped tokens are UUIDs, and only **path-shaped** tokens (separator, glob metachar `*?[`, `./` prefix, or absolute) are path globs — see the per-subcommand args table for the safety rationale. Identifier matching runs across ALL THREE sources (a hosted pin matches by purl or by the patch uuid in its hosted URL); an identifier matching nothing anywhere is the familiar exit-1 error. Path globs use the same matcher as `scan [PATHS]` (ancestor rule, `require_literal_separator`, absolute-only outside `--cwd`, Windows case-insensitive): installed copies of every candidate purl are discovered and purls with ≥ 1 matching copy are selected. Scoping sentences (shared with scan): * **A target that selects nothing is an error on `rollback` (exit 1) and an empty scan on `scan` (exit 0).** Each rollback path pattern must select at least one patched package; the error names the pattern and the reachability rule. * **Path targets select installed copies; entries with no installed copy are reachable only by identifier or unscoped runs.** * **Rollback restores every installed copy of a selected patch** — patches are tracked per-package, not per-path; copies restored outside the given patterns are surfaced as an `out_of_scope_copies_restored` warning, never skipped. -`--ecosystems` narrows every leg. `--one-off` still requires ≥ 1 identifier-shaped target and still fails "not yet implemented" before any network or disk activity. +`--ecosystems` narrows every leg. ### Default behavior: full-state rollback (MAJOR) A bare `rollback` (or a scoped one, for its scope) restores the SYSTEM to unpatched and cleans up the local state, in phases under one `apply.lock` acquisition: -1. **State discovery.** A missing manifest is no longer fatal when the vendor or redirect ledger holds work (`rollback` runs manifest-less on hosted-only / vendored projects — every `scan`/`get --mode vendored` project is manifest-less). The **truly-empty** project — all three stores absent — keeps the legacy "Manifest not found" exit 1 (JSON: the legacy `{status: "error", error: "Manifest not found", path}` shape). A project whose lockfiles still reference `.socket/vendor/` artifacts but whose vendor ledger is missing errors naming `socket-patch repair` (reconstruct the ledger, then roll back). **Corrupt-ledger containment**: an unreadable vendor ledger fails ONLY the legs that need it — the vendored leg, manifest cleanup, and GC are skipped fail-closed (`vendor_state_unreadable` warning) while the agent leg still restores files; an unreadable redirect ledger skips only the hosted leg (`redirect_state_unreadable` warning; v5.0 distinguishes a ledger that cannot be READ — EACCES, a directory or FIFO squatting on the path — which is reported as such and left in place with a fix-the-permissions remedy, from MALFORMED JSON, which is quarantined to `redirect-state.json.corrupt` with the restore remedy). Either drives `partial_failure` exit 1; an emergency restore is never blocked by an unrelated corrupt ledger. When the ONLY state on disk is an unreadable ledger, the run fails closed naming the store. +1. **State discovery.** A missing manifest is no longer fatal when the vendor ledger or the lockfiles' hosted pins hold work (`rollback` runs manifest-less on hosted-only / vendored projects — every `scan`/`get --mode vendored` and v5 `scan --mode hosted` project is manifest-less). The **truly-empty** project — no manifest, no vendor ledger, no hosted pin — keeps the legacy "Manifest not found" exit 1 (JSON: the legacy `{status: "error", error: "Manifest not found", path}` shape), with one v5.0 exception: when a pre-v5 `.socket/vendor/redirect-state.json` is the only thing left, nothing pins it any more, so a wet run deletes it and exits 0 (human `Removed the pre-v5 hosted ledger .socket/vendor/redirect-state.json: no lockfile pins a hosted patch.`, `Would remove …` on `--dry-run`, which deletes nothing; JSON `{status: "success", rolledBack: 0, alreadyOriginal: 0, failed: 0, dryRun, warnings, legacyRedirectLedgerRemoved}` — a minimal envelope without the keys below; a failed delete is the `legacy_redirect_ledger_kept` warning, still exit 0). A project whose lockfiles still reference `.socket/vendor/` artifacts but whose vendor ledger is missing errors asking for `.socket/vendor/state.json` to be restored from version control first (v5.0: `repair` no longer reconstructs the ledger). **Corrupt-ledger containment**: an unreadable vendor ledger fails ONLY the legs that need it — the vendored leg, manifest cleanup, and GC are skipped fail-closed (`vendor_state_unreadable` warning) while the agent and hosted legs still run; it drives `partial_failure` exit 1, and an emergency restore is never blocked by it. When the ONLY state on disk is an unreadable vendor ledger, the run fails closed naming the store. A pre-v5 redirect ledger is never read by rollback (v4's `redirect_state_unreadable` is no longer emitted). 2. **Agent leg** — the existing in-place restore machinery, unchanged (v5.0 presentation: the human `No patches found in manifest` line prints only for an unscoped run with no work in ANY leg — a run whose work is all vendored/hosted stays quiet about the manifest): multi-copy restore, release-variant narrowing, the before-blob gate (+ on-demand download; a gate abort still exits 1 with per-package `missing_blob` failure results **and** skips manifest cleanup + GC entirely — nothing was restored, and the retry's revert data must survive), local-go redirect drop, and the `not_installed` exit-0 asymmetry verbatim. Vendor-owned purls are still excluded here (see the vendored-mode section) — they are handled by the next leg instead of being punted to other commands. 3. **Vendored leg** — each in-scope ledger entry (embedded-record entries included) is reverted through the vendor backends: lockfile wiring restored, artifact dir deleted (and its emptied `.socket/vendor//` husk pruned, v5.0), ledger entry dropped + persisted per purl (crash-consistent, like `vendor --revert`). A **drift-keep** (the backend refused a drifted lock) keeps the entry, the artifact, AND the manifest record (`vendoredKept`, exit 1 — the system is still patched); a failure is recorded and other entries proceed. -4. **Hosted leg** — see "Hosted unwind coverage" below. +4. **Hosted leg** — each in-scope hosted pin is restored to its default upstream registry entry; see "Hosted unwind coverage" below. After a hosted leg with no failure, a wet run deletes a pre-v5 `redirect-state.json` once no lockfile pins a hosted patch any more (a failed delete is the `legacy_redirect_ledger_kept` warning). 5. **Manifest cleanup** — entries are removed ONLY for in-scope purls whose legs fully succeeded, were not-installed, or were release-variant siblings narrowed away by an attempted variant that succeeded (half a variant group never lingers — `remove` parity); drift-kept and failed purls keep their records, and a failed variant holds its whole group. No-op removals never rewrite the file. A failed write surfaces as `manifest_write_failed` (warning + `partial_failure` exit 1; GC still runs against the unchanged manifest). 6. **GC** — `cleanup_unused_blobs` + diff/package-archive sweeps against the post-removal manifest, with beforeHash blobs pinned (synthetic afterHash-slot records) for (a) removed-but-not-installed entries (a crawler miss must not destroy the only local revert data — `remove` parity) and (b) EVERY entry remaining in the post-removal manifest — still-active patches (failed, drift-kept, eco-/path-excluded) keep their revert data, so a scoped or failed run never destroys the blobs a later rollback needs; only blobs referenced solely by genuinely-removed entries are swept. GC errors warn (`cleanup_failed`) and continue — they never affect the exit (repair's posture). -**Confirmation prompt.** A wet, non-preserve run with work prompts once, remove-style, composing only the clauses that apply into one English list (`a and b`, `a, b, and c`) with counted nouns: `Roll back N patches`, `remove them from the local manifest`, `delete M vendored artifacts and their ledger records`, `unwind H hosted redirects` (a hosted ledger with leftover edits but no records gets `replay K leftover hosted redirect edits` instead of the unwind clause; e.g. `Roll back 1 patch, remove it from the local manifest, and unwind 1 hosted redirect?`) — default yes, auto-accepted under `--yes`/`--json`/non-TTY (the shared `confirm` semantics; CI unaffected). Decline prints `Rollback cancelled.` and exits 0. `--dry-run` and `--preserve-state` runs are prompt-free (they delete no local state). +**Confirmation prompt.** A wet, non-preserve run with work prompts once, remove-style, composing only the clauses that apply into one English list (`a and b`, `a, b, and c`) with counted nouns: `Roll back N patches`, `remove them from the local manifest`, `delete M vendored artifacts and their ledger records`, `restore H hosted packages to the upstream registry` (e.g. `Roll back 1 patch, remove it from the local manifest, and restore 1 hosted package to the upstream registry?`) — default yes, auto-accepted under `--yes`/`--json`/non-TTY (the shared `confirm` semantics; CI unaffected). Decline prints `Rollback cancelled.` and exits 0. `--dry-run` and `--preserve-state` runs are prompt-free (they delete no local state). ### `--preserve-state` (opt-out, both `rollback` and `remove`) -Restore the system but keep the local patch state for a later re-apply: manifest entries kept, vendored artifacts + ledger entries kept byte-identical (only the lockfile wiring is reverted; the already-reverted wiring records replay as silent no-ops on a later revert, and a re-vendor re-wires from the live lock), and all blob/archive GC skipped. **Hosted redirects have no preservable local state**: their ledger records describe live wiring only, so a preserve run still unwinds them and drops the records either way — surfaced as the `hosted_state_not_preservable` warning (re-run `scan --mode hosted` to re-wire). Caveat (documented): preserved vendored entries may be reclaimed by an explicit later `scan --prune` (user-invoked GC); `vendor` re-runs re-wire them. +Restore the system but keep the local patch state for a later re-apply: manifest entries kept, vendored artifacts + ledger entries kept byte-identical (only the lockfile wiring is reverted; the already-reverted wiring records replay as silent no-ops on a later revert, and a re-vendor re-wires from the live lock), and all blob/archive GC skipped. **Hosted pins have no preservable local state**: the lockfile pins are the only record, so a preserve run still restores them to upstream — surfaced as the `hosted_state_not_preservable` warning (re-run `scan --mode hosted` to re-wire). Caveat (documented): preserved vendored entries may be reclaimed by an explicit later `scan --prune` (user-invoked GC); `vendor` re-runs re-wire them. -**Replay fail-closed carve-outs (v5.0)**: the gem SECTION-MOVE record (`redirect_gemfile_lock_gem_source`) refuses in the replay — the writer records only the bare remote URLs, not the moved spec block, so a URL swap cannot invert the move (remedy: `scan --mode hosted` normalize). A socket-owned go.mod `replace` folded into a `replace ( … )` BLOCK and later refreshed also refuses (the ledger records the single-line spelling). Both keep their records + edits for a retry. **Ledger persistence rule**: rollback and remove persist the mutated redirect ledger whenever it changed — INCLUDING on partial-failure exits — so lockfile writes that already flushed are never stranded against a stale on-disk ledger. **Lock discipline**: all three state stores are LOADED under the apply lock (only cheap existence probes run before it), so a concurrent run's writes are never clobbered by a stale pre-lock snapshot. **Residue rule (v5.0)**: a reversal that empties a ledger deletes the file — `redirect-state.json` and/or `vendor/state.json` — and prunes the emptied `.socket/vendor//` and `.socket/vendor/` directories (non-recursive, so a `redirect-state.json.corrupt` quarantine or any other stray file keeps its directory alive — the one sanctioned `.socket/vendor/` residue); emptied `blobs/`, `diffs/` and `packages/` stores are removed by the GC sweep; `.socket/` itself is removed by the lock guard when the run leaves it empty, so a fully unwound hosted or vendored project has no `.socket/` at all. What legitimately survives a full reversal: `.socket/manifest.json` at `{"patches": {}}` (+ its `setup` block — never deleted, see the exit-code section), the setup-owned `.socket/.gitignore`, `gem-plugin-stamp` and `bundler-plugin/`, and `.corrupt` quarantine files. +**Lock discipline**: the manifest and vendor ledger are LOADED under the apply lock (only cheap existence probes and the read-only hosted-pin discovery run before it; the upstream restore re-reads every file it rewrites under the lock), so a concurrent run's writes are never clobbered by a stale pre-lock snapshot. **Residue rule (v5.0)**: a reversal that empties the vendor ledger deletes `vendor/state.json` (and a pre-v5 `redirect-state.json` is retired as above) and prunes the emptied `.socket/vendor//` and `.socket/vendor/` directories (non-recursive, so a pre-v5 `redirect-state.json.corrupt` quarantine or any other stray file keeps its directory alive — the one sanctioned `.socket/vendor/` residue); emptied `blobs/`, `diffs/` and `packages/` stores are removed by the GC sweep; `.socket/` itself is removed by the lock guard when the run leaves it empty, so a fully unwound hosted or vendored project has no `.socket/` at all. What legitimately survives a full reversal: `.socket/manifest.json` at `{"patches": {}}` (+ its `setup` block — never deleted, see the exit-code section), the setup-owned `.socket/.gitignore`, `gem-plugin-stamp` and `bundler-plugin/`, and `.corrupt` quarantine files. ### Hosted unwind coverage -* **Per-purl reverts** exist for **cargo, golang and the npm family** (`redirect_revert_supported`): staged, fail-closed on drift, and honoring `dry_run` (every inverse and drift check resolves like a wet run; nothing flushes and the ledger is untouched). npm purls on projects with bun-lock edits DEFER to the whole-ledger replay (below) whenever it will run — the scope covers every record, and the replay stages the bun group all-or-nothing. A SCOPED unwind (`rollback `, or `remove ` while other hosted records remain) takes the per-purl revert instead: it claims that purl's `redirect_bun_lock_package` edits by the recorded line's spec (`@` registry spec, or a hosted URL whose tarball leaf is `-.tgz`) and replays them like the yarn/pnpm text kinds (whole-line fragments, CRLF-exact); a sibling version's edit is neither claimed nor a refusal; an edit that mentions the package but is not a bun packages-entry line refuses with the unscoped-`rollback` remedy. Pinned by `tests/in_process_vendor_bun_takeover.rs` (`bun_scoped_rollback_of_one_of_two_hosted_records_unwinds_only_that_purl` and the `remove` twin). Native binary `redirect_bun_lockb_package` snapshots follow the same scoped ownership rule and restore only the claimed package records; unrelated binary resolutions stay intact. yarn lock blocks (`redirect_yarn_berry_entry` / `redirect_yarn_classic_entry`) are recorded in the lock's on-disk line endings and replayed byte-exactly; when a `core.autocrlf` checkout has since flipped the lock's UNIFORM ending (LF ↔ CRLF — the committed ledger keeps its fragments verbatim), this per-purl revert and the whole-ledger replay below match the recorded blocks respelled in the live ending and restore in that ending (v5.0). A lock with mixed endings proves nothing and still refuses as drift. vlt `redirect_vlt_lock_node` edits record entry text (`"": `, no indent, comma or `\r`) and revert slot by slot, in the per-purl revert and the whole-ledger replay alike: the line keyed by the recorded DepID gets the recorded slots [2] and [3] back while it keeps the flags, trailing slots, comma and line ending vlt has written since; a line already at the recorded original, or a DepID vlt has re-locked away with no line still carrying the hosted URL, is already reverted; anything else refuses as drift (remedy: restore the registry pin for the DepID by hand, or re-run `socket-patch scan --mode hosted` and roll back). Per-purl claims are by key: `@` or `@~` (peer and modifier variants). -* **Whole-ledger reverse replay** (`revert_remaining_redirect_edits`, core `patch/redirect/replay.rs`) runs whenever the in-scope hosted record set equals the FULL ledger record set — however the scope was spelled (bare `rollback`, `rollback '**'`, an identifier set covering every record; `remove` reuses the same eligibility rule). It walks every remaining ledger edit in reverse write order through a **per-kind inverse table**, staged and committed **per ecosystem group, all-or-nothing**: one drifted, ambiguous (a fragment appearing more than once), or unhandled edit refuses the whole group byte-untouched while other groups proceed. This covers **gem, golang, pypi, composer, bun**, the yarn/pnpm text kinds (normally claimed by the per-purl npm revert first), and the **non-package rideshare edits** — the pnpm `trustLockfile` auto-config (a pristine created scaffold is deleted; a user-modified one keeps the file and loses only the `trustLockfile: true` line, warned as `redirect_pnpm_trust_scaffold_modified`) — plus a "last one out turns off the lights" pass: when the record map empties but non-package edits remain, they are replayed in the same persist, so the trust edit never strands. The npm `.npmrc` `allow-remote=all` auto-config (`redirect_npmrc_allow_remote`) replays in the `npm` group (a pristine created file is deleted; otherwise only the line is removed, warned as `redirect_npmrc_allow_remote_modified` for a modified created file) and is ALSO claimed by the per-purl npm revert of the last package-lock entry, so a scoped unwind never strands it. -* **maven and nuget fail closed**: their structured-metadata kinds (`redirect_maven_repository` / `redirect_maven_dep_management` / `redirect_maven_config` / `redirect_maven_trusted_checksums`, `redirect_nuget_source` / `redirect_nuget_lock`) have no revert implementation, so any such edit refuses its whole group (the maven `` suffix rewrite alone IS invertible, but it rides the same all-or-nothing group). The refusal keeps their records + edits in the ledger and names the remedy: re-run `scan --mode hosted` to normalize, or restore the lockfiles from version control. -* **Unknown edit kinds fail closed (forward compatibility).** A ledger edit kind this release has no inverse for (written by a newer socket-patch) refuses in the replay's reserved `unknown` group with "the redirect ledger holds a {kind} edit this socket-patch release does not understand; upgrade socket-patch", and every record of every ecosystem is held while that group refuses, so no record is dropped beside an edit it may own. The other groups still unwind on disk and drop their edits; only their records wait until the unknown group clears. A per-purl revert (`rollback `, `remove`, the hosted→vendored takeover) refuses with the same text, and with nothing written, when any unknown `redirect_*` edit's `key`, `original` or `new` names the purl's `@` (at a package-name boundary: `left-pad@1.3.0` does not name `pad@1.3.0`, nor does `@scope/a@1.0.0` name `a@1.0.0`). When that scope covers every record, the whole-ledger replay above still runs after the refusal. The vendored flows' takeover reconcile (`vendor_supersedes_redirect`) drops nothing for such a purl and falls back to the manual advisory. vlt ledgers (`redirect_vlt_lock_node` edits, vendored entries with `flavor: "vlt"`) require the socket-patch release that adds vlt support. The ledger `version` stays 1: compatibility is decided per kind. -* **Scoped runs** (paths / identifiers / `--ecosystems`) that do NOT cover the full record set get per-purl reverts only; in-scope hosted purls of ecosystems without one fail closed — `rollback` reports them in `hosted.unsupported` (exit 1), `remove` as the top-level `hosted_revert_unsupported` error — with the remedy "run an unscoped `socket-patch rollback` to unwind ALL hosted redirects, or re-run `scan --mode hosted`". -* **Ledger accounting**: exactly the replayed (or already-at-original) edits are dropped; a record is dropped only when every group its ecosystem writes ended clean, so refused groups keep both edits and records — the intermediate-but-coherent ledger a retry needs. The mutated ledger is persisted (delete-when-empty); a failed persist rides `hosted.failed` / `hosted_revert_failed`. +v5.0 replaces v4's per-purl reverts and whole-ledger reverse replay (`revert_remaining_redirect_edits`) with ONE mechanism, the **upstream restore** (core `patch/redirect/upstream/`), shared by `rollback`, `remove` and the hosted → vendored takeover: + +* **Scope.** The hosted pins are what lockfile discovery finds — `(purl, patch uuid, files wiring it)`, recognized only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin. A scoped rollback (paths / identifiers / `--ecosystems`) restores exactly the pins in scope; each pin restores or refuses on its own (there is no whole-ledger replay, and a pre-v5 ledger's edits are never replayed). A pin discovery cannot see is out of reach: a lockless cargo `registry = "socket-patch-"` pin, a nuget exact-id mapping with no `packages.lock.json`, a gem wired only in the `Gemfile` (pre-bundler-2.6 mixed state) — restore those files from version control. +* **What a restore does.** Every file wiring the pin is rewritten back to the DEFAULT UPSTREAM registry entry for `name@version`, re-resolving whatever the entry pins (tarball URL, integrity, checksum, hashes) from the public registry; only the hosted entries change and every other byte stays the file's own. A pin is **all-or-nothing**: refused in one of its files, it is restored in none of them, so no pin is left half hosted. Nothing reaches disk until every pin has resolved, and `--dry-run` resolves exactly like a wet run — registry lookups included — and skips only the write. Per format: + * **npm family** — `package-lock.json` / `npm-shrinkwrap.json`, `yarn.lock` (classic and berry), `pnpm-lock.yaml` / `shrinkwrap.yaml`, `bun.lock`: resolution + integrity (+ shasum where recorded) from the npm registry's version document (`SOCKET_NPM_REGISTRY`). Side settings: a project `.npmrc` that is exactly `allow-remote=all\n` is deleted once no root npm lock entry is hosted, otherwise a remaining top-level `allow-remote=all` warns `npm_allow_remote_left`; a `pnpm-workspace.yaml` that is exactly the scaffold hosted mode creates is deleted once `pnpm-lock.yaml` is no longer hosted, otherwise a remaining `trustLockfile: true` warns `pnpm_trust_lockfile_left`. **`bun.lockb` (binary)**: `rollback` and `remove` refuse it (the checkout remedy). The hosted → vendored takeover and the eject DO restore it, since the vendor ledger then records the rebuilt record as its pre-vendor original: the native codec turns each hosted remote-tarball record back into Bun's npm registry record for `name@version` (the registry's `dist.tarball` + `dist.integrity`, the package metadata hash re-derived, the hosted URL string dropped from the string pool). The hosted rewrite keeps the registry record's inactive bytes (padding, semver) in the tarball record, so a lock it wrote comes back byte for byte — early writers' uninitialized padding included; a record without them (an older socket-patch or a Bun re-save) is rebuilt the way Bun writes one, and refused for a prerelease/build version. A lock the hosted rewrite had to normalize is marked in the root package's resolution value bytes (which no Bun reader reads): a binary format 1 lock it promoted to format 2 is demoted back to its exact format-1 bytes (verified by promoting it again, otherwise refused), and a lock whose workspace dependency behaviors it normalized is refused with the `git checkout -- bun.lockb` remedy. + * **vlt** — `vlt-lock.json`: slot [2] from the registry's `dist.integrity`, slot [3] per the lock's own convention (see the vlt hosted-mode contract); every hosted instance of the pin together. + * **cargo** — `Cargo.lock` back on crates.io (source + the sparse index's checksum, `SOCKET_CRATES_INDEX`); every `Cargo.toml` declaration loses its `registry = "socket-patch-"` pin (the shorthand the rewriter produced collapses back); the unreferenced `[registries.socket-patch-]` block leaves the project cargo config. A declaration it cannot unpin refuses. + * **golang** — the hosted `replace` and the socket module's go.sum lines go; the upstream module's two go.sum lines come back, hashed from the module proxy (`SOCKET_GOPROXY`, else `GOPROXY` / `GONOPROXY` / `GOPRIVATE` as go reads them) and cross-checked against the checksum database (`SOCKET_GOSUMDB_URL`, else `sum.golang.org` unless `GOSUMDB=off` / `GONOSUMDB` / `GOPRIVATE` say go would not ask it). A `replace` the user had before the hosted run is not recorded anywhere, so the restore lands on the plain upstream module. + * **pypi** — `Pipfile.lock`, `requirements.txt` (+ in-root `-r` includes), Hatch PEP 508 direct references (`pyproject.toml` / `hatch.toml`), `poetry.lock`, `pdm.lock`, `uv.lock`, PEP 723 script locks and PEP 751 `pylock*.toml` (+ the paired `pyproject.toml` / script metadata): hashes re-derived from PyPI's JSON API (`SOCKET_PYPI_JSON_API`). Refused: a `pdm.lock` without `cross_platform`, or a uv / script / pylock lock, whose release has a wheel that is not pure Python 3 (which files the lock keeps is not re-derivable); a uv lock whose options filter files (`exclude-newer`, `no-binary`, `no-build`), or whose other registry packages name no registry, several, or one other than PyPI's simple index; uv 0.2 `[[distribution]]` locks. A transitive `override-dependencies` entry hosted mode added is removed (`upstream_uv_override_removed`). + * **gem** — `Gemfile.lock` / `gems.locked` + `Gemfile` / `gems.rb`: the spec moves back into the upstream `GEM` section (or the Socket remote leaves a merged section), the `source "" do … end` block is undone, the `CHECKSUMS` entry is re-pinned from the rubygems.org compact index (`SOCKET_RUBYGEMS_URL`) and the `DEPENDENCIES` pin loses its `!`. The declaration's original constraint is not recorded, so it comes back as the exact pin `gem "", ""`. Refused: an ambiguous upstream section, an upstream remote other than rubygems.org. + * **composer** — `composer.lock`: `dist` and the deleted `source` block from packagist's composer v2 metadata (`SOCKET_PACKAGIST_URL`). Refused unless the entry is packagist-sourced and packagist still serves the lock's `dist.reference` for the version. + * **maven** — `pom.xml` (the `-socket.` version suffix, the added `` / `` entry) and the `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` lines hosted mode writes: **no network**, so it restores under `--offline` too. `.mvn` files holding anything else keep the resolver lines (`maven_trusted_checksums_left`). + * **nuget** — `nuget.config` loses the `socket-patch-` source and its exact-id mapping; every `packages.lock.json` entry of the id gets nuget.org's `contentHash` back (`SOCKET_NUGET_URL`). Refused when the restored config would not resolve the id from nuget.org alone. A config hosted mode created from scratch is kept (`nuget_default_config_left`). + * Any other file wiring a pin refuses it (`socket-patch cannot re-derive the upstream entry in `). +* **Refusals.** `--offline` refuses every pin whose restore needs a registry lookup (all but maven), as does a registry that does not answer or no longer describes the entry. A refused pin writes nothing; its message is `cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)` — human `Error: Cannot restore …` on stderr (even under `--silent`), JSON `hosted.failed[{purl, error}]`, and `partial_failure` exit 1 (`remove`: the `hosted_revert_failed` error). A write failure after every pin resolved is one `hosted.failed` entry with the pseudo-purl `files`. +* **Output.** Human `Restored to its upstream registry entry` / `Would restore to its upstream registry entry` (`--dry-run`). vlt: the stale installed copies of restored nodes are removed afterwards, as before (`--no-vlt-install-cleanup` keeps them). ### JSON envelope (legacy shape + additive always-present keys) @@ -955,18 +963,18 @@ Restore the system but keep the local patch state for a later re-apply: manifest | Key | Shape | Meaning | |---|---|---| -| `warnings` | `[{code, detail}]` | Run-level warnings, now populated (previously always empty): `reinstall_required`, `hosted_state_not_preservable`, `out_of_scope_copies_restored`, `vendor_state_unreadable`, `redirect_state_unreadable`, `cleanup_failed`, `manifest_write_failed`, `redirect_pnpm_trust_scaffold_modified`, `redirect_npmrc_allow_remote_modified`, `ownership_not_restored` (a restored file whose ownership could not be put back — see the apply warnings), plus vendored/hosted leg advisories. New codes are additive (MINOR) | -| `vendored` | `[purl]` | **Meaning narrowed (MAJOR)**: vendor-owned purls the run did NOT act on — today exactly the corrupt-vendor-ledger skip. Previously this listed every vendor-owned skip | +| `warnings` | `[{code, detail}]` | Run-level warnings, now populated (previously always empty): `reinstall_required`, `hosted_state_not_preservable`, `out_of_scope_copies_restored`, `vendor_state_unreadable`, `cleanup_failed`, `manifest_write_failed`, `legacy_redirect_ledger_kept`, the upstream-restore advisories (`npm_allow_remote_left`, `pnpm_trust_lockfile_left`, `maven_trusted_checksums_left`, `nuget_default_config_left`, `upstream_uv_override_removed`), `ownership_not_restored` (a restored file whose ownership could not be put back — see the apply warnings), plus vendored/hosted leg advisories. New codes are additive (MINOR) | +| `vendored` | `[purl]` | **Meaning narrowed (MAJOR)**: vendor-owned purls the run did NOT act on — today exactly the corrupt-vendor-ledger skip. | | `vendoredReverted` | `[purl]` | Ledger entries cleanly reverted this run (unwired + artifact deleted + entry dropped; previewed on dry-run) | | `vendoredPreserved` | `[purl]` | `--preserve-state`: unwired with artifact + ledger entry kept | | `vendoredKept` | `[{purl, reason}]` | Drift-keeps — wiring drifted, vendored state (and the manifest entry) left untouched; drives exit 1 | | `vendoredFailed` | `[{purl, error}]` | Vendored reverts that errored — entry, artifact, and manifest record all survive for a retry; drives exit 1 | -| `hosted` | `{reverted: [purl], failed: [{purl, error}], unsupported: [purl], editedFiles: N}` | The hosted leg. `failed` entries may carry a `group:` pseudo-purl for whole-group replay refusals; `unsupported` lists scoped purls with no per-purl revert; `editedFiles` counts distinct files rewritten | +| `hosted` | `{reverted: [purl], failed: [{purl, error}], unsupported: [purl], editedFiles: N}` | The hosted leg (v5.0: the upstream restore). `reverted` lists the pins restored (would-be on dry-run); `failed` the refused pins with the version-control remedy in `error` (the pseudo-purl `files` for a write failure); `unsupported` is kept for shape and is always empty (every ecosystem has a restore); `editedFiles` counts distinct files rewritten | | `manifest` | `{removedEntries: [purl], preserved: bool}` | Entries removed from the manifest (would-be removals on dry-run); `preserved` mirrors `--preserve-state` | | `gc` | `{skipped: true}` \| `{removedBlobs, removedDiffArchives, removedPackageArchives, bytesFreed}` | Skipped under `--preserve-state`, after a blob-gate abort, and under a corrupt vendor ledger | | `paths` | `[string]` | The path-glob targets verbatim (empty when none) | -**Exit rules**: not-installed entries never flip the exit (the documented apply/rollback asymmetry — even an all-not-installed run exits 0 `success`). Everything that leaves the system still patched DOES flip it to `partial_failure` exit 1: agent-leg failures, vendored drift-keeps and revert failures, hosted refusals and scoped-unsupported targets, corrupt ledgers, and a failed manifest write. GC failures never affect the exit. +**Exit rules**: not-installed entries never flip the exit (the documented apply/rollback asymmetry — even an all-not-installed run exits 0 `success`). Everything that leaves the system still patched DOES flip it to `partial_failure` exit 1: agent-leg failures, vendored drift-keeps and revert failures, hosted refusals, a corrupt vendor ledger, and a failed manifest write. GC failures never affect the exit. ## Self-update contract (`socket-patch --update`) @@ -977,28 +985,33 @@ Synopsis and behavior: | Invocation | Behavior | |---|---| | `--update` | Resolve the latest release; install it if newer than the running version. Already-newest (including a dev build newer than any release): informational no-op, exit 0. `latest` never downgrades. | -| `--update 3.4.0` | Install exactly that version, **up or down** — an explicit pin is explicit intent, no `--force` needed. Pin == current: no-op, exit 0. The inline `--update=3.4.0` spelling is equivalent. Also settable via `SOCKET_PATCH_VERSION` (the same pin env `install.sh` and the gem launcher honor); a malformed version is a usage error (exit 2). | +| `--update 3.4.0` | Install exactly that version, **up or down** — an explicit pin is explicit intent, no `--force` needed. Pin == current: no-op, exit 0. The inline `--update=3.4.0` spelling is equivalent. Also settable via `SOCKET_PATCH_VERSION` (the same pin env `install.sh` honors); a malformed version is a usage error (exit 2). | | `--update --force` | Reinstall/downgrade even when already at the target version, and proceed past a managed-install refusal (with a warning that the owning manager's next upgrade will overwrite the binary). Env: `SOCKET_FORCE`. | | `--update --dry-run` | **Check-only**: one metadata request, zero downloads, zero mutation, exit 0 — and always the `verified`/`update_check` event shape, whether or not an update exists. `--json` details carry `{current, latest, updateAvailable, target, asset, path}` — the cheap scriptable "is an update available" probe. | | `--update --offline` | Refused up front (strict airgap, before any client exists), exit 1. `--force` does **not** bypass it. | Honored global flags: `--json`, `--silent` (errors only), `--yes` (skip the confirm prompt; `--json` also auto-confirms), `--dry-run`, `--offline`, `--verbose`, `--debug`, `--no-telemetry`. Other global flags parse and are ignored (the `list --global` precedent). -**Managed-install refusal.** The canonicalized executable path (symlinked invocations resolve to the real file) is classified before any network I/O; non-standalone channels exit 1 with `errorCode: managed_install` and the owning manager's command: +**Managed-install refusal.** The canonicalized executable path (symlinked invocations resolve to the real file) is classified before any network I/O; non-standalone channels exit 1 with `errorCode: managed_install` and an upgrade or migration command: | Detected channel | Hint | |---|---| | npm (`node_modules` path component) | project-local (the directory holding the outermost `node_modules` has a `package.json`, and it is not directly under `lib`/`npm` or below a yarn/pnpm `global` store): `npm install @socketsecurity/socket-patch@latest`, or `vlt install @socketsecurity/socket-patch@latest` when that directory holds `vlt-lock.json`, or `vlx -y -- @socketsecurity/socket-patch@latest …` when its `package.json` is vlx's (`"name": "vlx"`, the vlx cache); otherwise global (including version-manager prefixes such as nvm-windows and fnm): `npm update -g @socketsecurity/socket-patch` | -| PyPI wheel (`site-packages`/`dist-packages`) | `pip install --upgrade socket-patch` | +| Legacy PyPI wheel (`site-packages`/`dist-packages`) | `pip uninstall socket-patch` followed by the standalone installer (macOS/Linux) or `npm install -g @socketsecurity/socket-patch` (Windows) | | `cargo install` (`$CARGO_HOME/bin`, `~/.cargo/bin`) | `cargo install socket-patch-cli` | -| gem launcher cache (`/socket-patch/bin/…`) | `gem update socket-patch` | +| Legacy gem launcher cache (`/socket-patch/bin/…`) | `gem uninstall socket-patch` followed by the standalone installer (macOS/Linux) or `npm install -g @socketsecurity/socket-patch` (Windows) | | Homebrew (`Cellar`, `/opt/homebrew`) | `brew upgrade socket-patch` | +v5 publishes only standalone binaries, Cargo crates, and npm packages. Legacy +PyPI and RubyGems locations remain detectable so self-update does not silently +replace a binary owned by an old package. The standalone migration command is +`curl -fsSL https://install.socket.dev/patch | sh`. + **Pipeline order** (each step gates the next; a failure at any point leaves the installed binary untouched): fetch `SHA256SUMS` → fetch the archive (`socket-patch-.tar.gz`/`.zip`, explicit timeouts, size caps) → verify the SHA-256 **before** extraction → extract the single expected member → stage as an executable sibling **in the install directory** (`EACCES` here is the permissions preflight → exit 1 with a sudo hint; system temp is never used, so `noexec` mounts don't matter) → run the staged binary's `--version` self-check (against real GitHub the reported version must equal the release tag; under a `SOCKET_UPDATE_BASE_URL` override a mismatch only warns) → one atomic rename over the install path (mode-preserving; a **setuid/setgid** target — or, on Linux, one carrying **file capabilities** (`setcap`) — is refused, since an unprivileged swap cannot restore those grants; Windows uses the rename-dance via `self-replace`). Concurrent updates are single-flighted per environment by an advisory lock at `/update.lock` (`errorCode: update_in_progress`; the OS releases a dead holder's lock, so there is no stale-lock state). Two updaters whose state dirs diverge (e.g. different `$HOME`s targeting one shared `/usr/local/bin`) are not serialized, but every path to the destination is a whole-file rename and stage cleanup is age-gated — the worst case is duplicated work, never a torn binary. **Envelope.** `command: "update"`. Success events: `downloaded` (`details: {asset, bytes, sha256}`) then `updated` (`details: {from, to, path, target}`). No-op: `skipped` with reason `already_latest`. Dry-run: `verified` with reason `update_check`. Non-fatal advisories ride the run-level `warnings[]` (`{code, detail}`, omitted when empty) — human runs print the same text to stderr as `Warning: ` (first letter capitalized), and `--json` (which silences stderr) carries them here instead so an override is never silent: `managed_install_override` (a `--force` run replaced a package-manager-owned binary that manager's next upgrade will overwrite) and `update_warning` (a non-fatal note from the update engine, today the relaxed version self-check under a `SOCKET_UPDATE_BASE_URL` override). Top-level `errorCode` values (stable): `offline`, `managed_install`, `check_failed`, `asset_not_found`, `download_failed`, `checksum_mismatch`, `verify_failed`, `swap_failed`, `permission_denied`, `update_in_progress`. Exit codes: 0 success / no-op / dry-run; 1 operational failure; 2 usage. -**Trust model.** Checksum-only, rooted in HTTPS + GitHub (identical to install.sh and the launcher wrappers): `SHA256SUMS` is served from the same origin as the archives, there are no signatures yet. Downloads are credential-free — the Socket API bearer is never sent to the release host — and non-HTTPS redirect hops are refused when talking to the default endpoints. +**Trust model.** Checksum-only, rooted in HTTPS + GitHub (identical to install.sh): `SHA256SUMS` is served from the same origin as the archives, there are no signatures yet. Downloads are credential-free — the Socket API bearer is never sent to the release host — and non-HTTPS redirect hops are refused when talking to the default endpoints. ### Passive update notice @@ -1021,7 +1034,7 @@ State lives at `$XDG_CACHE_HOME`|`~/.cache` (Unix/macOS) or `%LOCALAPPDATA%` (Wi ## Environment variables -All v3.0 env vars use the `SOCKET_*` prefix. Three legacy `SOCKET_PATCH_*` names are still honored at runtime for compatibility: on first read of any of the three the binary emits a one-shot deprecation warning to stderr (the warning fires unconditionally — even under `--silent` / `--json` — because it's a transition signal users need to see). The legacy names will be removed in the next major release. +Public configuration uses the `SOCKET_*` names below. The three deprecated v3/v4 environment aliases were removed in v5; see [Removed env vars](#removed-env-vars). Four `SOCKET_CLI_*` names from the sibling JS Socket CLI are additionally accepted as **peer aliases** (supported, not deprecated — no warning): `SOCKET_CLI_API_TOKEN` → `SOCKET_API_TOKEN`, `SOCKET_CLI_ORG_SLUG` → `SOCKET_ORG_SLUG`, `SOCKET_CLI_API_BASE_URL` → `SOCKET_API_URL`, `SOCKET_CLI_NO_API_TOKEN` → `SOCKET_NO_API_TOKEN`. The canonical `SOCKET_*` name always wins when both are set; promotion is silent and happens in-process before clap parses. Other socket-cli names (`SOCKET_CLI_CONFIG`, `SOCKET_CLI_API_PROXY`, `SOCKET_CLI_DEBUG`) are deliberately **not** honored. @@ -1034,9 +1047,9 @@ Empty string means unset at every layer: exported-but-empty flag-bound vars are | `SOCKET_API_URL` | `--api-url` | `https://api.socket.dev` | — | | `SOCKET_API_TOKEN` | `--api-token` | (none) | Absence selects the public proxy. | | `SOCKET_ORG_SLUG` | `--org` / `-o` | (auto-resolve) | — | -| `SOCKET_PROXY_URL` | `--proxy-url` | `https://patches-api.socket.dev` | **Renamed in v3.0** (was `SOCKET_PATCH_PROXY_URL`). | +| `SOCKET_PROXY_URL` | `--proxy-url` | `https://patches-api.socket.dev` | — | | `SOCKET_ECOSYSTEMS` | `--ecosystems` / `-e` | (all) | Comma-separated list. | -| `SOCKET_DOWNLOAD_MODE` | `--download-mode` | `diff` | One of `diff` / `package` / `file`. | +| `SOCKET_DOWNLOAD_MODE` | `--download-mode` | `diff` | One of `diff` / `file`. | | `SOCKET_VENDOR_SOURCE` | `--vendor-source` | `auto` | One of `auto` / `service` / `build`. | | `SOCKET_VENDOR_URL` | `--vendor-url` | (active API/proxy base) | Vendoring-service package-reference host. | | `SOCKET_PATCH_SERVER_URL` | `--patch-server-url` | (server-returned) | Rewrites the prebuilt-archive download host. | @@ -1048,20 +1061,26 @@ Empty string means unset at every layer: exported-but-empty flag-bound vars are | `SOCKET_VERBOSE` | `--verbose` / `-v` | `false` | — | | `SOCKET_SILENT` | `--silent` / `-s` | `false` | — | | `SOCKET_DRY_RUN` | `--dry-run` | `false` | — | -| `SOCKET_YES` | `--yes` / `-y` | `false` | — | +| `SOCKET_YES` | `--yes` / `-y` | `false` | Skips the prompts of `get`, `rollback`, `remove` and `--update`; `scan` never prompts, so it has no effect there. | | `SOCKET_LOCK_TIMEOUT` | `--lock-timeout` | (none) | Seconds to wait for `apply.lock` on the lock-taking subcommands (incl. hosted/vendored `scan`/`get`); unset/`0` = single non-blocking try. | -| `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`, `--update --force` | `false` | Local to `apply` and `--update`. | -| `SOCKET_PATCH_VERSION` | `--update ` | (latest) | Local to `--update`; the same pin `install.sh` and the gem launcher honor. Not one of the deprecated legacy `SOCKET_PATCH_*` trio. | +| `SOCKET_DEBUG` | `--debug` | `false` | — | +| `SOCKET_TELEMETRY_DISABLED` | `--no-telemetry` | `false` | — | +| `SOCKET_NO_TRUST_LOCKFILE_CONFIG` | `--no-trust-lockfile-config` | `false` | Hosted mode: skip the `trustLockfile: true` write to `pnpm-workspace.yaml`. | +| `SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG` | `--no-npm-allow-remote-config` | `false` | Hosted mode: skip the `allow-remote=all` write to the project `.npmrc`. | +| `SOCKET_NO_VLT_INSTALL_CLEANUP` | `--no-vlt-install-cleanup` | `false` | Hosted mode, `rollback`, `remove`: keep stale vlt installed copies. | +| `SOCKET_FORCE` | `apply --force` / `-f`, `vendor --force` / `-f`, `--update --force` | `false` | Local to `apply`, `vendor` and `--update`. | +| `SOCKET_PATCH_VERSION` | `--update ` | (latest) | Local to `--update`; the same pin `install.sh` honors. | | `SOCKET_BATCH_SIZE` | `scan --batch-size` | `500` authenticated / `100` proxy | Local to `scan`. | +| `SOCKET_MAX_NEW_PATCHES` | `scan --max-new-patches` | (unlimited) | Local to `scan` (v5.0): a count or `none`; empty is unset, malformed exits 2. | +| `SOCKET_SCAN_PACKAGES` | `scan --package` | (none) | Local to `scan` (v5.0); comma-separated names or purls. | +| `SOCKET_NO_SOCKET_YML` | `scan --no-socket-yml` | `false` | Local to `scan` (v5.0); bool vocabulary, empty = unset. | +| `SOCKET_MIN_SEVERITY` | `scan --min-severity` | (none) | Local to `scan` (v5.0); read by scan (not clap) so the `policy` block can say `source: "env"`; empty = unset, malformed = exit 2. | | `SOCKET_SAVE_ONLY` | `get --save-only` | `false` | Local to `get`. | -| `SOCKET_ONE_OFF` | `get --one-off` / `rollback --one-off` | `false` | Local to `get`/`rollback`. Both are **not yet implemented**: the flag parses (boolishly, empty-tolerant) and the command fails up front with a "not yet implemented" error, before any network or disk activity (on `rollback`, with no identifier-shaped target it instead fails "requires an identifier", equally up front). | | `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`. Conflicts with `--preserve-state`/`SOCKET_PRESERVE_STATE` (exit 2 — see below). | | `SOCKET_PRESERVE_STATE` | `rollback --preserve-state` / `remove --preserve-state` | `false` | (v5.0) Shared by `rollback`/`remove` (boolish, empty-tolerant parse like the other bool flags): restore the system but keep the local patch state — manifest entries, vendored artifacts + ledger entries — and skip all GC. On `remove`, combining it with `--skip-rollback` is a usage error (exit 2) **whether either side is flag- or env-sourced** (`SOCKET_PRESERVE_STATE=true remove --skip-rollback` exits 2 too). | | `SOCKET_DOWNLOAD_ONLY` | `repair --download-only` | `false` | Local to `repair`. | -| `SOCKET_SETUP_EXCLUDE` | `setup --exclude` | (none) | Local to `setup`; comma-separated workspace-member paths, persisted to `setup.exclude`. | +| `SOCKET_VENDOR_REVERT` | `vendor --revert` | `false` | Local to `vendor`. | | `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`). | @@ -1099,40 +1118,41 @@ Contract properties: - The file is read lazily at most once per process, only when a key is still unresolved after flag + env. - The telemetry endpoint resolver shares the same `apiBaseUrl` chain as API-client construction (`resolve_api_base_url`), so telemetry can never target a different host than the client. - `--offline` semantics are unchanged: reading the local file is not network contact; a config-sourced token is inert offline. -- **Repo-level files never carry endpoints, credentials, or interlock-disablers**: configuration for those comes only from flags, env vars, this user-level file, and built-in defaults — never from files inside the repository being patched (manifest, socket.yml, `.env`, …). +- **Repo-level files never carry endpoints, credentials, or interlock-disablers**: configuration for those comes only from flags, env vars, this user-level file, and built-in defaults — never from files inside the repository being patched (manifest, socket.yml, `.env`, …). A repository file may **narrow or pace** what `scan` patches (socket.yml's `patches` block and `projectIgnorePaths`, see "socket.yml patch policy"). It may never name an endpoint or credential, pick a mode or download format, turn off a safety check, or make `scan` patch anything it would not patch with no file present; the one exception is negating the built-in test/fixture path ignores, which are repo policy by nature. - `--debug` names the source on stderr whenever a setting resolves from the socket-cli config (the token value itself is never echoed). ### Registry override env vars -Env-only knobs (no CLI flag) read by the vendor auto-fetch / artifact-rebuild paths in `socket-patch-core` (`src/vendor/registry_fetch.rs`, `src/vendor/maven_repo.rs`). Each is the enterprise-mirror / test escape hatch for one registry base; trailing slashes are trimmed and an exported-but-empty value falls back to the default. Lock-recorded URLs (npm/yarn/composer/gem/uv `resolved`/dist URLs) are used verbatim and bypass these. +Env-only knobs (no CLI flag) read by the vendor auto-fetch / artifact-rebuild paths in `socket-patch-core` (`src/vendor/registry_fetch.rs`, `src/vendor/maven_repo.rs`) and (v5.0) by the hosted upstream restore of `rollback` / `remove` / the vendored takeover (`src/patch/redirect/upstream/client.rs`, which honors the same bases). Each is the enterprise-mirror / test escape hatch for one registry base; trailing slashes are trimmed and an exported-but-empty value falls back to the default. Lock-recorded URLs (npm/yarn/composer/gem/uv `resolved`/dist URLs) are used verbatim and bypass these. | Env var | Default | Notes | |---|---|---| -| `SOCKET_NPM_REGISTRY` | `https://registry.npmjs.org` | Base for conventional npm tarball URLs (vendor auto-fetch + the npm-family lockfile-integrity reconstruction rung in `repair`). | +| `SOCKET_NPM_REGISTRY` | `https://registry.npmjs.org` | Base for conventional npm tarball URLs (vendor auto-fetch, including `repair`'s local-build fallback) and, v5.0, the version documents (`//`, a scoped name's `/` as `%2f`; `dist.tarball` / `integrity` / `shasum`) the npm-family and vlt upstream restore reads. | | `SOCKET_CRATES_REGISTRY` | `https://static.crates.io/crates` | crates.io static `.crate` download host. | | `SOCKET_GOPROXY` | `https://proxy.golang.org` | Go module proxy. Wins over the standard `GOPROXY` env var, whose first element is used otherwise. When that element is `off` or `direct`, or the module matches `GONOPROXY` (default `GOPRIVATE`), go would not ask a proxy, so the pristine fetch is refused (`vendor_fetch_unverifiable` + the calm `package_not_installed` skip) instead of falling back to `proxy.golang.org`. | | `SOCKET_MAVEN_REGISTRY` | `https://repo1.maven.org/maven2` | maven2 base for the fallback upstream-pom download. | +| `SOCKET_CRATES_INDEX` | `https://index.crates.io` | v5.0 upstream restore: the crates.io sparse index whose `checksum` a restored `Cargo.lock` entry gets back. | +| `SOCKET_GOSUMDB_URL` | `https://sum.golang.org` | v5.0 upstream restore: the checksum database the restored go.sum lines are checked against. Without it, `GOSUMDB=off` or a module matching `GONOSUMDB` (default `GOPRIVATE`) skips the database and the hashes come from the module proxy's bytes alone (`SOCKET_GOPROXY` above). | +| `SOCKET_PYPI_JSON_API` | `https://pypi.org/pypi` | PyPI's JSON API (`///json`): the vendored fetch's hash → URL lookup, and (v5.0) the release files whose sha256 the upstream restore writes back into every Python lock format. | +| `SOCKET_RUBYGEMS_URL` | `https://rubygems.org` | v5.0 upstream restore: the compact index (`/info/`) a restored `CHECKSUMS` entry is re-pinned from. | +| `SOCKET_PACKAGIST_URL` | `https://repo.packagist.org` | v5.0 upstream restore: packagist's composer v2 metadata (`/p2//.json`) a restored `composer.lock` `dist` / `source` comes from. | +| `SOCKET_NUGET_URL` | `https://api.nuget.org` | v5.0 upstream restore: the nuget.org API host whose catalog `packageHash` a restored `packages.lock.json` `contentHash` comes from. | ### Internal env vars (no stability guarantee) -These exist for staged rollouts and the launcher wrappers. They are **internal**: names, semantics, and existence may change in any release without a semver bump. +These exist for mirrors and testing. They are **internal**: names, semantics, and existence may change in any release without a semver bump. | Env var | Purpose | |---|---| -| `SOCKET_PATCH_BIN` | Points the RubyGems CLI launcher and the gem Bundler plugin at an existing `socket-patch` binary (skips the download-on-first-run); also the escape hatch `apply` names when a golang-featureless binary is asked to audit Go redirects. | | `SOCKET_UPDATE_BASE_URL` | Points BOTH the release-metadata and asset-download routes of `--update`/the update notice at one base (mirror or test fixture) instead of `github.com` + `api.github.com`. Overriding it relaxes the downloaded binary's version self-check from hard-fail to warning. | | `SOCKET_UPDATE_STATE_DIR` | Overrides the per-user dir holding `update-check.json` + `update.lock` (tests point it into a tempdir). | | `SOCKET_UPDATE_TIMEOUT_MS` | Caps the update fetches' connect/metadata/download budgets (defaults 10 s / 30 s / 300 s; the notice's fetch defaults to 2 s). Doubles as the slow-network escape hatch. | | `SOCKET_UPDATE_NOTIFIER_FORCE` | Test hook: bypasses the update notice's stderr-TTY guard — and nothing else (opt-out, offline, `--silent`, `--json`, CI all still win). | | `SOCKET_UPDATE_GRACE_MS` | Test hook: overrides the notice's post-command join grace (default 500 ms — how long the run waits for the background check before abandoning it and exiting). Lets the e2e suite await the loopback fetch to completion so its observable effect is deterministic; production keeps the tight 500 ms ceiling. | -### Deprecated env vars +### Removed env vars -| Legacy | Renamed to | Status | -|---|---|---| -| `SOCKET_PATCH_PROXY_URL` | `SOCKET_PROXY_URL` | Honored with warning; remove in next major. | -| `SOCKET_PATCH_DEBUG` | `SOCKET_DEBUG` | Honored with warning; remove in next major. | -| `SOCKET_PATCH_TELEMETRY_DISABLED` | `SOCKET_TELEMETRY_DISABLED` | Honored with warning; remove in next major. | +The v3.0 legacy names `SOCKET_PATCH_PROXY_URL`, `SOCKET_PATCH_DEBUG` and `SOCKET_PATCH_TELEMETRY_DISABLED` were removed in v5.0 and are ignored; use `SOCKET_PROXY_URL`, `SOCKET_DEBUG` and `SOCKET_TELEMETRY_DISABLED`. ## CSV value parsing @@ -1146,7 +1166,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified ```jsonc { - "command": "scan" | "apply" | "vex" | "vendor" | "setup" | "rollback" | "get" | "list" | "remove" | "repair", + "command": "scan" | "apply" | "vex" | "vendor" | "rollback" | "get" | "list" | "remove" | "repair", "status": "success" | "partialFailure" | "error" | "noManifest" | "paidRequired" | "notFound", "dryRun": false, "events": [ , ... ], @@ -1180,7 +1200,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified { "path": "package/index.js", "verified": true, - "appliedVia": "package" | "diff" | "blob" // only on action=applied + "appliedVia": "diff" | "blob" // only on action=applied; v5.0 drops "package" } ], "bytes": 1234, // optional (downloaded/removed) @@ -1205,7 +1225,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `failed` | every command | A specific patch attempt failed. `errorCode` + `error` set. | | `removed` | `gc`/`repair`, `remove`, `rollback` | Data was removed from `.socket/` (or files rolled back). `bytes` optional. | | `verified` | `apply --dry-run`, `scan --dry-run` | The patch *would* apply cleanly. `files` lists previewed changes. | -| `rebuilt` | `repair` | A missing/corrupt vendored artifact was rebuilt in place (or its lost ledger entry restored — `details.ledgerRestored`). `summary.rebuilt` counts these (the field is omitted while zero). | +| `rebuilt` | `repair` | A missing/corrupt vendored artifact was re-vendored in place (v5.0: never a lost ledger entry — see `vendor_ledger_missing`). `summary.rebuilt` counts these (the field is omitted while zero). | ### Stable `errorCode` tags @@ -1223,34 +1243,34 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `vendored` | `skipped` | apply (every ecosystem) + scan `--apply`: the package is managed by `socket-patch vendor`; the command yields ownership (scan also skips the download). v5.0: rollback no longer yields — its vendored leg reverts these entries by default, and its `vendored: []` array is reserved-empty (a corrupt vendor ledger surfaces via the `vendor_state_unreadable` warning + exit 1 — the skip cannot name purls, since naming them needs the ledger). Scan `--apply --json` additionally surfaces one run-level `vendored_ownership_retained` warning naming the skipped purls (additive; exit/status unchanged). | | `vendor_reverted` | `removed` | remove: vendoring reverted (lock fragments restored, artifact + ledger entry gone) as part of removing the patch. | | `vendor_revert_failed` | top-level error | remove: the vendor revert failed; the manifest was NOT modified. | -| `vendor_state_retained` | `skipped` | remove `--skip-rollback`: vendor wiring + artifact deliberately left in place (the next `vendor` run reconciles the dropped entry). Also the top-level error code when `--skip-rollback` targets a vendored patch with no manifest record (every `scan`/`get --mode vendored` entry — and, v5.0, the ledger-only leftover of an earlier `remove --skip-rollback` of a manifest-tracked vendored patch, which used to answer `not_found`). | -| `hosted_state_retained` | (top-level error) | remove `--skip-rollback` targeting a hosted-only patch (no manifest entry): unwinding the redirect is the only possible removal, so the combination is refused (exit 1), mirroring the manifest-less vendored refusal above. | +| `vendor_state_retained` | `skipped` | remove `--skip-rollback`: vendor wiring + artifact deliberately left in place (the next `vendor` run reconciles the dropped entry). Also the top-level error code when `--skip-rollback` targets a vendored patch with no manifest record (every `scan`/`get --mode vendored` entry — and, v5.0, the ledger-only leftover of an earlier `remove --skip-rollback` of a manifest-tracked vendored patch). | +| `hosted_state_retained` | (top-level error) | remove `--skip-rollback` targeting a hosted-only patch (no manifest entry): restoring the pin's upstream registry entry is the only possible removal, so the combination is refused (exit 1), mirroring the manifest-less vendored refusal above. | | `vendor_state_preserved` | `skipped` | remove `--preserve-state` (v5.0): lockfile unwired; artifact, ledger entry, and manifest entry all kept for a later re-apply. Rollback's counterpart is the `vendoredPreserved: []` envelope array. | | `vendor_revert_kept` | `skipped` + top-level error | remove (v5.0): the vendored revert drift-kept (`kept_artifact`), so the ledger entry AND the manifest entry were both kept. ANY drift-keep makes the run a `partialFailure` (exit 1) — part of the requested removal did not happen; when EVERY matching entry drift-kept, the top-level error carries this code (`summary.removed` stays 0; the identifier DID match, so never `not_found`). Remedy: re-run `scan --mode vendored` to normalize, then remove. Rollback's counterpart is the `vendoredKept: []` envelope array (also exit 1). | -| `hosted_reverted` | `removed` | remove (v5.0): a hosted lockfile redirect was unwound as part of removing the patch (`verified` on dry-run). Bypasses `summary.removed` like `vendor_reverted`. | -| `hosted_revert_unsupported` | top-level error | remove (v5.0): the identifier matches hosted records of an ecosystem with no per-purl revert (and the identifier does not cover the full record set, so the whole-ledger replay cannot serve it — maven/nuget always land here scoped, as do npm purls a refused replay left behind). The manifest was not modified; exit 1. Remedy: unscoped `socket-patch rollback`, or re-run `scan --mode hosted`. Rollback reports the same condition in its `hosted.unsupported` array (exit 1). | -| `hosted_revert_failed` | top-level error | remove (v5.0): a per-purl hosted unwind, group replay, or redirect-ledger persist failed; the manifest was not modified, exit 1. Rollback's counterpart is a `hosted.failed[]` entry (also `partial_failure` exit 1). | +| `hosted_reverted` | `removed` | remove (v5.0): a hosted lockfile pin was restored to its upstream registry entry as part of removing the patch (`verified` on dry-run). Beside a manifest entry it bypasses `summary.removed` like `vendor_reverted`. | +| `hosted_revert_failed` | top-level error | remove (v5.0): a matched hosted pin could not be restored to its upstream registry entry (`--offline`, a registry that does not answer, `bun.lockb`, a lock shape the restore refuses — see "Hosted unwind coverage"), or writing the restored files failed; the message names the `git checkout -- ` remedy. The manifest was not modified, exit 1. Rollback's counterpart is a `hosted.failed[]` entry (also `partial_failure` exit 1). v4's `hosted_revert_unsupported` is no longer emitted (every ecosystem has a restore). | | `reinstall_required` | rollback `warnings[]` | rollback (v5.0): vendored/hosted wiring was unwound, but installed trees keep their patched bytes until the next package-manager install — the stale-install advisory. | -| `hosted_state_not_preservable` | rollback `warnings[]` | rollback `--preserve-state` (v5.0): hosted redirects were unwound and their ledger records dropped anyway — hosted has no preservable local state; re-run `scan --mode hosted` to re-wire. (`remove --preserve-state` prints the same note on stderr.) | +| `hosted_state_not_preservable` | rollback `warnings[]` | rollback `--preserve-state` (v5.0): hosted pins were restored to upstream anyway — the lockfile pins are hosted mode's only record, so there is no local state to preserve; re-run `scan --mode hosted` to re-wire. (`remove --preserve-state` prints the same note on stderr.) | | `out_of_scope_copies_restored` | rollback `warnings[]` | path-scoped rollback (v5.0): a selected patch had installed copies outside the given patterns; ALL copies were restored (patches are per-package). Informational — never flips the exit. | | `path_scope_excluded_supplements` | scan `warnings[]` | path-scoped scan (v5.0): lockfile-only / vendor-ledger supplement packages have no installed path and were excluded from the scoped scan; the detail carries the count. | | `vendor_commit_failed` | top-level error (`vendor`, and the nested vendor envelope of `scan` / `get --mode vendored`) | v5.0 group commit: the run's lockfile / manifest / ledger edits could not be written (the detail names the I/O error). Exit 1; the project's lockfiles and `.socket/vendor/state.json` are left as they were before the run (a partially-applied commit is put back), and the per-package events describe the uncommitted outcome. When putting a partially-applied commit back fails too, the journal is kept instead and the detail says the next socket-patch command in the project finishes the commit. | -| `vendor_state_unreadable` / `redirect_state_unreadable` | rollback `warnings[]`; remove top-level error | corrupt-ledger containment (v5.0). Rollback: an unreadable vendor ledger skips the vendored leg + manifest cleanup + GC; an unreadable redirect ledger skips the hosted leg (quarantine/restore remedy in the detail); either drives `partial_failure` exit 1 while the agent leg still restores files. Remove: `vendor_state_unreadable` is a hard top-level error before any mutation (an unreadable redirect ledger only warns — the identifier may match other stores). Also the Bun vendored preflight's refusal code: `get` / `scan --mode vendored`, `vendor`'s pre-takeover check and the `--dry-run` `would_refuse` preview report an unreadable `.socket/vendor/state.json` as itself (`errorCode` in `patches[]` / `download.patches[]`, or `get `'s top-level `error.code`), fail-closed — nothing is exempt — instead of a Bun lock code. | +| `vendor_state_unreadable` | rollback `warnings[]`; remove top-level error | corrupt-ledger containment (v5.0). Rollback: an unreadable vendor ledger skips the vendored leg + manifest cleanup + GC and drives `partial_failure` exit 1 while the agent and hosted legs still run. Remove: a hard top-level error before any mutation. Also the Bun vendored preflight's refusal code: `get` / `scan --mode vendored`, `vendor`'s pre-takeover check and the `--dry-run` `would_refuse` preview report an unreadable `.socket/vendor/state.json` as itself (`errorCode` in `patches[]` / `download.patches[]`, or `get `'s top-level `error.code`), fail-closed — nothing is exempt — instead of a Bun lock code. (v4's `redirect_state_unreadable` is no longer emitted: v5 never reads the redirect ledger on these paths.) | | `manifest_write_failed` | rollback `warnings[]` | rollback (v5.0): the post-rollback manifest update could not be written; no entries were removed (`manifest.removedEntries: []`) and the run exits `partial_failure` 1. | -| `redirect_pnpm_trust_scaffold_modified` | rollback/remove `warnings[]` | hosted replay (v5.0): the redirect-created `pnpm-workspace.yaml` scaffold was modified since; the file was kept and only the `trustLockfile: true` line removed. | -| `redirect_npmrc_allow_remote_modified` | rollback/remove `warnings[]` (+ human stderr); vendored-supersedes-hosted reconcile `warnings[]` (`vendor`, `scan --mode vendored`); vendor advisory event | hosted unwind (v5.0): the redirect-created project `.npmrc` was modified since; the file was kept and only the `allow-remote=all` line removed. | +| `npm_allow_remote_left` / `pnpm_trust_lockfile_left` | rollback/remove `warnings[]`; vendor advisory event (takeover) | upstream restore (v5.0): no npm-family lock entry is hosted any more, but the project `.npmrc` keeps a top-level `allow-remote=all` (resp. `pnpm-workspace.yaml` keeps `trustLockfile: true`) in a file that is not exactly what hosted mode creates; the file is left untouched (v5 records no provenance), remove the line if nothing else needs it. A file that is exactly hosted mode's own is deleted silently. | +| `maven_trusted_checksums_left` / `nuget_default_config_left` / `upstream_uv_override_removed` | rollback/remove `warnings[]`; vendor advisory event (takeover) | upstream restore (v5.0): `.mvn` config keeps the trusted-checksums resolver lines because it holds more than hosted mode writes; `nuget.config` now holds only the nuget.org source (delete it if hosted mode created it); a transitive `override-dependencies` entry hosted mode added to `pyproject.toml` was removed. | +| `legacy_redirect_ledger_kept` | rollback `warnings[]` (+ remove stderr) | v5.0: a pre-v5 `.socket/vendor/redirect-state.json` could not be deleted once no hosted pin was left; the file is inert (never read for planning). Never flips the exit. | | `vendor_stale_artifact_removed` | `removed` | vendor / scan `--vendor`: re-vendor under a newer patch uuid removed the previous uuid's orphaned artifact dir. | | `vendor_unsupported_ecosystem` | `skipped` | vendor: no vendor backend for this purl's ecosystem (jsr). | | `already_vendored` | `skipped` | vendor: artifact + wiring already in sync for this patch uuid. | | `unsafe_coordinates` | `failed` | vendor: purl/uuid would escape `.socket/vendor/` (tampered manifest/state); refused before any write. | | `revert_failed` | `failed` | vendor --revert: a recorded entry could not be reverted. | -| `vendor_wiring_unknown_revert_blocked` | `skipped` (beside the `failed`/`revert_failed` event) | vendor --revert: the ledger entry was reconstructed by `repair` without wiring records and the live lockfile still resolves through the artifact — the revert refuses (fail-closed) instead of deleting a tarball the lock points at. Recovery: `socket-patch repair`, then restore the pre-vendor lock (or re-lock without the override) and re-run the revert. repair: an npm ledger entry whose `flavor` this release does not know (written by a newer socket-patch) is skipped, never health-checked or rebuilt, and the artifact, wiring and ledger stay as found (a lone `skipped` event; the run's exit is unaffected). Recovery: upgrade socket-patch. | -| `ecosystem_not_setup` | `skipped` | vex: the patch is applied and byte-verified but its ecosystem has no install hook configured and is not declared in the manifest's `setup.manual`, so it is omitted from the document (Property 7). Previously invisible in `--json`. | +| `vendor_ledger_missing` | `failed` (artifact-level: `uuid` + `details.{ecosystem,path}`, no purl) | repair (v5.0): a lockfile references `.socket/vendor///` but the vendor ledger has no entry for it; repair no longer rebuilds ledger entries from lockfiles. Recovery: restore `.socket/vendor/state.json` from version control and re-run `repair`, or `git checkout -- ` and re-vendor. | +| `vendor_wiring_unknown_revert_blocked` | `skipped` (beside the `failed`/`revert_failed` event) | vendor --revert: the ledger entry was reconstructed by a pre-v5 `repair` without wiring records and the live lockfile still resolves through the artifact — the revert refuses (fail-closed) instead of deleting a tarball the lock points at. Recovery: `socket-patch repair`, then restore the pre-vendor lock (or re-lock without the override) and re-run the revert. repair: an npm ledger entry whose `flavor` this release does not know (written by a newer socket-patch) is skipped, never health-checked or rebuilt, and the artifact, wiring and ledger stay as found (a lone `skipped` event; the run's exit is unaffected). Recovery: upgrade socket-patch. | | `stale_install` | `skipped` | vex (in-run `scan --mode hosted --vex`): a hosted stale-install probe found positively unpatched installed bytes, so the purl is omitted even under `--vex-no-verify` (see the gem / Python stale-install guards). | -| `record_unavailable` | `skipped` | vex (manifest-less): a lockfile-wired patch has no local record (manifest, redirect ledger, vendor ledger) and none could be fetched — `--offline`, transport error, 404, or a refused (paid) patch. Omitted, never attested from the `socket-patch.vendor.json` marker. | +| `record_unavailable` | `skipped` | vex (manifest-less): a lockfile-wired patch has no local record (manifest, this run's hosted records or a pre-v5 redirect ledger, vendor ledger) and none could be fetched — `--offline`, transport error, 404, or a refused (paid) patch. Omitted, never attested from the `socket-patch.vendor.json` marker. | | `record_mismatch` | `skipped` | vex (manifest-less): the record found for a wired patch names another package or another patch uuid than the wiring. | | `vendor_unwired` | `skipped` | vex: a vendor-ledger entry whose committed artifact no lockfile/config wires any more (reverted lock, leftover ledger or artifact). Applies under `--no-verify` too. | -| `redirect_unwired` | `skipped` | vex: a redirect-ledger record whose hosted patch no lockfile wires any more (and no manifest entry owns the purl). Applies under `--no-verify` too. | +| `redirect_unwired` | `skipped` | vex: a hosted record (this run's, or a pre-v5 redirect ledger's) whose hosted patch no lockfile wires any more (and no manifest entry owns the purl). Applies under `--no-verify` too. | | `wiring_conflict` | `skipped` | vex (manifest-less): the lockfiles wire one package to two or more different patches (e.g. a stale sibling lock); which one the build installs is undecidable, so none is attested. | | `hash_mismatch` / `not_applied` / `file_not_found` / `package_not_found` / `no_files` / `vendor_*` | `skipped` | vex: verification omissions — the installed copy (agent / hosted) or the committed artifact (`vendor_hash_mismatch`, `vendor_artifact_missing`, `vendor_artifact_unreadable`, `vendor_inventory_mismatch`, `vendor_uuid_mismatch`, `vendor_path_unsafe`) does not carry the patched bytes, or nothing is installed. `vendor_manifest_unverifiable`: a vendored vlt directory verified without its vendor ledger (from `vlt-lock.json` alone) holds a `package.json` with its devDependencies stripped, and the patched `package.json` blob is not in `.socket/blobs`, so it cannot be checked. A lockfile-pinned hosted reference with nothing installed attests instead of `package_not_found` (see "Manifest-less VEX"). | | `lockfile_unreadable` / `lockfile_unparseable` / `patched_ref_invalid` / `patched_ref_unattributable` | run-level `warnings[]` | vex (every form): lockfile-discovery diagnostics — see "Manifest-less VEX (lockfile discovery)". Never flip the exit on their own. | @@ -1259,7 +1279,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `vendor_bun_lockb_invalid` | `failed` | vendor / scan / get `--mode vendored`: the binary lock is malformed, unreadable, unsupported or cannot be rewritten safely. The detail names the parser, hash or filesystem error. Refused before patch downloads and before hosted takeover; `patches[]` / `download.patches[]` carry `errorCode` and `error`, while `get ` also carries top-level `error.code`. Dry-run predicts the same refusal. | | `vendor_bun_workspace_unsupported` | `failed` | vendor / scan / get `--mode vendored` (bun): the text lock holds `workspace:` packages and its `lockfileVersion` is below 2 — Bun 1.2–1.3 resolve a workspace member's local-tarball path relative to the member; a committed version-2 lock is the proof every consumer runs Bun ≥ 1.4 (deliberate over-approximation: root-only declared packages would install on version 1 too). Detail names the version integer and a version-specific remedy: delete `bun.lock` and re-lock with Bun ≥ 1.4 (an in-place `bun install` keeps the existing version) — then, for a version-1 lock, "or use `--mode hosted`, which accepts version-1 workspace locks"; for a version-0 lock, "or delete `bun.lock`, re-lock with Bun ≥ 1.2 (which writes lockfileVersion 1) and use `--mode hosted`" (hosted refuses version-0 workspace locks, so a bare hosted pointer would send the user into a second refusal). Refused before any write — in the pre-download preflight on `get`/`scan` (see `vendor_bun_lockb_invalid` for the placements); in the shared preflight that `vendor` and the vendor step run BEFORE a hosted → vendored takeover's revert (a hosted-redirected purl stays hosted-wired, ledger and lock untouched; `vendor --dry-run` previews the same `failed` code); and in the engine when the run would write a NEW local tuple. Exempt: purls the vendor ledger wires at the selected uuid, purls whose every `bun.lock` instance is already a `.socket/vendor/npm/` tuple (any uuid), in-sync re-runs and `repair` rebuilds. | | `vendor_lockfile_missing` / `vendor_lockfile_version_unsupported` (bun preflight placement) | `failed` | scan / get `--mode vendored` (bun): the pre-download preflight found `bun.lock` unreadable / at a `lockfileVersion` other than 0, 1 or 2 (a newer version: update socket-patch; no integer: re-lock with Bun ≥ 1.2 — the same text as hosted's `redirect_bun_lock_unsupported`) or outside bun's single-line `packages` grammar. Same placements as `vendor_bun_lockb_invalid`; nothing fetched, no patch record. An unreadable `.socket/vendor/state.json` met by the same preflight is `vendor_state_unreadable` (see that row), never one of these. | -| `bun_lockb_invalid` | scan `warnings[]` (run-level) | scan (every mode): the native binary inventory could not parse or read `bun.lockb`; detail names the format or filesystem error. Also printed as `Warning (bun_lockb_invalid): …` on stderr. Exit and status remain unchanged. The warning is retained on empty and non-empty scans; valid binary locks are inventoried normally without a runtime or install. | +| `bun_lockb_invalid` | scan `warnings[]` (run-level) | scan (every mode): the native binary inventory could not parse or read `bun.lockb`; detail names the format or filesystem error. Also printed as `Warning: …` on stderr. Exit and status remain unchanged. The warning is retained on empty and non-empty scans; valid binary locks are inventoried normally without a runtime or install. | | `would_refuse` | dry-run preview action (`vendor.patches[]`) | scan `--mode vendored --dry-run` / get `--mode vendored --dry-run`: the wet run's Bun preflight would refuse this npm purl; the record carries `errorCode` (one of the four Bun lock codes above, or `vendor_state_unreadable` for an unreadable vendor ledger) + `error`. Exit 0 / `status: "success"`, nothing written. | | `cargo_wiring_migrated` | `skipped` (advisory note) | vendor / scan / get `--mode vendored` / repair (v5.0): a pre-v5 `.cargo/config.toml` / `.cargo/config` vendored `[patch.crates-io]` entry was moved into the workspace-root `Cargo.toml` (dry run: "would move"); the ledger entry is rewritten to name `Cargo.toml` (lock originals kept). A vendor re-run that migrates reports the package `applied`, not `already_vendored`. | | `cargo_legacy_wiring_kept` | vendor: `failed`; repair: `skipped` (warning) | vendor / scan / get `--mode vendored` (v5.0): the pre-v5 config entry could not be removed after the manifest took the wiring — the run is unwound (manifest, lock and copy as before) and the package fails, since a kept entry would double-wire the crate and, on a uuid bump, point at a copy the stale sweep deletes; the code prefixes the error detail. repair: the move was refused (e.g. an unparseable `Cargo.toml`, a user entry for the crate, or an unremovable legacy entry — the manifest edit is unwound); left in place. | @@ -1269,25 +1289,32 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `cargo_copy_untaggable` | `failed` (error prefix) | vendor / scan / get `--mode vendored` (cargo, v5.0): the copy's `Cargo.toml` has no literal `[package] version` string that can be rewritten byte-exactly (or it names another version); nothing is swapped in. A dry run over an already-vendored copy reports the same failure; a patch-service crate that cannot be tagged is a miss (`vendor_prebuilt_layout_mismatch`: `auto` builds locally, `service` fails `vendor_prebuilt_required`). | | `cargo_wiring_restored` | `skipped` (advisory note) | repair (v5.0): a vendored crate's Cargo.lock entry was detached with no Socket-owned `[patch]` pointing at its committed copy (a pre-v5 release overwrote its crate-named config key when a second version was vendored); the manifest entry is written back and the ledger updated (dry run: "would restore"). A `vendor` re-run heals the same state as a plain re-vendor. | | `cargo_manifest_unreadable` / `cargo_manifest_unparseable` / `cargo_manifest_symlink_unsupported` / `cargo_manifest_not_workspace_root` / `cargo_manifest_patch_source_alias` | `failed` | vendor / scan / get `--mode vendored` (cargo, v5.0): the workspace-root `Cargo.toml` cannot carry the vendored `[patch.crates-io]` entry (or cargo would ignore it there) — see the cargo caveat under "Vendored mode". Refused before any write. | -| `vendor_would_revert_redirect` / `vendor_takeover_reverted_redirect` | `skipped` (advisory event) | vendor / scan / get `--mode vendored` over a hosted-redirected purl (cargo and the npm family, bun included): dry run — the per-purl hosted revert was PROBED and would succeed (for bun, only after the Bun vendored preflight accepted the lock; a refused lock is previewed as the wet run's `failed ` instead) / wet run — the hosted lockfile edits were reverted to their pre-redirect registry values and the redirect-ledger record dropped before vendoring (mode takeover). Fires on the run that takes over, not on re-runs. | -| `redirect_revert_failed` | `failed` | vendor / scan / get `--mode vendored` (dry and wet): the per-purl hosted revert refused (drifted lock, missing original fragment, an undecidable ledger edit) — nothing vendored for the purl, hosted wiring left in place, exit 1 `partial_failure`; the detail names the remedy (for bun: an unscoped `socket-patch rollback`). | +| `vendor_would_revert_redirect` / `vendor_takeover_reverted_redirect` | `skipped` (advisory event) | vendor / scan / get `--mode vendored` over a hosted pin (every ecosystem, v5.0): dry run — the upstream restore was resolved (registry lookups included) and would succeed (for bun, only after the Bun vendored preflight accepted the lock; a refused lock is previewed as the wet run's `failed ` instead) / wet run — the pin's lock entries were restored to their upstream registry entry before vendoring (mode takeover; detail ` was hosted; restored its upstream registry entry () before vendoring (mode takeover)`), so `vendor --revert` later returns to upstream. Fires on the run that takes over, not on re-runs. | +| `redirect_revert_failed` | `failed` | vendor / scan / get `--mode vendored` (dry and wet): the upstream restore of a hosted pin was refused (`--offline`, a registry that does not answer, a lock shape the restore refuses — for `bun.lockb`, a record the codec cannot rebuild) — detail `cannot vendor over the live hosted pin: cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)`; nothing vendored for the purl, hosted wiring left in place, exit 1 `partial_failure`. | +| `patch_fetch_failed` (eject) | `failed` | vendor eject (v5.0): a hosted pin's patch record could not be fetched from `…/patches/view/`; the whole eject is refused (`eject_refused`), nothing touched, exit 1. | +| `eject_refused` | top-level `errorCode` (`status: "error"`) | vendor eject (v5.0): a record fetch failed or a pin's upstream restore was refused while planning; nothing was changed, exit 1. | +| `eject_planned` | `applied` (reason) | vendor eject `--dry-run` (v5.0): the pin would be restored upstream and vendored; nothing written. | +| `eject_rolled_back` | warning | vendor eject (v5.0): a package failed after the restore began; every touched file was put back from the pre-eject snapshot, so the project is still hosted; `partial_failure`, exit 1. | +| `eject_rollback_failed` | top-level `errorCode` | vendor eject (v5.0): putting the pre-eject snapshot back failed; the detail names the files to `git checkout --`; exit 1. | +| `offline_eject_unavailable` | top-level `errorCode` | vendor eject under `--offline` / `SOCKET_OFFLINE` (v5.0): records and registry entries cannot be fetched offline; zero network requests, nothing touched, exit 1. | +| `hosted_wiring_contested` | top-level `errorCode` (list: warning when it can still list) | rollback / remove / vendor eject / list (v5.0): a lockfile mentions a recognized hosted patch uuid that discovery rejected (or a pin with no lockfile), so the hosted set is not known exactly; refused with nothing touched, exit 1. Remedy: fix or `git checkout` the named lockfile. | | `vendor_yarn_berry_cache_unsupported` | `failed` | vendor (yarn berry): lock `cacheKey ≠ 10c0` or non-default `.yarnrc.yml` `compressionLevel` — the cache-zip checksum is not reproducible. | -| `vendor_yarn_berry_mixed_line_endings` | `failed` | vendor (yarn berry): `yarn.lock` or the root `package.json` mixes CRLF and LF line endings (or holds a bare CR) — no single ending can be kept, and yarn rewrites such a file wholesale on its next install (a mixed lock also fails `--immutable`, YN0028). Refused before any write; `yarn install` normalizes the files. A uniformly CRLF pair is vendored in CRLF. A hosted→vendored takeover (`vendor`, `scan`/`get --mode vendored`) raises this — and the berry `vendor_yarn_berry_cache_unsupported` gates — BEFORE reverting the hosted redirect (dry run too), so a refused purl stays hosted. | +| `vendor_yarn_berry_mixed_line_endings` | `failed` | vendor (yarn berry): `yarn.lock` or the root `package.json` mixes CRLF and LF line endings (or holds a bare CR) — no single ending can be kept, and yarn rewrites such a file wholesale on its next install (a mixed lock also fails `--immutable`, YN0028). Refused before any write; `yarn install` normalizes the files. A uniformly CRLF pair is vendored in CRLF. A hosted→vendored takeover (`vendor`, `scan`/`get --mode vendored`) raises this — and the berry `vendor_yarn_berry_cache_unsupported` gates — BEFORE restoring the hosted pin's upstream entry (dry run too), so a refused purl stays hosted. | | `vendor_override_conflict` | `failed` | vendor (pnpm/yarn-berry): a user-authored override/resolution for the package already exists. | | `vendor_integrity_unverified` | `skipped` (warning) | vendor (pipenv): the lockfile format does not hash-check file entries; the committed wheel bytes are the protection. | | `vendor_content_mismatch_overwritten` | `skipped` (warning) | vendor: a staged file matched NEITHER beforeHash nor afterHash (patch built against different bytes, or local edits); the stage was overwritten with the verified patched content and the vendor succeeded. | -| `vendor_fetched_missing` | `skipped` (warning) | vendor: the package was not installed; its pristine artifact was fetched per the lockfile resolution (or staged from the committed vendor artifact), integrity-verified, and vendored — the project tree was not touched. Not emitted when no fetch happened: an in-sync re-run of a ledger-covered purl, or a cargo crate the patch service served (see Vendor auto-fetch § Deferred fetch). For `poetry.lock` (which records hashes but no URLs) the pure-Python wheel's sha256 selects the file through PyPI's JSON API (`SOCKET_PYPI_JSON_API` overrides the endpoint); Poetry 0.12's bare `[metadata.hashes]` names no wheel, so those locks still need an installed copy (`vendor_fetch_unverifiable`). | +| `vendor_fetched_missing` | `skipped` (warning) | vendor: the package was not installed; its pristine artifact was fetched per the lockfile resolution (or staged from the committed vendor artifact), integrity-verified, and vendored — the project tree was not touched. Not emitted when no fetch happened: an in-sync re-run of a ledger-covered purl, or an npm, cargo, golang or composer package the patch service served (see Vendor auto-fetch § Deferred fetch). For `poetry.lock` (which records hashes but no URLs) the pure-Python wheel's sha256 selects the file through PyPI's JSON API (`SOCKET_PYPI_JSON_API` overrides the endpoint); Poetry 0.12's bare `[metadata.hashes]` names no wheel, so those locks still need an installed copy (`vendor_fetch_unverifiable`). | | `vendor_fetch_failed` | `failed` | vendor: the lockfile-resolved fetch was attempted and failed (HTTP error, size cap, integrity mismatch, or a PRESENT-but-corrupt committed artifact — pointed at `socket-patch repair`). A MISSING committed artifact no longer lands here: it falls through to the ledger-recovered registry fetch. Suppresses the duplicate `package_not_installed` skip. | | `vendor_fetch_unverifiable` | `skipped` (warning) | vendor: the lockfile records no usable integrity for the missing package; nothing was fetched (fail-closed) and the `package_not_installed` skip follows. Unchanged for gems by the build-mode `gem_spec_missing` refusal below, which fires only where a fetch WOULD have run. | | `vendor_vlt_transitive_unsupported` | `failed` | vendor (vlt): the target has an inbound edge from another package in `vlt-lock.json` (the detail names it); vendored mode rewires only direct dependencies of the root or a workspace member, because vlt silently reverts transitive lock surgery. Remedy: `--mode hosted`. Refused before any download or write, dry runs included (`would_refuse`). | | `vendor_vlt_lock_out_of_sync` | `failed` | vendor (vlt): an importer's `package.json` is missing, unparseable, or declares a spec for the dependency that differs from the lock's importer edge. Remedy: `vlt install` first. Refused before any write. | | `vendor_vlt_build_scripts_unsupported` | `failed` | vendor (vlt): the package declares a `preinstall`, `install`, `postinstall` or `prepare` script, or ships a `binding.gyp`. vlt builds a registry copy in the untracked store, but a vendored `file:` dependency in place, so `vlt build` would rewrite the committed artifact (a platform binary over a JS shim, say) and every later vendor, repair and `vex` would treat it as tampered. Remedy: `--mode hosted`. Refused before any write. | | `vendor_vlt_legacy_lockfile` | `skipped` (warning) | vendor (vlt): an era-A lock (vlt 0.0.0-19 … 1.0.0-rc.8): a `··` default-registry id, or default-registry ids that are URL segments equal to a scalar `options.registry` with no `·npm·` id (era B writes `·npm·` whatever the scalar). vlt 0.0.0-31 … 1.0.0-rc.5 install the vendored lock but fail to reinstall the vendored `file:` dependency if `vlt-lock.json` is deleted and re-created (the other era-A releases reinstall it; the lock does not say which release reads it). The package is still vendored; remedy: upgrade vlt. | -| `vendor_vlt_reinstall_required` | `skipped` (advisory; human: `Warning (vendor_vlt_reinstall_required): …`) | vendor / scan / get `--mode vendored` (vlt), wet and dry runs, and in-sync reruns: (a) the run rewires an optional dependency, or an importer's `node_modules/` of an optional dependency still resolves into `node_modules/.vlt/`: from vlt 0.0.0-30 a plain `vlt install` (1.2.0: also `--force`) keeps that installed upstream copy linked; the detail says to run `vlt ci` (or delete `node_modules` and run `vlt install`) to link the vendored copy, and that vlt 0.0.0-30 … 1.0.4 install no optional dependency from the lock of a project that declares only optional dependencies (upgrade to 1.0.5 or later first); (b) otherwise, an importer's link of the dependency still resolves into `node_modules/.vlt/`: the detail names the links (`node_modules/`, `/node_modules/`) and says `vlt install` (or `vlt ci`) links the vendored copy — on a warm tree after a plain `vlt install` that is true of every vendored direct dependency; (c) an importer's link resolves into the vendored dir of the patch this run replaces (a new patch uuid), which the run removes: the detail names the links and says `vlt install` (or `vlt ci`) links the new vendored copy; (d) a rebuild of the payload (vendor, or `repair` after a corrupt or missing payload) could not keep vlt's links to the package's own dependencies (its old `node_modules/` held more than links): the detail says to run `vlt ci` (or delete `node_modules` and run `vlt install`), since a plain `vlt install` does not re-link them. `repair` moves those links back into the rebuilt payload when they are only links. The package is vendored either way; a run whose patch fails to apply emits neither. A wet `vendor --revert` (and the revert a vendored → hosted takeover runs, whose advisory joins `redirect.warnings[]`): (a) the revert moves an `optionalDependencies` spec back from the `file:` dir, or an optional importer's `node_modules/` still resolves into the vendored uuid dir: from vlt 0.0.0-30 a plain `vlt install` keeps that link (dangling once the dir is removed), so the detail says to run `vlt ci` (or delete `node_modules` and run `vlt install`) to link the restored copy, with the same vlt 1.0.5 note; (b) otherwise, an importer's link still resolves into the vendored uuid dir: the detail names the links and says `vlt install` (or `vlt ci`) links the restored copy. A dry-run revert emits neither. | +| `vendor_vlt_reinstall_required` | `skipped` (advisory; human: `Warning: …`) | vendor / scan / get `--mode vendored` (vlt), wet and dry runs, and in-sync reruns: (a) the run rewires an optional dependency, or an importer's `node_modules/` of an optional dependency still resolves into `node_modules/.vlt/`: from vlt 0.0.0-30 a plain `vlt install` (1.2.0: also `--force`) keeps that installed upstream copy linked; the detail says to run `vlt ci` (or delete `node_modules` and run `vlt install`) to link the vendored copy, and that vlt 0.0.0-30 … 1.0.4 install no optional dependency from the lock of a project that declares only optional dependencies (upgrade to 1.0.5 or later first); (b) otherwise, an importer's link of the dependency still resolves into `node_modules/.vlt/`: the detail names the links (`node_modules/`, `/node_modules/`) and says `vlt install` (or `vlt ci`) links the vendored copy — on a warm tree after a plain `vlt install` that is true of every vendored direct dependency; (c) an importer's link resolves into the vendored dir of the patch this run replaces (a new patch uuid), which the run removes: the detail names the links and says `vlt install` (or `vlt ci`) links the new vendored copy; (d) a rebuild of the payload (vendor, or `repair` after a corrupt or missing payload) could not keep vlt's links to the package's own dependencies (its old `node_modules/` held more than links): the detail says to run `vlt ci` (or delete `node_modules` and run `vlt install`), since a plain `vlt install` does not re-link them. `repair` moves those links back into the rebuilt payload when they are only links. The package is vendored either way; a run whose patch fails to apply emits neither. A wet `vendor --revert` (and the revert a vendored → hosted takeover runs, whose advisory joins `redirect.warnings[]`): (a) the revert moves an `optionalDependencies` spec back from the `file:` dir, or an optional importer's `node_modules/` still resolves into the vendored uuid dir: from vlt 0.0.0-30 a plain `vlt install` keeps that link (dangling once the dir is removed), so the detail says to run `vlt ci` (or delete `node_modules` and run `vlt install`) to link the restored copy, with the same vlt 1.0.5 note; (b) otherwise, an importer's link still resolves into the vendored uuid dir: the detail names the links and says `vlt install` (or `vlt ci`) links the restored copy. A dry-run revert emits neither. | | `vendor_flavor_changed` | `failed` | vendor (npm): the purl's vendor ledger entry was written for another lockfile `flavor` than the one the router now detects (for example `npm` → `vlt` after switching package managers). Remedy: `socket-patch vendor --revert` it first, then re-vendor. Refused before any write. | | `vendor_artifact_gitignored` | `failed` | vendor (vlt): inside a git work tree, `git check-ignore --no-index` reports the new artifact's uuid directory as ignored by a rule its own `.gitignore` cannot override (such as a root `.socket/` rule; the detail names the rule). Remedy: drop that rule for `.socket/vendor/`. Refused before any write. | | `vendor_artifact_gitignore_unchecked` | warning | vendor (vlt): git is installed but could not answer the ignore check for the written vendored directory (it failed to start, ran past 30 s, or `rev-parse` / `check-ignore` exited with an error); the package is vendored and the detail names what failed. Remedy: make sure no ignore rule covers `.socket/` before committing. Git absent, or a project outside any work tree, raises nothing. | -| `vendor_ledger_entry_missing` | `failed` | vendor (vlt): the only installed copy is vlt's link to a committed vendored directory, but the vendor ledger has no entry for the package; run `socket-patch repair` to restore it. Replaces the `package_not_installed` skip. | +| `vendor_ledger_entry_missing` | `failed` | vendor (vlt): the only installed copy is vlt's link to a committed vendored directory, but the vendor ledger has no entry for the package; restore `.socket/vendor/state.json` from version control (v5.0: `repair` no longer re-synthesizes it). Replaces the `package_not_installed` skip. | | `vendor_artifact_missing` | `skipped` (warning) / `failed` | vendor: the committed artifact is gone — the registry resolution is recovered from the ledger and the artifact rebuilt (warning); repair `--offline` with no local source surfaces it as the per-entry failure instead. | | `vendor_artifact_corrupt` | `failed` | repair `--offline`: the committed artifact fails verification (member afterHashes or the ledger's whole-file sha256) and no local source can rebuild it. Online repairs rebuild instead. | | `vendor_artifact_reused` | `skipped` (verbose note) | vendor / scan `--vendor` (pypi): the wiring was dropped by a relock but the committed wheel the ledger vouches for verified, so it was re-wired as-is — no service download, no rebuild; the lock pins the first run's sha again. | @@ -1327,7 +1354,6 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `redirect_vlt_no_lockfile` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (vlt): `vlt.json` or vlt's install state is present without `vlt-lock.json`; replaces `redirect_npm_no_lockfile` for vlt projects. | | `redirect_vlt_artifact_unverifiable` | `redirect.warnings[]` (warning), `redirect.skipped[].reason` | scan/get `--mode hosted` (vlt): before any takeover or rewrite (dry runs included), each granted artifact with a default-registry instance in `vlt-lock.json` (or, for a purl a `flavor: "vlt"` vendored entry claims, its vendored node, probed before the takeover reverts it) is fetched once as vlt fetches it (`accept-encoding: gzip;q=1.0, identity;q=0.5`, no `Authorization`, up to 10 redirects) and must return 200 with no content encoding (or `identity`) and the granted sha512. On failure (`content-encoding `, `sha512 mismatch`, `http `, `fetch error `, `offline`) the dep is withheld from every rewriter when vlt drives or it is vlt-vendored (which also keeps it vendored), and from the vlt rewrite only otherwise (detail "…; vlt-lock.json was not changed for {purl}"; only the sibling lock this run rewrote can confirm it). A lock already pinned by an earlier run is left pinned, and neither confirmed nor attested. Projects without `vlt-lock.json` make no such request. The detail quotes the artifact URL (and any fetch error that echoes it) with its grant-token path level, the one just before the patch uuid, spelled ``; host, uuid and leaf stay. The in-memory hosted engine (`hosted-bundle`, the Node addon) has no network for this fetch, so it judges every in-scope artifact as `--offline` does (withheld, never pinned; the vendored takeover it refuses anyway). Exit 0. | | `redirect_vlt_reinstall_required` | `redirect.warnings[]` (advisory); rollback/remove `warnings[]` (+ human stderr) | vlt: `vlt-lock.json` pins (or, after rollback/remove, no longer pins) Socket-patched packages, and vlt never refreshes an installed copy. The heal removes `node_modules/.vlt-lock.json` and each stale `node_modules/.vlt/` of a Socket-owned node (never a link's target, never outside the project, never a copy it cannot judge) unless `--no-vlt-install-cleanup` or `--dry-run`. It never removes an optional node's copy (lock flags 1 or 3, or flags it cannot read): `vlt install` does not put a removed optional dependency back (its link dangles) unless the same install also reinstalls a non-optional node, so such a copy is left stale and the detail says to run `vlt ci` (or delete `node_modules` and run `vlt install`); vlt 0.0.0-30 … 1.0.4 install no optional dependency from the lock of a project that declares only optional dependencies, so there both commands remove the installed copy and the detail says to upgrade vlt to 1.0.5 or later first. The detail says whether copies were removed, left stale by a skipped cleanup, could not be checked, or none were stale, and adds how many optional copies were kept whenever there are any. The kept optional copies are named by what they are: `unpatched copies of optional dependencies` after `scan`/`get`, `patched copies of optional dependencies` after `rollback`/`remove`, and `installed copies of the vendored optional dependencies` after a hosted → vendored takeover (the copy the hosted pin left installed, which may still be the registry bytes). Stale or unchecked copies are not attested by the run's `--vex`, nor is a confirmed vlt pin the heal did not check (a URL on a host other than patch.socket.dev and the configured `--patch-server-url`/`--api-url`). A hidden lock that cannot be removed keeps every store entry. Invalidation failures only warn. | -| `vlt_root_scripts_not_run` | setup `warnings[]` entry `vlt_root_scripts_not_run: ` (advisory; human: `Warning (vlt_root_scripts_not_run): ` on stderr, muted by `--silent`) | setup (vlt project, npm in scope, every non-`no_files` run incl. `--dry-run` and already-configured): vlt before 1.0.0-rc.13 never runs a root `postinstall`, so the wired hook would not fire. Definite when the `vlt` on `PATH` (absolute entries only; spawned with `VLT_TELEMETRY=0`, 5 s budget) reports a semver below 1.0.0-rc.13 (the detail ends in "(`vlt --version` reports )"); an unparseable `--version` never warns. Otherwise it is a "may" when `vlt-lock.json` has `lockfileVersion` `0` or none, a lock the reported `vlt` would not write: always without a usable `vlt` (absent, non-zero exit, timeout); with one at or above 1.0.0-rc.13, for a lock with no `lockfileVersion`, and for a `0` lock when it reports 1.0.0-rc.15 or later (which refuses a v0 lock, so another vlt installs the project). Remedy: upgrade vlt or run `socket-patch apply` after `vlt ci`. The hook is still written. | | `vendor_prebuilt_stub_invalid` | `failed` / `skipped` (warning) | vendor (gem, `--vendor-source`): the served stub gemspec fails the rubygems `summary`/`authors` bar, so bundler would refuse the vendored path source at install time. `service`: refusal naming the missing attributes; `auto`: loud warning + local-build fallback — or, when the gem is also not installed locally (no stub to derive), a refusal naming the served defect and the install-the-gem remedy. | | `gem_spec_invalid` | `failed` | vendor (gem): the LOCAL `specifications/` stub gemspec fails the same rubygems `summary`/`authors` bar (a corrupted or hand-edited gem home); the refusal names the file — reinstall the gem (`gem pristine ` / fresh `bundle install`). | | `vendor_*` / `pypi_*` / `gemfile_*` / `lock_*` / `locked_version_mismatch` / `user_authored_*` / `native_extensions_unsupported` / `platform_gem_unsupported` | `failed`/`skipped` | vendor: per-ecosystem refusal + drift vocabulary; see the Vendor command contract section. New tags are additive (MINOR). | @@ -1336,11 +1362,12 @@ Every `--json` invocation emits a single JSON object that follows the **unified | Code | Subcommands | Meaning | |-----------------------|----------------------------------|---------| -| `manifest_not_found` | list, remove, repair, rollback, vex | `.socket/manifest.json` doesn't exist. For `vex` (and `scan --vex`) it fires only when, in addition, NOTHING else names a patch — no redirect-ledger record, no vendor-ledger entry, no lockfile reference — and the message says so (exit 2 standalone; `apply`/`vendor --vex` treat it as their calm no-op). v3.5: `repair` proceeds anyway (vendored phase only) when a vendor ledger or vendor-path lockfile references exist, and exits 0 with a `redirect_only_project` skip (not this error) when the only `.socket/` trace is a hosted-mode `redirect-state.json`. `list` likewise no longer fires this on a hosted-only project: when the hosted redirect ledger holds ≥ 1 `records` entry, the records are listed (exit 0, labeled `details.mode: "hosted"` + `details.ledger`; when the manifest exists too, both stores are shown, purl-sorted with the manifest entry first on a tie). v5.0: `list` reads the vendor ledger the same way — a vendored-only project (every `scan`/`get --mode vendored` project) lists its ledger entries' embedded records labeled `Mode: vendored (recorded in .socket/vendor/state.json)` in human mode — the twin of the hosted `Mode: hosted (recorded in .socket/vendor/redirect-state.json)` line — (`details.mode: "vendored"` + `details.ledger: ".socket/vendor/state.json"` in JSON), exit 0. A standalone-`vendor` entry's fallback `record` lists the same way once no manifest entry covers it (by ledger key or base purl) — the copy manifest-less `vex` attests from, so `list` never reports `manifest_not_found` for a tree whose VEX document attests a patch; while the manifest covers it, only the manifest entry is listed. All stores always come from the SAME project: the ledger is resolved against the root the RESOLVED manifest path implies (its `.socket` parent's parent in the standard layout, else the manifest file's directory — exactly `--cwd` for the default path), so `--manifest-path` into another project reads that project's ledger, never the local one. The error still fires when NONE of the three stores has a record — an edits-only ledger asserts no patches — and a present-but-broken manifest still reports `manifest_invalid`/`manifest_unreadable` regardless of ledger records (corruption is never masked). A malformed ledger degrades to "nothing to consult" with a stderr warning, muted by `--silent` (read-only consumer posture; the hosted write path hard-errors instead); `list --json` carries it in the run-level `warnings[]` as `redirect_ledger_corrupt` instead of on stderr. v5.0: `rollback` likewise proceeds manifest-less when the vendor ledger or the redirect ledger holds work (its error is the legacy `{status: "error", error: "Manifest not found", path}` shape, not this envelope code); only the truly-empty project — all three stores absent — keeps the exit-1 error, and a project whose lockfiles still reference `.socket/vendor/` artifacts with NO vendor ledger gets a distinct error naming `socket-patch repair`. `remove` (v5.0) proceeds manifest-less whenever a vendor OR redirect ledger file exists (two existence probes before the lock; the stores themselves load under it): ANY vendor-ledger entry matching the identifier — detached or not — is removed through the ledger path (`--preserve-state` and drift-keeps behave exactly as on the manifest path), a hosted-only match unwinds its redirect, and when the ledgers exist but hold nothing for the identifier the error is `not_found` (exit 1), not this code — `manifest_not_found` fires from `remove` only when all three stores are absent. Manifest entries are removed in sorted purl order. | +| `manifest_not_found` | remove, repair, rollback, vex (not `list` since v5.0: a missing manifest is an empty list) | `.socket/manifest.json` doesn't exist. For `vex` (and `scan --vex`) it fires only when, in addition, NOTHING else names a patch — no vendor-ledger entry, no lockfile reference (hosted or vendored) — and the message says so (exit 2 standalone; `apply`/`vendor --vex` treat it as their calm no-op). v3.5: `repair` proceeds anyway (vendored phase only) when a vendor ledger or vendor-path lockfile references exist, and exits 0 with a `redirect_only_project` skip (not this error) when the project's only patch state is hosted pins in its lockfiles (v5.0; or a pre-v5 `redirect-state.json`). `list` likewise no longer fires this on a hosted-only project: v5.0 lists every hosted pin the lockfiles wire (exit 0, labeled `details.mode: "hosted"` + `details.lockfiles: []` — no `details.ledger`, since hosted mode keeps none; when the manifest exists too, both are shown, purl-sorted with the manifest entry first on a tie). A pin carries its uuid and empty details unless a pre-v5 redirect ledger records the same purl and uuid (read for migration only: its record supplies the vulnerabilities / tier / description); a pre-v5 ledger record whose pin is in no lockfile is not listed. v5.0: `list` reads the vendor ledger the same way — a vendored-only project (every `scan`/`get --mode vendored` project) lists its ledger entries' embedded records labeled `Mode: vendored (recorded in .socket/vendor/state.json)` in human mode — the twin of the hosted `Mode: hosted (wired in )` line — (`details.mode: "vendored"` + `details.ledger: ".socket/vendor/state.json"` in JSON), exit 0. A standalone-`vendor` entry's fallback `record` lists the same way once no manifest entry covers it (by ledger key or base purl) — the copy manifest-less `vex` attests from, so `list` never reports `manifest_not_found` for a tree whose VEX document attests a patch; while the manifest covers it, only the manifest entry is listed. All sources always come from the SAME project: the vendor ledger and the lockfiles are resolved against the root the RESOLVED manifest path implies (its `.socket` parent's parent in the standard layout, else the manifest file's directory — exactly `--cwd` for the default path), so `--manifest-path` into another project reads that project's state, never the local one. The error still fires when NONE of the three sources has a patch, and a present-but-broken manifest still reports `manifest_invalid`/`manifest_unreadable` regardless (corruption is never masked). A malformed pre-v5 redirect ledger degrades to "nothing to consult" with a stderr warning, muted by `--silent` (the pins still list); `list --json` carries it in the run-level `warnings[]` as `redirect_ledger_corrupt` instead of on stderr. v5.0: `rollback` likewise proceeds manifest-less when the vendor ledger or the lockfiles' hosted pins hold work (its error is the legacy `{status: "error", error: "Manifest not found", path}` shape, not this envelope code); only the truly-empty project — no manifest, no vendor ledger, no hosted pin (a lone pre-v5 redirect ledger is deleted, exit 0) — keeps the exit-1 error, and a project whose lockfiles still reference `.socket/vendor/` artifacts with NO vendor ledger gets a distinct error naming `socket-patch repair`. `remove` (v5.0) proceeds manifest-less whenever a vendor ledger file exists or the lockfiles pin a hosted patch (an existence probe and the read-only hosted-pin discovery before the lock; the vendor ledger loads under it): ANY vendor-ledger entry matching the identifier — detached or not — is removed through the ledger path (`--preserve-state` and drift-keeps behave exactly as on the manifest path), a hosted-only match restores its upstream registry entry, and when that state exists but holds nothing for the identifier the error is `not_found` (exit 1), not this code — `manifest_not_found` fires from `remove` only when all three sources are empty. Manifest entries are removed in sorted purl order. | | `manifest_invalid` | list, remove | Manifest exists but is unparseable. | | `manifest_unreadable` | list, remove, vex | I/O error reading manifest (vex: also an unparseable manifest; exit 2). | -| `no_patches` | vex | The manifest file exists but is empty AND no ledger record or lockfile reference names a patch (exit 1). | -| `redirect_ledger_corrupt` / `vendor_ledger_corrupt` | vex (every form) | `.socket/vendor/redirect-state.json` / `.socket/vendor/state.json` exists but is malformed or unreadable. Both ledgers are attestation inputs (records and liveness), so attesting from a partial view is refused (exit 2 standalone; the host command fails). A missing ledger is simply empty. | +| `no_patches` | vex | The manifest file exists but is empty AND no vendor-ledger record or lockfile reference names a patch (exit 1). | +| `vendor_ledger_corrupt` | vex (every form) | `.socket/vendor/state.json` exists but is malformed or unreadable. The vendor ledger is an attestation input (records and liveness), so attesting from a partial view is refused (exit 2 standalone; the host command fails). A missing ledger is simply empty. | +| `redirect_ledger_corrupt` | vex, list (`warnings[]`) | v5.0: a WARNING, no longer an error — a pre-v5 `.socket/vendor/redirect-state.json` exists but is malformed or unreadable. v5 hosted mode keeps no ledger (hosted references come from the lockfiles, their records from the API), so the file is only an optional migration record source: its records are not consulted and the run continues. Delete the file or restore it from version control. | | `serialize_failed` | vex | The built document could not be serialized (exit 2). | | `apply_failed` | apply | apply pipeline error before any patch ran. | | `repair_failed` | repair | repair pipeline error. | @@ -1352,8 +1379,8 @@ Every `--json` invocation emits a single JSON object that follows the **unified |--------------|---| | `apply` | `Applied` · `Updated` · `Skipped` (already_patched / package_not_installed / vendored) · `Failed` · `Verified` (dry-run) | | `vendor` | `Applied` (= vendored; `command` routes) · `Skipped` (refusals, warnings, unsupported ecosystems) · `Failed` · `Removed` (reconcile + `--revert`) · `Verified` (dry-run) | -| `list` | `Discovered` (with `details.vulnerabilities`, `details.tier`, `details.license`, `details.description`, `details.exportedAt`; hosted redirect-ledger records additionally carry `details.mode: "hosted"` — the constant mode name, whatever opaque mode string the ledger itself carries — and `details.ledger: ".socket/vendor/redirect-state.json"`, both additive and absent on manifest entries; v5.0: vendor-ledger records carry `details.mode: "vendored"` + `details.ledger: ".socket/vendor/state.json"` the same way, and the human listing labels them `Mode: vendored (recorded in .socket/vendor/state.json)`; a `state.json` that cannot be read or parsed degrades to nothing-to-consult with the stderr line `Warning: unreadable vendor ledger (); its vendored patches are not listed` — muted by `--silent`, exit unchanged) | -| `repair`/`gc`| `Downloaded` (or `Verified` on dry-run) · `Rebuilt` (vendored artifacts; `Verified` previews on dry-run) · `Skipped` (vendor_uuid_mismatch) · `Removed` (or `Verified`) · `Failed` events | +| `list` | `Discovered` (with `details.vulnerabilities`, `details.tier`, `details.license`, `details.description`, `details.exportedAt`; hosted pins (v5.0: one per `(purl, uuid)` the lockfiles wire) additionally carry `details.mode: "hosted"` and `details.lockfiles: []` (no `details.ledger` — hosted mode keeps no ledger; the human listing labels them `Mode: hosted (wired in )`), both additive and absent on manifest entries; v5.0: vendor-ledger records carry `details.mode: "vendored"` + `details.ledger: ".socket/vendor/state.json"` the same way, and the human listing labels them `Mode: vendored (recorded in .socket/vendor/state.json)`; a `state.json` that cannot be read or parsed degrades to nothing-to-consult with the stderr line `Warning: unreadable vendor ledger (); its vendored patches are not listed` — muted by `--silent`, exit unchanged) | +| `repair`/`gc`| `Downloaded` (or `Verified` on dry-run; a diff-mode repair adds a second one, `mode: "file"`, for the blobs of files the patches create) · `Rebuilt` (vendored artifacts; `Verified` previews on dry-run) · `Skipped` (vendor_uuid_mismatch) · `Removed` (or `Verified`) · `Failed` events | | `remove` | `Removed` (per purl; `Verified` on dry-run) · artifact-level `Removed`/`Verified` event (with `details.blobsRemoved`, `details.rolledBack`) | | `--update` | `Downloaded` → `Updated` (success) · `Skipped` (already_latest) · `Verified` (dry-run check, reason update_check) — see the Self-update contract section for details fields and top-level error codes | @@ -1372,7 +1399,6 @@ The remaining commands still emit their pre-v3.0 ad-hoc JSON shapes and will mig - ⏳ `scan` — still emits the discovery + `apply.patches[*]` + `gc.*` shape documented in earlier drafts of this file. - ⏳ `get` — still emits per-patch action arrays. - ⏳ `rollback` — still emits per-package result records. Additive (v3.5): a manifest entry with no matching installed package appears in `results[]` as a marker record `{ "purl", "path": null, "skipped": "package_not_installed" }` — no `success`/`error` keys, never counted in `rolledBack`/`failed`, never flips the status or exit code (rollback's job is "make the tree unpatched"; a not-installed package already satisfies that end state, deliberately asymmetric with apply's exit-1-on-unmatched). v5.0 keeps that legacy shape and adds the ALWAYS-PRESENT keys `warnings[]` (`{code, detail}` objects, now populated), `vendored` (meaning narrowed — MAJOR), `vendoredReverted`, `vendoredPreserved`, `vendoredKept` (`{purl, reason}`), `hosted` (`{reverted, failed: [{purl, error}], unsupported, editedFiles}`), `manifest` (`{removedEntries, preserved}`), `gc` (`{skipped: true}` \| `{removedBlobs, removedDiffArchives, removedPackageArchives, bytesFreed}`), and `paths` — full key semantics and exit rules in the [Rollback command contract](#rollback-command-contract-v50). -- ⏳ `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). One command is **intentionally not** plain-envelope and will stay that way (not migration debt): @@ -1466,21 +1492,44 @@ as raw strings, they sort by weekday name. A package can have several available patches; the manifest holds one record per PURL, so exactly one is chosen. Both `get` and every `scan` mode rank candidates identically (`socket_patch_core::api::ranking`), -best first: - -1. **Severity** — `critical > high > medium = moderate > low > (unknown)`, - taken as the worst severity across everything the patch fixes. -2. **Merge state** — a patch that remediates *more* advisories in one blob - leads. Inferred, not flagged: see below. -3. **Patch publish date**, most recent first — when the *patch* was - published, never the upstream package's release date. Unparseable or - absent dates sort last. -4. `tier` (paid first), then `uuid` — tiebreaks only, present so the +best first (v5.0, MAJOR): + +1. **Merged patches** (a patch naming ≥ 2 advisories; inferred, see + below), **newest first, whatever their severity**. A merged patch is + the cumulative fix for its package, so the most recent one wins + outright — even against a newer single-advisory `critical` patch. +2. **Everything else** — by **severity** (`critical > high > medium = + moderate > low > (unknown)`, the worst severity across everything the + patch fixes), then **patch publish date**, most recent first. +3. `tier` (paid first), then `uuid` — tiebreaks only, present so the order is total and therefore reproducible across runs. -`tier` is an **access filter, not a ranking signal**: a free `critical` -patch outranks a paid `low` one. Paid patches are excluded outright for -callers whose `canAccessPaidPatches` is false. +"Publish date" is when the *patch* was published, never the upstream +package's release date; unparseable or absent dates sort last. + +`tier` is an **access filter, not a ranking signal**: paid patches are +excluded before ranking for callers whose `canAccessPaidPatches` is +false, so the winner is the best patch the account can download. + +`scan`'s `[UPDATE]` marker and `updates[]` use the same order +(`ranking::search_result_supersedes`, v5.0): a candidate supersedes the +applied patch only on a meaningful rung — merged over unmerged, higher +severity between unmerged patches, or a real, strictly later publish +date. The tier and uuid tiebreaks and a missing date never count. Every +mode that fetches the by-package records (hosted, vendored, agent, and +every human run with a downloadable patch) judges this on those records, +so `updates[]` lists exactly the UPGRADE rows the run acts on; a package +the by-package lookup returns no offer for, and a JSON report-only run +(which fetches no by-package records), fall back to the batch records +(`ranking::batch_supersedes`). When the selection does not supersede the +recorded patch, scan keeps the recorded one (v5.0): a re-scan never swaps +an applied patch for an equal sibling. + +This order picks one patch **per package**. Which packages a capped scan +patches first is a separate, cross-package order (`rollout::rollout_cmp`, +see "Per-run limit on new patches"): severity of the selected patch, then +advisory count, then ecosystem, base purl and uuid — never the publish +date. #### Merge state is inferred, not reported @@ -1496,27 +1545,7 @@ Advisories are counted, **not** CVE ids: one advisory routinely carries several CVE aliases, and counting those would inflate a single-fix patch into a phantom merged one. -As of 2026-08-05 production publishes no merged patches — all 28 patches -sampled across npm/PyPI/gem/cargo covered exactly one advisory each — so -this rung is currently inert and ranking falls through to recency. The -moment a consolidated patch is published it is preferred automatically, -with no client *or* server change. - -#### Why severity sits above merge state - -The merged patch is the general preference: it fixes the most in one -shot, and only one patch per PURL can be applied, so breadth is what an -operator wants. But it must never shadow a *worse* vulnerability. If a -patch addresses a higher-severity advisory than anything the merged patch -covers, that one wins — you do not leave a critical unfixed to pick up -two extra mediums. Severity on the top rung expresses exactly that, -because a patch's severity is the worst advisory it fixes: - -| merged patch | rival patch | winner | why | -|---|---|---|---| -| high | critical | rival | higher severity available | -| critical | high | merged | merged already covers the worst | -| high | high | merged | severities tie → breadth decides | +Production published its first merged patch on 2026-09-04. This ordering is also the presentation order everywhere patches are listed — `scan --json`'s `packages[].patches[]`, `get`'s "Found @@ -1524,12 +1553,15 @@ patches:" listing, and the `selection_required` `options[]` array — so `patches[0]` for a package is the patch that would be applied, and `updates[].newUuid` names that same patch. -Free/unauthorized callers with more than one candidate for a PURL still -get the interactive picker (or `selection_required` in `--json`); the -ranking decides the presented order and hence the highlighted default, -not the outcome. `--yes` answers the picker with that default without -showing it (the same pick a non-terminal run makes); `--json` keeps -`selection_required` even with `--yes`. +`scan` never shows a picker: it always takes the top-ranked downloadable +patch. Neither does hosted or vendored `get` (v5.0): no picker, no +confirmation, no `selection_required` — the top-ranked accessible patch per +package, in `--json` too. On agent-mode `get`, free/unauthorized callers with more than one candidate +for a PURL still get the interactive picker (or `selection_required` in +`--json`); the ranking decides the presented order and hence the +highlighted default, not the outcome. `--yes` answers the picker with +that default without showing it (the same pick a non-terminal run makes); +`--json` keeps `selection_required` even with `--yes`. One additive key may appear on `scan --json`'s `packages[].patches[]` entries, omitted when absent: `publishedAt`, present whenever the server @@ -1537,13 +1569,13 @@ supplies it (the public-proxy fallback path fills it in from the per-package results). > **Known gap — batch responses without `publishedAt`.** `scan`'s -> discovery (`packages[]`, the table, `updates[]`) is built from the -> **batch** endpoint, whose response shape currently omits `publishedAt`; +> discovery (`packages[]`, and `updates[]` on a JSON report-only run) is +> built from the **batch** endpoint, whose response shape currently omits `publishedAt`; > the selection that `--apply` performs is built from the **by-package** -> endpoint, which carries it. Ranks 1, 2 and 4 agree across both, so the -> two only diverge for a package whose top candidates tie on severity -> *and* merge state — there the batch side falls through to the UUID -> tiebreak while apply correctly uses the date. +> endpoint, which carries it. The two diverge wherever the date decides — +> between merged patches, or between unmerged patches of equal severity — +> where the batch side falls through to the tier/UUID tiebreak while apply +> correctly uses the date. > > Live example: `pkg:npm/axios@1.6.0` has two free `HIGH` patches; > `packages[0].patches[0]` reports `0bc312a6…` (2026-03-27) while @@ -1557,6 +1589,14 @@ per-package results). ### `jq` recipes for PR-comment bots +Deferred by the per-run cap (`scan --max-new-patches`), most urgent first: + +```bash +socket-patch scan --json --max-new-patches 5 | jq -r ' + .rollout.deferred[] | "\(.rank). \(.purl) (\(.severity))" +' +``` + Applied + updated patches (envelope shape): ```bash @@ -1591,25 +1631,25 @@ socket-patch apply --json | jq ' Exit `0` when `status` is `success`, `noManifest`, or `notFound`-with-zero-failed. Exit `1` when `status` is `partialFailure` (any `events[*].action == "failed"`) or `error`. -`apply` with no manifest at all is a clean exit-0 no-op (`status: "noManifest"`), and an **empty** manifest (zero patches) is a plain `success` exit 0 — this is load-bearing for the install hooks, which run `apply` on every install. A fully rolled-back agent project therefore keeps `.socket/manifest.json` at `{"patches": {}}` (+ its `setup` block): the v5.0 residue rule never deletes a zero-patch manifest, precisely so these hook exits (and `list`'s 0-vs-1 below) never flip. Pinned by `tests/in_process_edge_cases.rs` and `tests/cli_dry_run_paths_e2e.rs`. **One carve-out**: a yarn-berry Plug'n'Play layout (`.pnp.*` loader at `--cwd`) refuses with the loud `yarn_pnp_unsupported` error (exit 1) even when no manifest exists — `scan` cannot discover PnP packages (they live inside `.yarn/cache/*.zip`, no `node_modules/`) and therefore never writes a manifest, so without the carve-out the documented refusal was unreachable and a PnP project's only signal was the calm noManifest exit. Pinned by `tests/e2e_safety_yarn_pnp.rs`. +`apply` with no manifest at all is a clean exit-0 no-op (`status: "noManifest"`), and an **empty** manifest (zero patches) is a plain `success` exit 0 — this is load-bearing for CI steps that run `apply` after every install. A fully rolled-back agent project therefore keeps `.socket/manifest.json` at `{"patches": {}}`: the v5.0 residue rule never deletes a zero-patch manifest, precisely so these CI exits (and `list`'s 0-vs-1 below) never flip. Pinned by `tests/in_process_edge_cases.rs` and `tests/cli/cli_dry_run_paths_e2e.rs`. **One carve-out**: a yarn-berry Plug'n'Play layout (`.pnp.*` loader at `--cwd`) refuses with the loud `yarn_pnp_unsupported` error (exit 1) even when no manifest exists — `scan` cannot discover PnP packages (they live inside `.yarn/cache/*.zip`, no `node_modules/`) and therefore never writes a manifest, so without the carve-out the documented refusal was unreachable and a PnP project's only signal was the calm noManifest exit. Pinned by `tests/e2e_safety_yarn_pnp.rs`. ## Exit codes | Code | Meaning | |---|---| | `0` | Success | -| `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`), `scan PATHS` combined with `--mode hosted`/`--mode vendored` (same enforcement point), `remove --preserve-state --skip-rollback` (the no-op quadrant; flag- or env-sourced alike), an unparseable path glob on `scan`/`rollback`, `repair --offline --download-only`. `vex` also exits `2` on hard errors before document generation (see its tri-state table below). **Carve-out**: `get`'s self-enforced conflicts have always exited `1` via its error envelope (`--id`/`--cve`/`--ghsa`/`--package` multi-select, `--one-off --save-only`) and the v3.6 `--mode hosted\|vendored --save-only` conflict deliberately follows that get-internal precedent — changing the existing ones to `2` would be a MAJOR exit-code change | +| `1` | Error (missing/invalid manifest, fetch failed, apply failed, selection cancelled in non-JSON mode, an invalid or ambiguous socket.yml on `scan` (v5.0), etc.) | +| `2` | Usage error: clap parse failures (unknown flag/value, missing required arg, an unknown subcommand such as the removed `setup`) 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`) and `--mode hosted` with `--global`/`--global-prefix` (same enforcement point); in hosted/vendored `scan` (bare `scan` included), a PATH that is not a directory, a PATH glob matching no directory, and `--json` with more than one project directory (`run_project_dirs`); `remove --preserve-state --skip-rollback` (the no-op quadrant; flag- or env-sourced alike), an unparseable path glob on `scan`/`rollback`, a `scan` PATH outside the repository root and a malformed `SOCKET_MIN_SEVERITY` or `SOCKET_MAX_NEW_PATCHES` (v5.0), `repair --offline --download-only`. `vex` also exits `2` on hard errors before document generation (see its tri-state table below). v5.0: `get`'s self-enforced conflicts exit `2` too (`--id`/`--cve`/`--ghsa`/`--package` multi-select, `--mode hosted\|vendored --save-only`, a malformed identifier for a forced `--id`/`--cve`/`--ghsa`) — previously `1` (MAJOR). The never-implemented `get --one-off` / `rollback --one-off` (and `SOCKET_ONE_OFF`) are removed in v5.0; `--one-off` is now an ordinary unknown-flag clap error. | -`list` returns **`0`** for an empty manifest and **`1`** for a missing manifest — these are distinct and load-bearing (a manifest-less project whose vendor or redirect ledger holds records is NOT "missing": `list` reads all three stores and exits 0 — see the `manifest_not_found` row). Every lock-taking subcommand — including `scan`/`get --mode hosted` as of v5.0 — returns **`1`** with `errorCode: lock_held` when another live socket-patch process holds `<.socket>/apply.lock`. +`list` returns **`0`** for every project it can read, empty or not (**v5.0, BREAKING**: a project with no manifest and no ledger record — normal for hosted mode, which writes no manifest — used to exit `1` with `manifest_not_found`; it is now an empty list: `No patches in this project. Run \`socket-patch scan\`.` on stdout, and under `--json` the success envelope with `events: []`). Only an unreadable or invalid manifest (`manifest_unreadable` / `manifest_invalid`) exits `1`. Every lock-taking subcommand — including `scan`/`get --mode hosted` as of v5.0 — returns **`1`** with `errorCode: lock_held` when another live socket-patch process holds `<.socket>/apply.lock`. `vex` exit codes are tri-state: | Code | Meaning | |---|---| | `0` | A non-empty OpenVEX document was produced | -| `1` | Nothing attested: `no_applicable_patches` (every candidate was omitted — by verification, a wiring gate, a missing record, or Property 7; the omissions ride `skipped` events) or `no_patches` (an empty manifest file and nothing wired anywhere) | -| `2` | Hard error: `manifest_not_found` (no manifest AND no ledger record / lockfile reference anywhere), `manifest_unreadable`, `redirect_ledger_corrupt`, `vendor_ledger_corrupt`, `json_requires_output`, `product_undetected`, `serialize_failed`, `write_failed` | +| `1` | Nothing attested: `no_applicable_patches` (every candidate was omitted — by verification, a wiring gate, or a missing record; the omissions ride `skipped` events) or `no_patches` (an empty manifest file and nothing wired anywhere) | +| `2` | Hard error: `manifest_not_found` (no manifest AND no vendor-ledger record / lockfile reference anywhere), `manifest_unreadable`, `vendor_ledger_corrupt` (v5.0: `redirect_ledger_corrupt` is a warning), `json_requires_output`, `product_undetected`, `serialize_failed`, `write_failed` | A missing manifest alone is not an error: a hosted or vendored checkout attests from its lockfiles (see "Manifest-less VEX"). Embedded `--vex` maps every failure to the host command's exit `1`. @@ -1630,7 +1670,7 @@ When verification is enabled (the default) and a patch is omitted, the failed PU ## Semver policy -Versioning lives in **`Cargo.toml`** at the workspace root (`version = "..."`) and is propagated to every ecosystem wrapper and launcher package by **`scripts/version-sync.sh `** (the full list of stamped files is below). +Versioning lives in **`Cargo.toml`** at the workspace root (`version = "..."`) and is propagated to the Cargo and npm packages by **`scripts/version-sync.sh `** (the full list of stamped files is below). | Change | Bump | |---|---| @@ -1662,28 +1702,47 @@ scripts/version-sync.sh This syncs the workspace package version into: -- `npm/socket-patch/package.json` (and its `optionalDependencies`) +- `Cargo.toml` (workspace version and the exact `socket-patch-core` dependency pin) +- `npm/socket-patch/package.json` (and its `optionalDependencies`) and `package-lock.json` - every per-platform `npm/socket-patch-*/package.json` -- `pypi/socket-patch/pyproject.toml` and `pypi/socket-patch-hook/pyproject.toml` -- `gem/socket-patch-bundler/socket-patch-bundler.gemspec` (the Bundler plugin gem) -- `gem/socket-patch/socket-patch.gemspec` + its launcher `VERSION` (the RubyGems CLI launcher) - -All ecosystem publishing fans out from the single -**`.github/workflows/release.yml`** dispatch: one run publishes crates.io, -npm, and PyPI plus the CLI launcher gem (`socket-patch` on RubyGems). Each -registry leg lives in its own workflow -(`.github/workflows/publish-{cargo,npm,pypi,rubygems}.yml`), dispatched at -the release tag by the release run and also independently dispatchable to -retry one registry against an existing release. The npm, PyPI, and -launcher-gem legs are gated on the GitHub release — with its binaries and -`SHA256SUMS` — existing. + +Publishing fans out from the single **`.github/workflows/release.yml`** +dispatch: one run creates a GitHub release with standalone binaries and +`SHA256SUMS`, and publishes the crates.io and npm packages. The binary is +the preferred install via `https://install.socket.dev/patch`; npm also +supplies the official Socket CLI. Each registry leg lives in its own +workflow (`.github/workflows/publish-{cargo,npm}.yml`), dispatched at the +release tag and independently dispatchable to retry one registry against +an existing release. The npm leg waits for the GitHub release so it can +package those same binaries. See [the release runbook](../../docs/releasing.md). ## How the contract is enforced Every item in this document is locked in by at least one of: - **clap parser snapshots** in `crates/socket-patch-cli/tests/cli_parse_*.rs` — assert flag names, short forms, defaults, aliases, and CSV delimiters by calling `socket_patch_cli::Cli::try_parse_from(...)`. -- **Helper unit tests** in `crates/socket-patch-cli/src/**` (`#[cfg(test)] mod tests` blocks) — cover `looks_like_uuid`, `parse_argv_with_shortcuts`, `detect_identifier_type`, `select_patches`, `find_patches_to_rollback`, `partition_purls`, `verify_status_str`, the JSON serializers, and the terminal UI in `src/ui/` (`StatusLine` redraw/clear/`println` byte streams, `confirm_with` answers and non-interactive notes, `select_one`'s JSON/empty guards, `plural`, `truncate`, the `color_enabled` truth table, `paint`/`severity`, and `pad`/`strip_ansi` alignment). -- **Async `run()` integration tests** in `tests/cli_parse_list.rs`, `tests/cli_parse_remove.rs`, `tests/cli_parse_setup.rs` — exercise the no-network error paths and assert JSON shape via `serde_json::from_str::` + per-key assertions. +- **Helper unit tests** in `crates/socket-patch-cli/src/**` (`#[cfg(test)] mod tests` blocks) — cover `looks_like_uuid`, `parse_argv_with_shortcuts`, `detect_identifier_type`, `select_patches`, `find_patches_to_rollback`, `partition_purls`, the JSON serializers, and the terminal UI in `src/ui/` (`StatusLine` redraw/clear/`println` byte streams, `confirm_with` answers and non-interactive notes, `select_one`'s JSON/empty guards, `plural`, `truncate`, the `color_enabled` truth table, `paint`/`severity`, and `pad`/`strip_ansi` alignment). +- **Async `run()` integration tests** in `tests/cli_parse_list.rs`, `tests/cli_parse_remove.rs` — exercise the no-network error paths and assert JSON shape via `serde_json::from_str::` + per-key assertions. If you add a new flag/subcommand/JSON key, add a test here that locks the new surface in the same PR. + + +### Vendored JVM support (v5) + +Maven reactors and Gradle 6.8+ route to the JVM backend automatically. Ledger +entries use ecosystem `jvm` with Maven PURLs. Revert, remove, rollback and repair +share the v5 vendored backend; existing prototype wiring remains readable. +See [the JVM design](../../docs/design/maven-vendoring.md) for supported shapes. + +`vendor --check` is an offline, read-only audit. Healthy entries emit `verified` +with `vendor_check_ok`; drift emits `failed` with `vendor_check_failed`, a +`partialFailure` envelope and exit 1. Missing ledger entries fail with +`vendor_ledger_missing`. Offline upstream metadata is reported as the run warning +`vendor_jvm_upstream_unverified`. The check never starts an API client or writes +lock/recovery files. `--check` conflicts with `--revert`. + +`vendor --check --local-repo ` additionally checks existing suffixed Maven +jar/POM copies for conflicting bytes. `--maven-config auto|none` is a global +vendoring option so scan/get/repair receive it too; omission preserves the +ledger's recorded choice. Switching existing auto-config wiring to `none` +requires reverting it first. `none` cannot be combined with a repository ban. diff --git a/crates/socket-patch-cli/Cargo.toml b/crates/socket-patch-cli/Cargo.toml index 9d9166e23..ab434d5e0 100644 --- a/crates/socket-patch-cli/Cargo.toml +++ b/crates/socket-patch-cli/Cargo.toml @@ -45,24 +45,13 @@ windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_System_ [features] # Every ecosystem (npm, PyPI, Ruby gems, Go, Cargo, NuGet, Maven, Composer, # Deno) is unconditionally compiled in AND enabled at runtime — there are no -# ecosystem feature gates and no runtime env gates (the old -# SOCKET_EXPERIMENTAL_MAVEN/NUGET opt-ins are retired). +# ecosystem feature gates and no runtime env gates. # The only features left gate opt-in test suites: # # Enables the Docker-driven real-package e2e test suite under # `tests/docker_e2e_*.rs`. Tests in this suite require either a running # Docker daemon OR `SOCKET_PATCH_TEST_HOST=1` (host-toolchain mode). docker-e2e = [] -# Enables the experimental `setup` end-to-end test matrix under -# `tests/setup_matrix_*.rs`, which drives the `socket-patch setup` → -# native-install → patch-applied flow across every ecosystem/package -# manager via `tests/setup_matrix/run-case.sh`. Same runtime requirement -# as docker-e2e (Docker daemon OR `SOCKET_PATCH_TEST_HOST=1`). These -# tests are ASPIRATIONAL: they assert the ideal (install applies the -# patch) and are EXPECTED to fail for ecosystems whose install hooks -# `setup` does not yet configure. Kept off `--all-features`-required CI; -# the dedicated `setup-matrix` CI job runs them non-blocking. -setup-e2e = [] [dev-dependencies] # vendor_crash_safety_e2e / vendor_group_commit_e2e crash the binary through @@ -75,6 +64,8 @@ sha1 = { workspace = true } # scan_vendor_e2e builds pristine registry tarballs for the auto-fetch tests. tar = { workspace = true } flate2 = { workspace = true } +# diff_created_file_e2e builds a real diff archive. +qbsdiff = { workspace = true } # update_fixture builds the Windows-shaped release archive for self-update e2e. zip = { workspace = true } hex = { workspace = true } diff --git a/crates/socket-patch-cli/src/args.rs b/crates/socket-patch-cli/src/args.rs index 499cf69f6..13731f268 100644 --- a/crates/socket-patch-cli/src/args.rs +++ b/crates/socket-patch-cli/src/args.rs @@ -9,9 +9,7 @@ //! //! Precedence for every flag: CLI arg > env var > default. //! -//! All env-var names use the `SOCKET_*` prefix. Three legacy `SOCKET_PATCH_*` -//! names are still read at runtime (via `socket_patch_core::env_compat`) with -//! a one-shot deprecation warning; they will be removed in the next major. +//! All env-var names use the `SOCKET_*` prefix. use std::path::{Path, PathBuf}; @@ -91,7 +89,7 @@ pub(crate) fn parse_bool_flag(s: &str) -> Result { /// flags (listed first, under "Options") are not buried among the ~24 shared /// ones. Set per-arg rather than as the struct's `next_help_heading`: that /// would leak onto the subcommand-local flags declared after the flatten. -const GLOBAL_OPTIONS: &str = "Global options"; +pub(crate) const GLOBAL_OPTIONS: &str = "Global options"; // Arguments inherited by every subcommand via `#[command(flatten)]`. // @@ -178,6 +176,11 @@ pub struct GlobalArgs { )] pub vendor_source: String, + /// Vendored Maven: auto writes the repository tail; none uses only the file repository. + /// The choice is preserved on subsequent runs. + #[arg(help_heading = GLOBAL_OPTIONS, long, value_parser = ["auto", "none"])] + pub maven_config: Option, + /// Base URL for the patch vendoring service. Defaults to the active API base (`--api-url`) when /// authenticated or the proxy base (`--proxy-url`) otherwise. Override to /// point `vendor` at staging / local dev independently of `--api-url`. @@ -303,7 +306,7 @@ pub struct GlobalArgs { /// backoff until the lock frees or the budget elapses. Only meaningful /// for the commands that take the lock (`apply`, `rollback`, `repair`, /// `remove`, `vendor`, `get` and `scan` when they record, apply, - /// vendor or redirect patches, and `setup --exclude`'s manifest write); + /// vendor or host patches); /// other commands accept it silently. Every holder removes the lock file on exit, so a leftover /// from a crashed run never contends. #[arg(help_heading = GLOBAL_OPTIONS, long = "lock-timeout", env = "SOCKET_LOCK_TIMEOUT")] @@ -364,7 +367,7 @@ pub struct GlobalArgs { pub no_npm_allow_remote_config: bool, /// Hosted mode (`scan`/`get --mode hosted`, and `rollback`/`remove` of - /// hosted redirects): do NOT remove stale vlt installed copies + /// hosted patches): do NOT remove stale vlt installed copies /// (`node_modules/.vlt-lock.json` and the stale `node_modules/.vlt` /// entries) after `vlt-lock.json` is repointed or restored. vlt never /// refreshes an installed copy on its own, so opting out means running @@ -382,6 +385,40 @@ pub struct GlobalArgs { } impl GlobalArgs { + /// The crawler options this run's `--cwd` / `--global` / + /// `--global-prefix` select. + pub(crate) fn crawler_options(&self) -> socket_patch_core::crawlers::CrawlerOptions { + socket_patch_core::crawlers::CrawlerOptions { + cwd: self.cwd.clone(), + global: self.global, + global_prefix: self.global_prefix.clone(), + } + } + + /// Whether this run targets globally installed packages (`--global` or + /// `--global-prefix`) rather than the project at `--cwd`. + pub(crate) fn is_global(&self) -> bool { + self.global || self.global_prefix.is_some() + } + + /// Whether `--ecosystems` selects `eco` (every ecosystem when unset or + /// empty). The names are validated at parse time, so this is an exact + /// match. + pub(crate) fn ecosystem_selected(&self, eco: Ecosystem) -> bool { + self.ecosystems.as_ref().is_none_or(|list| { + list.is_empty() || list.iter().any(|name| name == eco.cli_name()) + }) + } + + /// [`Self::ecosystem_selected`] for the ecosystem of `purl`; a purl of + /// no known ecosystem is selected only when `--ecosystems` is unset. + pub(crate) fn purl_ecosystem_selected(&self, purl: &str) -> bool { + match Ecosystem::from_purl(purl) { + Some(eco) => self.ecosystem_selected(eco), + None => self.ecosystems.as_ref().is_none_or(Vec::is_empty), + } + } + /// Resolve `manifest_path` against `cwd`: absolute paths are returned /// as-is, relative paths are joined to `cwd`. pub(crate) fn resolved_manifest_path(&self) -> PathBuf { @@ -393,7 +430,7 @@ impl GlobalArgs { } /// The project root whose `.socket/` state stores — manifest, vendor - /// ledger, redirect ledger — belong together: the RESOLVED manifest's + /// ledger — belong together: the RESOLVED manifest's /// directory, stepping out of a standard `.socket/` layout when the /// manifest lives in one. For the default `/.socket/manifest.json` /// this is exactly `cwd`; for a `--manifest-path` into another project @@ -449,7 +486,7 @@ impl GlobalArgs { /// The `(api_token, org_slug)` telemetry is attributed with, resolved /// through the API client's own credential chain (flag → the /// `SOCKET_NO_API_TOKEN` veto → env → `socket login` config) WITHOUT - /// building a client. For the purely local commands (`list`, `setup`, + /// building a client. For the purely local commands (`list`, /// `vex`): a client would add the org-slug auto-resolve round-trip and /// the "No SOCKET_API_TOKEN set" advisory to a command that needs /// neither, while anything less than the full chain reported a @@ -475,6 +512,7 @@ impl GlobalArgs { use_public_proxy: bool, ) -> VendorServiceConfig { VendorServiceConfig { + maven_config: self.maven_config.as_deref().map(|v| v != "none"), source: VendorSource::parse(&self.vendor_source).unwrap_or_default(), client, use_public_proxy, @@ -580,14 +618,13 @@ pub const LOCAL_ARG_ENV_VARS: &[&str] = &[ "SOCKET_FORCE", "SOCKET_PATCH_VERSION", "SOCKET_SAVE_ONLY", - "SOCKET_ONE_OFF", "SOCKET_ALL_RELEASES", "SOCKET_SKIP_ROLLBACK", "SOCKET_PRESERVE_STATE", "SOCKET_DOWNLOAD_ONLY", - "SOCKET_SETUP_EXCLUDE", "SOCKET_VENDOR_REVERT", "SOCKET_BATCH_SIZE", + "SOCKET_SCAN_PACKAGES", "SOCKET_VEX", "SOCKET_VEX_OUTPUT", "SOCKET_VEX_PRODUCT", @@ -607,7 +644,7 @@ pub const LOCAL_ARG_ENV_VARS: &[&str] = &[ /// per-token validator) outright — a single stray blank var crashed every /// subcommand — and an empty `SOCKET_DOWNLOAD_MODE` / `SOCKET_MANIFEST_PATH` /// (or `SOCKET_VEX_OUTPUT`, which would silently target `""`) leaked `""` -/// past the documented defaults. Called from `main` after legacy-name +/// past the documented defaults. Called from `main` after peer-alias /// promotion and before clap runs. Only exactly-empty values are scrubbed; /// whitespace is significant in paths, so it is left for the parsers to /// judge. @@ -644,6 +681,7 @@ impl Default for GlobalArgs { ecosystems: None, download_mode: "diff".to_string(), vendor_source: "auto".to_string(), + maven_config: None, vendor_url: None, patch_server_url: None, offline: false, @@ -729,11 +767,10 @@ mod tests { /// Clear the extra env the core telemetry gate reads beyond the /// `SOCKET_*` set (`is_telemetry_disabled` also consults `VITEST` — the - /// kill-switch socket-cli's vitest suite relies on — and the legacy - /// `SOCKET_PATCH_TELEMETRY_DISABLED` name), so the airgap tests below - /// can't pass or fail vacuously. Restores afterwards. + /// kill-switch socket-cli's vitest suite relies on), so the airgap tests + /// below can't pass or fail vacuously. Restores afterwards. fn with_clean_telemetry_env(f: impl FnOnce()) { - with_env_cleared(&["VITEST", "SOCKET_PATCH_TELEMETRY_DISABLED"], f); + with_env_cleared(&["VITEST"], f); } /// `--offline` promises "never contact the network", but the telemetry @@ -852,8 +889,8 @@ mod tests { /// `scrub_empty_env_vars` removes exactly-empty `SOCKET_*` flag vars /// (the `VAR=` blank-without-unsetting idiom) — global and local — and /// nothing else: set, non-empty values — even whitespace-only ones, - /// which are significant in paths — survive, and the - /// previously-crashing parse then sees plain defaults. + /// which are significant in paths — survive, and the parse then sees + /// plain defaults. #[test] #[serial_test::serial] fn scrub_empty_env_vars_unsets_only_empties() { @@ -949,8 +986,7 @@ mod tests { } /// Regression: scan's vendored flow must build its service config FROM - /// `--vendor-source`, not hardcode build-only (the pre-fix `service = - /// None`). Under the default (`auto`), the config must permit the + /// `--vendor-source`, not hardcode build-only. Under the default (`auto`), the config must permit the /// vendoring service exactly as the `vendor` command's default does — /// otherwise `scan --mode vendored` silently builds locally while a /// plain `vendor` service-downloads, and the two commit different bytes / @@ -1071,7 +1107,7 @@ mod tests { } } - /// The bug fix: an empty (or whitespace-only) string is `false`, not an + /// An empty (or whitespace-only) string is `false`, not an /// error. Shells/CI export `SOCKET_OFFLINE=` to mean "unset". #[test] fn parse_bool_flag_treats_empty_as_false() { @@ -1088,9 +1124,9 @@ mod tests { assert!(parse_bool_flag("tru").is_err()); } - /// Regression: an exported-but-empty bool env var must NOT crash the parse. - /// Before the fix, `BoolishValueParser` aborted with "value was not a - /// boolean", taking down every subcommand. Now it resolves to `false`. + /// Regression: an exported-but-empty bool env var must NOT crash the parse + /// (`BoolishValueParser` rejects it, taking down every subcommand); it + /// resolves to `false`. #[test] #[serial_test::serial] fn empty_bool_env_var_parses_as_false_not_crash() { @@ -1450,15 +1486,15 @@ mod tests { } /// The mirror only works if every subcommand's `run` actually calls - /// `apply_env_toggles`. `list` and `setup` fire telemetry - /// (`track_patch_listed` / `track_patch_setup`) whose kill-switch reads + /// `apply_env_toggles`. `list` fires telemetry + /// (`track_patch_listed`) whose kill-switch reads /// `SOCKET_TELEMETRY_DISABLED` / `SOCKET_OFFLINE` from the env only — a /// run entry point that skips the mirror silently ignores /// `--no-telemetry` and lets `--offline` (strict airgap: never contact /// the network) still fire the telemetry HTTP request. #[test] #[serial_test::serial] - fn list_and_setup_run_mirror_global_toggles_for_airgap() { + fn list_run_mirrors_global_toggles_for_airgap() { with_clean_socket_env(|| { with_clean_telemetry_env(|| { let rt = tokio::runtime::Builder::new_current_thread() @@ -1488,28 +1524,6 @@ mod tests { env — its telemetry kill-switch reads only SOCKET_OFFLINE / \ SOCKET_TELEMETRY_DISABLED", ); - - // Reset the mirrored vars so setup can't pass on list's leftovers. - std::env::remove_var("SOCKET_OFFLINE"); - std::env::remove_var("SOCKET_TELEMETRY_DISABLED"); - - let tmp = tempfile::tempdir().unwrap(); - rt.block_on(crate::commands::setup::run( - crate::commands::setup::SetupArgs { - check: false, - remove: false, - exclude: Vec::new(), - common: GlobalArgs { - // `setup` must not write anything from a unit test. - dry_run: true, - ..toggles_on(tmp.path()) - }, - }, - )); - assert!( - socket_patch_core::telemetry::is_telemetry_disabled(), - "`setup --offline --no-telemetry` must mirror the toggles into the env", - ); }); }); } @@ -1560,12 +1574,10 @@ mod tests { ("SOCKET_FORCE", &["socket-patch", "vendor"]), ("SOCKET_FORCE", &["socket-patch", "self-update"]), ("SOCKET_SAVE_ONLY", &["socket-patch", "get", "x"]), - ("SOCKET_ONE_OFF", &["socket-patch", "get", "x"]), - ("SOCKET_ONE_OFF", &["socket-patch", "rollback"]), ("SOCKET_ALL_RELEASES", &["socket-patch", "get", "x"]), ("SOCKET_ALL_RELEASES", &["socket-patch", "scan"]), ("SOCKET_SKIP_ROLLBACK", &["socket-patch", "remove", "x"]), - // Shared by rollback and remove, like SOCKET_ONE_OFF above. + // Shared by rollback and remove. ("SOCKET_PRESERVE_STATE", &["socket-patch", "rollback"]), ("SOCKET_PRESERVE_STATE", &["socket-patch", "remove", "x"]), ("SOCKET_DOWNLOAD_ONLY", &["socket-patch", "repair"]), @@ -1573,7 +1585,7 @@ mod tests { ("SOCKET_VEX_NO_VERIFY", &["socket-patch", "vex"]), ("SOCKET_VEX_COMPACT", &["socket-patch", "vex"]), // The embedded `--vex-*` twins share the same env vars and must - // not abort host commands (e.g. apply from a postinstall hook). + // not abort host commands (e.g. apply from a CI step). ("SOCKET_VEX_NO_VERIFY", &["socket-patch", "apply"]), ("SOCKET_VEX_COMPACT", &["socket-patch", "scan"]), ]; @@ -1609,8 +1621,8 @@ mod tests { const VALUE_BINDINGS: &[(&str, &[&str])] = &[ ("SOCKET_BATCH_SIZE", &["socket-patch", "scan"]), + ("SOCKET_SCAN_PACKAGES", &["socket-patch", "scan"]), ("SOCKET_PATCH_VERSION", &["socket-patch", "self-update"]), - ("SOCKET_SETUP_EXCLUDE", &["socket-patch", "setup"]), ("SOCKET_VEX", &["socket-patch", "apply"]), ("SOCKET_VEX_OUTPUT", &["socket-patch", "vex"]), ("SOCKET_VEX_PRODUCT", &["socket-patch", "vex"]), diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index d599048ee..761d830cf 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -3,7 +3,7 @@ use socket_patch_core::api::blob_fetcher::get_missing_blobs; use socket_patch_core::api::client::{get_api_client_with_overrides, ApiClient}; use socket_patch_core::crawlers::ruby_crawler::config_path_ignored_warning; use socket_patch_core::crawlers::{ - detect_npm_pkg_manager, CrawlerOptions, Ecosystem, NpmPkgManager, RubyCrawler, + detect_npm_pkg_manager, Ecosystem, NpmPkgManager, RubyCrawler, }; use socket_patch_core::manifest::operations::read_manifest; use socket_patch_core::manifest::schema::{PatchFileInfo, PatchManifest, PatchRecord}; @@ -70,7 +70,7 @@ fn warn_mismatch_overwrites(result: &ApplyResult, common: &GlobalArgs) { fn format_mismatch_warning(purl: &str, file: &str, dry_run: bool) -> String { let what = if dry_run { "would apply" } else { "applied" }; format!( - "Warning (content_mismatch_overwritten): {purl} {file} did not match the patch's \ + "Warning: {purl} {file} did not match the patch's \ expected original content; {what} the full verified patched content instead \ (pass --strict to fail on mismatches)" ) @@ -338,7 +338,7 @@ pub struct ApplyArgs { #[command(flatten)] pub vex: VexEmbedArgs, - /// Set when `get` / `scan --apply/--sync` runs this apply as its last + /// Set when `get` / `scan --mode agent` runs this apply as its last /// step (`None` for the `apply` command itself). Not a CLI flag. #[arg(skip)] pub nested: Option, @@ -363,8 +363,7 @@ impl ApplyArgs { } // ── local-go redirect helpers ──────────────────────────────────────────────── -// The Go analog of the cargo helpers above: in local mode a `pkg:golang/…` PURL -// redirects to a project-local patched copy under `.socket/go-patches/` wired via +// In local mode a `pkg:golang/…` PURL redirects to a project-local patched copy under `.socket/go-patches/` wired via // a `go.mod` `replace` directive. /// True for a golang PURL in local mode (no `--global` / `--global-prefix`). @@ -386,13 +385,7 @@ pub(crate) fn is_local_go(purl: &str, common: &GlobalArgs) -> bool { /// `.yarn/cache/*.zip`, so a run that never crawls the checkout's /// `node_modules` must not be refused by its layout). fn eco_in_local_scope(common: &GlobalArgs, eco: Ecosystem) -> bool { - if common.global || common.global_prefix.is_some() { - return false; - } - match &common.ecosystems { - None => true, - Some(list) => list.iter().any(|e| e == eco.cli_name()), - } + !common.is_global() && common.ecosystem_selected(eco) } /// Materialise a local-go redirect for `purl`, or `None` if `purl` isn't a @@ -744,9 +737,8 @@ fn manifest_targets_npm(manifest: &PatchManifest) -> bool { /// Print the yarn-PnP refusal (JSON envelope or human stderr) and return /// apply's refusal exit code. Shared by the pre-manifest gate and the /// package-manager layout gate below: scan cannot discover PnP packages so -/// it never writes a manifest, which used to leave the calm `noManifest` -/// exit as the ONLY thing a PnP user ever saw — the documented loud -/// `yarn_pnp_unsupported` refusal was unreachable without a manifest. +/// it never writes a manifest, and the loud `yarn_pnp_unsupported` refusal +/// must still be reachable without one. fn refuse_yarn_pnp(args: &ApplyArgs) -> i32 { if args.common.json { let mut env = Envelope::new(Command::Apply); @@ -773,7 +765,8 @@ pub async fn run(args: ApplyArgs) -> i32 { let manifest_path = args.common.resolved_manifest_path(); // No manifest → nothing to apply: a clean exit-0 no-op (load-bearing - // for the install hooks, which run `apply --silent` on every install). + // for CI steps and legacy install hooks that run `apply --silent` on + // every install). // Nothing below this gate is touched — no API client (its config read, // stderr advisory and org-slug round-trip), no lock, no `.socket/`. if tokio::fs::metadata(&manifest_path).await.is_err() { @@ -915,7 +908,7 @@ pub async fn run(args: ApplyArgs) -> i32 { /// package-manager layout gate, the apply loop, embedded VEX, output and /// telemetry — over a `lock` the caller already holds and the caller's /// `client`. [`run`] takes the lock itself; agent-mode `get` and -/// `scan --apply/--sync` call this straight after their manifest write, so +/// `scan --mode agent` call this straight after their manifest write, so /// download → manifest write → apply is ONE lock window (a same-process /// re-acquire would contend) and the nested apply never builds a second /// client. `lock` is released explicitly once every mutation is done @@ -1041,11 +1034,11 @@ pub(crate) async fn run_locked( // own `Error:` diagnostic (already printed, even under // --silent); they exist for the JSON envelope. if !is_stage_failure_code(&w.code) { - eprintln!("Warning ({}): {}", w.code, w.detail); + eprintln!("Warning: {}", w.detail); } } for skip in &fallback_skips { - eprintln!("Warning (gem_fallback_home_skipped): {}", skip.detail()); + eprintln!("Warning: {}", skip.detail()); } } @@ -1358,9 +1351,11 @@ struct ApplyOutcome { results: Vec, /// In-scope manifest purls with no installed package on disk. unmatched: Vec, - /// Run-level advisories: JSON `warnings[]`, one gated stderr line each - /// on the human path (`--silent` = errors only). Today: the gem - /// config-root containment skip. + /// Run-level advisories: JSON `warnings[]`, and one gated stderr line + /// each on the human path (`--silent` = errors only) except the + /// sources-unavailable codes (already printed by the stager): the gem + /// config-root containment skip and the sources-unavailable reason + /// (`run` adds `ownership_not_restored`). run_warnings: Vec, /// Gem-env fallback-home copies deliberately left unpatched /// (best-effort class): one non-fatal `Skipped` event each in the @@ -1692,10 +1687,9 @@ async fn apply_patches_inner( if partitioned.is_empty() { // Nothing in scope: the manifest lists no patches (or every patch was // filtered out by `--ecosystems`). There is genuinely no work to do, - // so this is a clean no-op SUCCESS — not a failure. Returning `false` - // here used to exit 1 / `partialFailure`, which broke the npm - // `postinstall` hook (it runs `apply` on every install, including - // fresh projects whose manifest has no matching patches yet). Decided + // so this is a clean no-op SUCCESS — not a failure: the npm + // `postinstall` hook runs `apply` on every install, including fresh + // projects whose manifest has no matching patches yet. Decided // BEFORE the ledger read, gem discovery and the crawl — none of which // can add work to an empty scope — but AFTER the staging above, which // is where `--download-mode` is validated at runtime. @@ -1725,11 +1719,7 @@ async fn apply_patches_inner( let (mut results, mut matched_manifest_purls, vendored_bases) = synthesize_vendor_owned_results(&target_manifest_purls, &vendored_purls); - let crawler_options = CrawlerOptions { - cwd: args.common.cwd.clone(), - global: args.common.global, - global_prefix: args.common.global_prefix.clone(), - }; + let crawler_options = args.common.crawler_options(); // Gem bundle-store discovery, re-run cheaply (filesystem probes only, // no `gem env` shell-out) against the same ambient environment the @@ -1795,8 +1785,8 @@ async fn apply_patches_inner( let mut unmatched = unmatched; unmatched.sort(); // This diagnostic flips the exit code, so it is an error — and it - // prints even under --silent ("errors only", never nothing — the - // hooked `apply --silent` used to exit 1 mutely here); `--json` + // prints even under --silent ("errors only", never a mute exit 1); + // `--json` // mutes stderr and the envelope's `package_not_installed` events // are the channel. if !unmatched.is_empty() && args.prints_errors() { @@ -2106,7 +2096,7 @@ async fn apply_patches_inner( if is_vendored(purl) { continue; } - // npm PURLs: direct lookup + // Non-variant PURLs: direct lookup let patch = match manifest.patches.get(purl) { Some(p) => p, None => continue, @@ -2123,8 +2113,8 @@ async fn apply_patches_inner( // Local go redirects to a project-local patched copy under // `.socket/go-patches/` wired via a `go.mod` `replace` (the // module cache is `go.sum`-verified, so in-place patching - // can't build). Everything else — npm/pypi/gem and cargo - // (vendored or registry cache) — patches in place via + // can't build). Everything else in this branch (npm, cargo, + // composer, nuget, …) patches in place via // `apply_package_patch`. let result = match try_local_go_apply(purl, pkg_path, patch, &sources, &args.common, policy) @@ -2340,7 +2330,7 @@ mod tests { fn applied_event_emits_one_file_entry_per_patched_file() { let mut applied_via = HashMap::new(); applied_via.insert("package/a.js".to_string(), CoreAppliedVia::Diff); - applied_via.insert("package/b.js".to_string(), CoreAppliedVia::Package); + applied_via.insert("package/b.js".to_string(), CoreAppliedVia::Diff); applied_via.insert("package/c.js".to_string(), CoreAppliedVia::Blob); let result = ApplyResult { package_key: "pkg:npm/foo@1.0.0".to_string(), @@ -2367,7 +2357,7 @@ mod tests { .map(|f| (f["path"].as_str().unwrap().to_string(), f)) .collect(); assert_eq!(by_path["package/a.js"]["appliedVia"], "diff"); - assert_eq!(by_path["package/b.js"]["appliedVia"], "package"); + assert_eq!(by_path["package/b.js"]["appliedVia"], "diff"); assert_eq!(by_path["package/c.js"]["appliedVia"], "blob"); } @@ -2430,10 +2420,8 @@ mod tests { /// Regression: a non-installed release variant whose first patched /// file is `NotFound` (e.g. an sdist patching `setup.py` while only a /// wheel is on disk) must be treated as NOT installed and skipped — - /// exactly like a `HashMismatch`. Before the fix the loop only skipped - /// `HashMismatch`, so a `NotFound` variant slipped through to - /// `apply_package_patch` and produced a spurious `Failed` event in the - /// JSON envelope. This pins the apply-side decision to the same + /// exactly like a `HashMismatch`, never reaching `apply_package_patch` + /// as a spurious `Failed` event. This pins the apply-side decision to the same /// Ready/AlreadyPatched contract as `select_installed_variants`. #[test] fn variant_matches_only_when_first_file_ready_or_already_patched() { @@ -3119,7 +3107,7 @@ mod tests { fn mismatch_messages_follow_dry_run_tense() { assert_eq!( format_mismatch_warning("pkg:npm/nuxt@4.5.0", "dist/index.mjs", false), - "Warning (content_mismatch_overwritten): pkg:npm/nuxt@4.5.0 dist/index.mjs did \ + "Warning: pkg:npm/nuxt@4.5.0 dist/index.mjs did \ not match the patch's expected original content; applied the full verified \ patched content instead (pass --strict to fail on mismatches)" ); diff --git a/crates/socket-patch-cli/src/commands/bun_preflight.rs b/crates/socket-patch-cli/src/commands/bun_preflight.rs index f972b7e69..3655295e3 100644 --- a/crates/socket-patch-cli/src/commands/bun_preflight.rs +++ b/crates/socket-patch-cli/src/commands/bun_preflight.rs @@ -1,6 +1,6 @@ //! The Bun vendored-mode preflight shared by EVERY path that feeds the -//! vendor engine: `scan --mode vendored` (its in-memory download phase; -//! the hidden `--detached` flag is a no-op), `get … --mode vendored` +//! vendor engine: `scan --mode vendored` (its in-memory download phase), +//! `get … --mode vendored` //! (search and uuid paths), their `--dry-run` previews, and the `vendor` command's engine //! loop itself ([`crate::commands::vendor::vendor_records`], where it runs //! BEFORE the hosted→vendored takeover reverts anything). diff --git a/crates/socket-patch-cli/src/commands/context.rs b/crates/socket-patch-cli/src/commands/context.rs new file mode 100644 index 000000000..3ae3b0050 --- /dev/null +++ b/crates/socket-patch-cli/src/commands/context.rs @@ -0,0 +1,100 @@ +//! The project a command reads, loaded lazily and at most once per run: +//! the patch stores ([`LoadedLedgers`]: manifest + both ledgers), the lock +//! set (the lockfile inventory and its refused npm layouts) and the +//! lockfile wiring discovery. `scan`, `vendor`, `vex`, `list` and `get` +//! read these through one [`ProjectContext`] instead of each re-loading +//! and re-merging them its own way. +//! +//! The lock set and the discovery read `--cwd` through one +//! [`DiskSnapshot`], so each lock and config file is read once and both see +//! the same bytes. +//! +//! Everything here is a read-only snapshot. A command that writes a store +//! under the apply lock (the hosted engine, rollback, remove) re-loads it +//! under that lock instead of trusting a pre-lock snapshot, and an embedded +//! `--vex` after the writes loads its own inputs. + +use std::path::PathBuf; + +use socket_patch_core::ledgers::{Ledgers, LoadedLedgers}; +use socket_patch_core::vendor::lock_inventory::{ + DiskSnapshot, LockfileEntry, ProjectView, UnsupportedNpmLayout, +}; +use socket_patch_core::vex::discover::Discovery; +use tokio::sync::OnceCell; + +use crate::args::GlobalArgs; + +/// The project's lockfile inventory and the npm layouts it refused. +pub(crate) struct LockSet { + pub(crate) entries: Vec, + pub(crate) unsupported: Vec, +} + +pub(crate) struct ProjectContext<'a> { + pub(crate) common: &'a GlobalArgs, + /// Where the ledgers live: the manifest's project (see + /// [`GlobalArgs::project_root`]). + pub(crate) root: PathBuf, + snapshot: DiskSnapshot<'a>, + ledgers: OnceCell, + locks: OnceCell, + discovery: OnceCell, +} + +impl<'a> ProjectContext<'a> { + pub(crate) fn new(common: &'a GlobalArgs) -> Self { + Self::rooted(common, common.project_root()) + } + + /// A context whose ledgers load from `root` (commands that read the + /// ledgers of `--cwd` rather than of the manifest's project). + pub(crate) fn rooted(common: &'a GlobalArgs, root: PathBuf) -> Self { + Self { + common, + root, + snapshot: DiskSnapshot::new(&common.cwd), + ledgers: OnceCell::new(), + locks: OnceCell::new(), + discovery: OnceCell::new(), + } + } + + /// The three stores, each with its own load outcome. + pub(crate) async fn loaded(&self) -> &LoadedLedgers { + self.ledgers + .get_or_init(|| async { + LoadedLedgers::load(&self.root, &self.common.resolved_manifest_path()).await + }) + .await + } + + /// The readable stores as one view (a failed store reads as absent). + pub(crate) async fn ledgers(&self) -> Ledgers<'_> { + self.loaded().await.view() + } + + /// The lockfile inventory of `--cwd`. + pub(crate) async fn locks(&self) -> &LockSet { + self.locks + .get_or_init(|| async { + let (entries, unsupported) = + socket_patch_core::vendor::lock_inventory::inventory_project_diagnosed_in( + &ProjectView::Snapshot(&self.snapshot), + ) + .await; + LockSet { + entries, + unsupported, + } + }) + .await + } + + /// The lockfile wiring discovery of `--cwd` ([`super::discover_wiring`]). + pub(crate) async fn discovery(&self) -> &Discovery { + self.discovery + .get_or_init(|| super::discover_wiring_in(self.common, &self.snapshot)) + .await + } +} diff --git a/crates/socket-patch-cli/src/commands/fetch_stage.rs b/crates/socket-patch-cli/src/commands/fetch_stage.rs index c0ac01ec9..dbde61fcd 100644 --- a/crates/socket-patch-cli/src/commands/fetch_stage.rs +++ b/crates/socket-patch-cli/src/commands/fetch_stage.rs @@ -1,8 +1,8 @@ //! Shared patch-source staging for the mutating commands (`apply`, `vendor`). //! -//! Resolves where the patch pipeline should read blob/diff/package artifacts -//! from, downloading what's missing into a transient overlay tempdir. The -//! persistent `.socket/{blobs,diffs,packages}` cache is only ever *read* — +//! Resolves where the patch pipeline should read blob/diff artifacts from, +//! downloading what's missing into a transient overlay tempdir. The +//! persistent `.socket/{blobs,diffs}` cache is only ever *read* — //! downloads land in the tempdir and are discarded when it drops (filling the //! cache is `repair`'s job, keeping these commands read-only against //! `.socket/`). @@ -33,7 +33,6 @@ use crate::ui::{plural, StatusLine}; pub(crate) struct StagedSources { pub(crate) blobs: PathBuf, diffs: PathBuf, - packages: PathBuf, _stage: Option, } @@ -42,7 +41,6 @@ impl StagedSources { pub(crate) fn as_patch_sources(&self) -> PatchSources<'_> { PatchSources { blobs_path: &self.blobs, - packages_path: Some(&self.packages), diffs_path: Some(&self.diffs), mem_blobs: None, } @@ -175,11 +173,6 @@ fn format_fetch_failures(result: &FetchMissingBlobsResult, (one, many): Noun) -> lines } -/// Announce the per-file blob top-up that follows a diff-mode fetch. It -/// runs even when every diff archive arrived — a diff cannot patch a file -/// whose bytes differ from `beforeHash`, and the pipeline then falls back -/// to the blob — so it is worded as a complement, not a failure, unless -/// some archives really were unavailable. /// The disk stager's status line while it downloads what `.socket/` lacks. const DOWNLOADING_ARTIFACTS: &str = "Downloading missing patch artifacts..."; @@ -188,6 +181,11 @@ fn format_fetching_content(n: usize) -> String { format!("Fetching content for {}...", plural(n, "patch", "patches")) } +/// Announce the per-file blob top-up that follows a diff-mode fetch. It +/// runs even when every diff archive arrived — a diff cannot patch a file +/// whose bytes differ from `beforeHash`, and the pipeline then falls back +/// to the blob — so it is worded as a complement, not a failure, unless +/// some archives really were unavailable. fn format_blob_fallback(diff_failed: usize, blobs: usize) -> String { let blobs = plural(blobs, "per-file blob", "per-file blobs"); if diff_failed == 0 { @@ -201,10 +199,10 @@ fn format_blob_fallback(diff_failed: usize, blobs: usize) -> String { } /// The manifest PURLs with no usable local source. A patch is "locally -/// applicable" iff at least one of: -/// - every `after_hash` blob it references is on disk, OR -/// - its diff archive is on disk, OR -/// - its package archive is on disk. +/// applicable" iff every file it touches has its `after_hash` blob on +/// disk or is covered by the patch's diff archive. A diff covers only files +/// that exist before the patch: a created file (empty `before_hash`) has +/// nothing to diff against, so it always needs its blob. /// /// The patch pipeline picks whichever is present per file. Shared by the /// offline gate (probed against `.socket/`) and the post-download gate @@ -213,19 +211,17 @@ fn patches_without_source<'m>( manifest: &'m PatchManifest, missing_blobs: &HashSet, missing_diff_archives: &HashSet, - missing_package_archives: &HashSet, ) -> Vec<&'m str> { manifest .patches .iter() .filter_map(|(purl, record)| { - let all_blobs_present = record - .files - .values() - .all(|f| !missing_blobs.contains(&f.after_hash)); let diff_present = !missing_diff_archives.contains(&record.uuid); - let pkg_present = !missing_package_archives.contains(&record.uuid); - if all_blobs_present || diff_present || pkg_present { + let files_covered = record.files.values().all(|f| { + !missing_blobs.contains(&f.after_hash) + || (diff_present && !f.before_hash.is_empty()) + }); + if files_covered { None } else { Some(purl.as_str()) @@ -234,6 +230,33 @@ fn patches_without_source<'m>( .collect() } +/// `manifest` cut down to the files a diff archive cannot patch (created +/// files, whose `before_hash` is empty): the blobs a diff-mode fetch still +/// needs even when every diff archive is present. +pub(crate) fn files_diffs_cannot_cover(manifest: &PatchManifest) -> PatchManifest { + let patches = manifest + .patches + .iter() + .filter_map(|(purl, record)| { + let files: HashMap<_, _> = record + .files + .iter() + .filter(|(_, f)| f.before_hash.is_empty()) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + (!files.is_empty()).then(|| { + let mut record = record.clone(); + record.files = files; + (purl.clone(), record) + }) + }) + .collect(); + PatchManifest { + patches, + setup: manifest.setup.clone(), + } +} + /// Mirror `src`'s files into `dst` by hardlink (copy fallback). Pre-seeds the /// overlay tempdir with everything already cached so only the gap downloads. async fn overlay_dir(src: &Path, dst: &Path) { @@ -276,7 +299,6 @@ pub(crate) async fn stage_patch_sources( let quiet = common.silent || common.json; let socket_blobs_path = socket_dir.join("blobs"); let socket_diffs_path = socket_dir.join("diffs"); - let socket_packages_path = socket_dir.join("packages"); let download_mode = DownloadMode::parse(&common.download_mode).map_err(|e| e.to_string())?; @@ -285,14 +307,9 @@ pub(crate) async fn stage_patch_sources( // on disk. These probes are read-only. let missing_blobs = get_missing_blobs(manifest, &socket_blobs_path).await; let missing_diff_archives = get_missing_archives(manifest, &socket_diffs_path).await; - let missing_package_archives = get_missing_archives(manifest, &socket_packages_path).await; - let no_source_purls = patches_without_source( - manifest, - &missing_blobs, - &missing_diff_archives, - &missing_package_archives, - ); + let no_source_purls = + patches_without_source(manifest, &missing_blobs, &missing_diff_archives); if common.offline { // Offline: bail only if some patch has no usable local source. @@ -307,24 +324,24 @@ pub(crate) async fn stage_patch_sources( // Decide what (if anything) needs downloading. // - // The patch pipeline tries sources in the order package → diff → blob + // The patch pipeline tries sources in the order diff → blob // locally. We honor `--download-mode` for the primary fetch when there's // actually a gap to close. Skip the archive fetch entirely when all file // blobs are already present locally — the pipeline will succeed via the - // blob path, and the archive endpoints would just 404 (current server - // doesn't serve them yet). + // blob path, so an archive fetch would be wasted round-trips. Cached + // diff archives can still leave a patch uncovered (a created file), and + // the blob top-up below closes that gap. let download_needed = !common.offline && match download_mode { DownloadMode::File => !missing_blobs.is_empty(), DownloadMode::Diff if missing_blobs.is_empty() => false, - DownloadMode::Diff => !missing_diff_archives.is_empty(), + DownloadMode::Diff => !missing_diff_archives.is_empty() || !no_source_purls.is_empty(), }; if !download_needed { return Ok(StageOutcome::Ready(StagedSources { blobs: socket_blobs_path, diffs: socket_diffs_path, - packages: socket_packages_path, _stage: None, })); } @@ -337,17 +354,15 @@ pub(crate) async fn stage_patch_sources( let staged = StagedSources { blobs: stage.path().join("blobs"), diffs: stage.path().join("diffs"), - packages: stage.path().join("packages"), _stage: Some(stage), }; - for dir in [&staged.blobs, &staged.diffs, &staged.packages] { + for dir in [&staged.blobs, &staged.diffs] { tokio::fs::create_dir_all(dir) .await .map_err(|e| e.to_string())?; } overlay_dir(&socket_blobs_path, &staged.blobs).await; overlay_dir(&socket_diffs_path, &staged.diffs).await; - overlay_dir(&socket_packages_path, &staged.packages).await; // Progress: a transient status line on stderr (stdout is data); the // result lines below are what stays on screen. @@ -375,15 +390,25 @@ pub(crate) async fn stage_patch_sources( // For non-file modes, automatically fetch any still-missing file blobs as // a fallback. Patches that lack the requested mode on the server will // still apply via the legacy blob path. + // + // With every diff archive already cached, only the files no diff can + // patch are fetched: that is the gap that triggered this download. let mut blob_fetch_failed = false; if download_mode != DownloadMode::File { - let still_missing_blobs = get_missing_blobs(manifest, &staged.blobs).await; + let created_only; + let blob_scope = if missing_diff_archives.is_empty() { + created_only = files_diffs_cannot_cover(manifest); + &created_only + } else { + manifest + }; + let still_missing_blobs = get_missing_blobs(blob_scope, &staged.blobs).await; if !still_missing_blobs.is_empty() { status.set(format_blob_fallback( fetch_result.failed, still_missing_blobs.len(), )); - let blob_result = fetch_missing_blobs(manifest, &staged.blobs, client, None).await; + let blob_result = fetch_missing_blobs(blob_scope, &staged.blobs, client, None).await; status.finish(); if !quiet { for line in format_fetch_summary(&blob_result, BLOB, true) { @@ -397,19 +422,11 @@ pub(crate) async fn stage_patch_sources( // Download failures only matter per patch: bail iff some patch is left // with no usable source at the staged paths — the same coverage rule as // the offline gate. Aggregate counters can't decide this (a patch whose - // diff failed may be covered by its blobs and vice versa, and a local - // package archive covers its patch even though packages are never - // downloaded). + // diff failed may be covered by its blobs and vice versa). if fetch_result.failed > 0 || blob_fetch_failed { let missing_blobs = get_missing_blobs(manifest, &staged.blobs).await; let missing_diff_archives = get_missing_archives(manifest, &staged.diffs).await; - let missing_package_archives = get_missing_archives(manifest, &staged.packages).await; - let uncovered = patches_without_source( - manifest, - &missing_blobs, - &missing_diff_archives, - &missing_package_archives, - ); + let uncovered = patches_without_source(manifest, &missing_blobs, &missing_diff_archives); if !uncovered.is_empty() { // An error, not progress chatter: prints even under --silent // (same rule as report_offline_missing above). @@ -441,7 +458,6 @@ pub(crate) async fn stage_patch_sources( pub(crate) struct MemStagedSources { blobs: PathBuf, diffs: PathBuf, - packages: PathBuf, mem: HashMap>, /// The purls this staging could NOT obtain patch content for, each with /// the reason, while at least one other patch staged fine. Each is an @@ -458,7 +474,6 @@ impl MemStagedSources { pub(crate) fn as_patch_sources(&self) -> PatchSources<'_> { PatchSources { blobs_path: &self.blobs, - packages_path: Some(&self.packages), diffs_path: Some(&self.diffs), mem_blobs: Some(&self.mem), } @@ -489,8 +504,8 @@ fn needs_blob(file: &PatchFileInfo) -> bool { } /// Stage patch sources for a VENDOR run without writing anything: -/// a record is locally satisfied when all its after-blobs are on disk or -/// a package archive is (a diff archive is NOT sufficient — vendor's +/// a record is locally satisfied when all its after-blobs are on disk (a +/// diff archive is NOT sufficient — vendor's /// auto-force policy can need the full after-blob for files a diff cannot /// reproduce); anything else has its full per-file content fetched into /// memory from the patch view endpoint (`blobContent`), preceded by the @@ -531,10 +546,8 @@ pub(crate) async fn stage_vendor_sources_in_memory( ) -> MemStageOutcome { let blobs = socket_dir.join("blobs"); let diffs = socket_dir.join("diffs"); - let packages = socket_dir.join("packages"); let missing_blobs = get_missing_blobs(manifest, &blobs).await; - let missing_package_archives = get_missing_archives(manifest, &packages).await; let mut mem = seed; let mut unavailable: Vec<(String, String)> = Vec::new(); @@ -542,7 +555,7 @@ pub(crate) async fn stage_vendor_sources_in_memory( // stager: vendoring runs the auto-force policy, where a beforeHash // mismatch (already-applied tree, patch built against different bytes) // is overwritten with the FULL after-blob — which a diff cannot - // produce. On-disk diffs still serve Strategy 2 for clean files; the + // produce. On-disk diffs still serve Strategy 1 for clean files; the // after-blob content must additionally exist (disk, seed/harvest, or // fetch). // @@ -560,7 +573,7 @@ pub(crate) async fn stage_vendor_sources_in_memory( !needs_blob(f) || !missing_blobs.contains(&f.after_hash) || mem.contains_key(&f.after_hash) - }) || !missing_package_archives.contains(&record.uuid) + }) }; let mut to_fetch: Vec<(&str, &str)> = manifest .patches @@ -756,7 +769,6 @@ pub(crate) async fn stage_vendor_sources_in_memory( MemStageOutcome::Ready(MemStagedSources { blobs, diffs, - packages, mem, unavailable, }) @@ -973,6 +985,70 @@ mod tests { ); } + /// A diff archive cannot patch a file the patch creates (nothing to diff + /// against), so it covers such a patch only together with the created + /// file's blob: without it, offline staging is Unavailable up front + /// instead of passing the gate and failing mid-apply. + #[tokio::test] + async fn stage_offline_diff_archive_does_not_cover_a_created_file() { + let tmp = tempfile::tempdir().unwrap(); + let socket_dir = tmp.path().join(".socket"); + std::fs::create_dir_all(socket_dir.join("diffs")).unwrap(); + std::fs::write( + socket_dir.join("diffs").join(format!("{UUID}.tar.gz")), + b"x", + ) + .unwrap(); + let created = "c".repeat(64); + let mut manifest = manifest_with_one_patch(); + manifest + .patches + .get_mut("pkg:npm/left-pad@1.3.0") + .unwrap() + .files + .insert( + "new.js".to_string(), + PatchFileInfo { + before_hash: String::new(), + after_hash: created.clone(), + }, + ); + + let outcome = + stage_patch_sources(&offline_args(), &manifest, &socket_dir, &offline_client()) + .await + .expect("no hard failure"); + assert!(matches!(outcome, StageOutcome::Unavailable)); + + std::fs::create_dir_all(socket_dir.join("blobs")).unwrap(); + std::fs::write(socket_dir.join("blobs").join(&created), b"new").unwrap(); + let outcome = + stage_patch_sources(&offline_args(), &manifest, &socket_dir, &offline_client()) + .await + .expect("no hard failure"); + assert!( + matches!(outcome, StageOutcome::Ready(_)), + "diff for the modified file + blob for the created one covers the patch" + ); + } + + #[test] + fn files_diffs_cannot_cover_keeps_only_created_files() { + let mut manifest = manifest_with_one_patch(); + assert!(files_diffs_cannot_cover(&manifest).patches.is_empty()); + let record = manifest.patches.get_mut("pkg:npm/left-pad@1.3.0").unwrap(); + record.files.insert( + "new.js".to_string(), + PatchFileInfo { + before_hash: String::new(), + after_hash: "c".repeat(64), + }, + ); + let cut = files_diffs_cannot_cover(&manifest); + let files: Vec<&String> = cut.patches["pkg:npm/left-pad@1.3.0"].files.keys().collect(); + assert_eq!(files, ["new.js"]); + } + /// The vendor (in-memory) stager documents the opposite policy: a diff /// archive is NOT sufficient (auto-force can need the full after-blob), /// so the same fixture that satisfies the disk stager is Unavailable @@ -1124,14 +1200,10 @@ mod tests { } } - /// A local package archive is a usable source (the pipeline's Strategy 1, - /// and exactly what the offline gate rules), so an online run whose - /// downloads all fail must still be Ready when the package archive covers - /// every patch. Regression: the failure gate used aggregate fetch - /// counters and never consulted package archives, so this cache state was - /// Unavailable online while succeeding with --offline. + /// A leftover legacy `.socket/packages/.tar.gz` is not a source: + /// nothing reads it, so it must not mask failed downloads. #[tokio::test] - async fn stage_online_fetch_failure_accepts_local_package_archive() { + async fn stage_online_fetch_failure_ignores_legacy_package_archive() { let tmp = tempfile::tempdir().unwrap(); let socket_dir = tmp.path().join(".socket"); std::fs::create_dir_all(socket_dir.join("packages")).unwrap(); @@ -1151,8 +1223,8 @@ mod tests { .await .expect("no hard failure"); assert!( - matches!(outcome, StageOutcome::Ready(_)), - "a local package archive covers the patch even when every download fails" + matches!(outcome, StageOutcome::Unavailable), + "a legacy package archive must not cover the patch" ); } diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 86e0dbbb4..daf8abbd4 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -11,9 +11,12 @@ use socket_patch_core::api::types::{ }; use socket_patch_core::crawlers::fuzzy_match::fuzzy_match_packages; use socket_patch_core::crawlers::{CrawlerOptions, Ecosystem}; +use socket_patch_core::formats::pnpm::PnpmLock; use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; +pub(crate) use socket_patch_core::manifest::records::record_from_patch_response; +use socket_patch_core::manifest::records::{build_patch_record, files_for_manifest}; use socket_patch_core::manifest::schema::{ - PatchFileInfo, PatchManifest, PatchRecord, VulnerabilityInfo, + PatchFileInfo, PatchManifest, PatchRecord, }; use socket_patch_core::patch::apply::{is_valid_blob_hash, select_installed_variants}; use socket_patch_core::patch::apply_lock::{LockError, LockGuard}; @@ -71,10 +74,8 @@ pub(crate) enum PatchAction { /// /// A non-zero exit code must ALWAYS pair with a non-`success` status: /// both are derived from the same predicate here so a JSON consumer -/// reading `status` and a shell reading `$?` can never disagree. The -/// historical bug was a `status` of `success` (keyed only on download -/// failures) sitting next to an exit code of `1` produced by a failed -/// *apply* step. +/// reading `status` and a shell reading `$?` can never disagree (a failed +/// *apply* step must not report `success`). fn run_outcome(patches_failed: bool, apply_failed: bool) -> (&'static str, i32) { if patches_failed || apply_failed { ("partial_failure", 1) @@ -367,43 +368,6 @@ async fn unwind_new_blobs(blobs_dir: &Path, hashes: &[String]) { } } -/// Convert the API-shaped vulnerability map on `PatchResponse` into the -/// serialization-shaped map stored in the manifest. -fn vulnerabilities_for_manifest( - vulns: &HashMap, -) -> HashMap { - vulns - .iter() - .map(|(id, v)| { - ( - id.clone(), - VulnerabilityInfo { - cves: v.cves.clone(), - summary: v.summary.clone(), - severity: v.severity.clone(), - description: v.description.clone(), - }, - ) - }) - .collect() -} - -/// Build the `PatchRecord` that will be inserted into the manifest for -/// `patch`. `files` is the (purl-keyed) before/after-hash map the -/// caller built — semantics for what counts as a "patchable file" differ -/// between the get and download flows, so the caller owns that decision. -fn build_patch_record(patch: &PatchResponse, files: HashMap) -> PatchRecord { - PatchRecord { - uuid: patch.uuid.clone(), - exported_at: patch.published_at.clone(), - files, - vulnerabilities: vulnerabilities_for_manifest(&patch.vulnerabilities), - description: patch.description.clone(), - license: patch.license.clone(), - tier: patch.tier.clone(), - } -} - /// Build a file map keyed by path, keeping only files that carry BOTH /// hashes — the rule used ONLY for installed-distribution matching in /// [`filter_to_installed_releases`]. New files (no `beforeHash`) can @@ -427,45 +391,6 @@ fn files_with_both_hashes(patch: &PatchResponse) -> HashMap` and -/// `scan`/`apply`/`vendor` all record and write the same set of files. -/// The previous both-hashes-only rule silently dropped every added file, -/// e.g. the whole-crate cargo export where ALL files lack a `beforeHash` -/// (recorded `files:{}` → reported `applied:1` while writing nothing) and -/// a gem patch's genuinely-new runtime-guard file. -fn files_for_manifest(patch: &PatchResponse) -> HashMap { - let mut files = HashMap::new(); - for (file_path, file_info) in &patch.files { - if let Some(after) = &file_info.after_hash { - files.insert( - file_path.clone(), - PatchFileInfo { - before_hash: file_info.before_hash.clone().unwrap_or_default(), - after_hash: after.clone(), - }, - ); - } - } - files -} - -/// `(purl, manifest record)` from a fetched patch view — retains -/// patch-added new files via [`files_for_manifest`]. -pub(crate) fn record_from_patch_response(patch: &PatchResponse) -> (String, PatchRecord) { - ( - patch.purl.clone(), - build_patch_record(patch, files_for_manifest(patch)), - ) -} #[derive(Args)] pub struct GetArgs { @@ -495,8 +420,7 @@ pub struct GetArgs { // `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_SAVE_ONLY=1` (or an - // exported-but-empty `SOCKET_SAVE_ONLY=`) aborted every `get` - // invocation. + // exported-but-empty `SOCKET_SAVE_ONLY=`) would abort every `get`. #[arg( long = "save-only", alias = "no-apply", @@ -506,23 +430,6 @@ pub struct GetArgs { )] pub save_only: bool, - /// Apply the patch without saving it to the .socket folder (not yet - /// implemented). - // Hidden: it always fails with "not yet implemented" (see `run`), but - // stays parseable so scripts and `SOCKET_ONE_OFF` keep getting that - // explicit error instead of a clap parse failure. - // `value_parser = parse_bool_flag`: same env-crash fix as `--save-only` - // above — and `SOCKET_ONE_OFF` is shared with `rollback --one-off`, - // which already parses boolishly; the two must not diverge. - #[arg( - long = "one-off", - env = "SOCKET_ONE_OFF", - default_value_t = false, - value_parser = crate::args::parse_bool_flag, - hide = true, - )] - pub one_off: bool, - /// Download patches for every release variant of a matched package, /// not just the one matching the locally-installed distribution. /// @@ -542,11 +449,11 @@ pub struct GetArgs { pub all_releases: bool, /// How to consume the patches: the same modes as `scan --mode` - /// (default: agent). + /// [default: hosted; agent with `--save-only` or `--global`] // agent = record in .socket/manifest.json + blobs and apply in place; // hosted = rewrite lockfiles so the patched deps resolve to Socket's - // hosted patch server (no manifest, no blobs; state lives in the - // redirect ledger); vendored = commit patched artifacts under + // hosted patch server (no manifest, no blobs, no ledger: the lockfile + // is the record); vendored = commit patched artifacts under // .socket/vendor/ and rewire the lockfile (no manifest, no blobs; the // vendor ledger carries the records). Hosted/vendored runs produce the // same on-disk result as `scan --mode hosted|vendored` selecting the @@ -946,17 +853,14 @@ fn format_all_narrowed(skips: &[serde_json::Value]) -> String { } } -/// The confirmation question for `n` selected patches. -fn format_confirm_prompt(mode: super::scan::ScanMode, n: usize, save_only: bool) -> String { +/// The agent-mode confirmation question for `n` selected patches (hosted +/// and vendored `get` never prompt). +fn format_confirm_prompt(save_only: bool, n: usize) -> String { let patches = crate::ui::plural(n, "patch", "patches"); - match mode { - super::scan::ScanMode::Agent if save_only => format!("Download {patches}?"), - super::scan::ScanMode::Agent => format!("Download and apply {patches}?"), - super::scan::ScanMode::Vendored => format!("Download and vendor {patches}?"), - super::scan::ScanMode::Hosted => format!( - "Redirect {} to the hosted patch server?", - crate::ui::plural(n, "package", "packages") - ), + if save_only { + format!("Download {patches}?") + } else { + format!("Download and apply {patches}?") } } @@ -979,9 +883,8 @@ fn no_packages_message(global: bool) -> String { /// `patch` names it (a purl, or the uuid when the purl is unknown). fn format_paid_required(patch: &str) -> String { format!( - "This patch requires a paid subscription to download.\n \ - Patch: {patch}\n \ - Upgrade at: https://socket.dev/pricing" + "This patch requires a paid Socket plan.\n Patch: {patch}\n{}", + crate::ui::PAID_UPGRADE ) } @@ -1079,9 +982,10 @@ fn forced_identifier_error(identifier: &str, id_type: IdentifierType) -> Option< /// Select one patch per PURL from available patches. /// /// Within a PURL, candidates are ranked by [`cmp_search_results`]: merged -/// patches first, then by severity (critical → low), then most recently -/// published. `tier` is an access filter here, not a ranking signal — a -/// free critical patch outranks a paid low one. +/// patches first (newest first, whatever their severity); other patches by +/// severity (critical → low), then most recently published. `tier` is an +/// access filter here, not a ranking signal — a free critical patch +/// outranks a paid low one. /// /// - Users with paid access: auto-select the top-ranked patch per PURL. /// - Free users with one patch, or with `--yes`: auto-select the @@ -1102,7 +1006,6 @@ pub(crate) fn select_patches( can_access_paid: bool, common: &GlobalArgs, ) -> Result, i32> { - // Group accessible patches by PURL let mut by_purl: HashMap> = HashMap::new(); for p in patches { if p.tier == "free" || can_access_paid { @@ -1126,8 +1029,8 @@ pub(crate) fn select_patches( if can_access_paid { // Take the top-ranked patch. Note this is NOT "prefer paid": - // tier only breaks ties once merge status, severity and recency - // have all tied. + // tier only breaks ties once the merged/severity/recency ranking + // (see `api::ranking`) has tied. selected.push(group[0].clone()); } else if group.len() == 1 || (common.yes && !common.json) { // One candidate, or `--yes` (which answers every prompt with its @@ -1180,7 +1083,7 @@ pub(crate) fn select_patches( return Err(1); } Err(SelectError::Cancelled) => { - eprintln!("Selection cancelled."); + eprintln!("{}", crate::ui::CANCELLED); return Err(0); } } @@ -1208,25 +1111,27 @@ pub struct DownloadParams { pub silent: bool, /// `--download-mode` value forwarded to the apply step. pub download_mode: String, - /// When `false` (the default — narrow), a PyPI package with multiple - /// release variants (`?artifact_id=...`) is filtered down to the one - /// matching the locally-installed distribution before download. When - /// `true` (`--all-releases`), every variant is downloaded. No effect - /// on ecosystems without per-release artifact_id variants. + /// When `false` (the default — narrow), a release-variant package (PyPI + /// `?artifact_id=`, RubyGems `?platform=`, Maven `?classifier=`) is + /// filtered down to the variant(s) matching the locally-installed + /// distribution before download. When `true` (`--all-releases`), every + /// variant is downloaded. No effect on ecosystems without per-release + /// variants. pub all_releases: bool, /// `--strict` forwarded to the nested apply (a beforeHash mismatch /// fails instead of warn-and-overwrite). pub strict: bool, - /// `--ecosystems` forwarded to the nested apply. Without this the - /// nested apply ran UNSCOPED over the whole manifest, so - /// `scan --ecosystems gem --sync` could mutate other ecosystems' - /// packages the user had explicitly filtered out. + /// `--ecosystems` forwarded to the nested apply, so it never touches + /// other ecosystems' packages the user filtered out. pub ecosystems: Option>, /// Persist downloaded blob content into `.socket/blobs` (the apply /// flows need it for later hook/rollback runs). Vendor flows pass /// `false`: their patch content is staged in memory and the committed /// artifact is the patch — nothing should land in `.socket/blobs`. pub persist_blobs: bool, + /// `--patch-server-url`: the extra origin whose URLs count as hosted + /// when lockfile discovery reads the project's hosted pins. + pub patch_server_url: Option, } impl DownloadParams { @@ -1265,14 +1170,6 @@ pub struct DownloadRun<'a> { pub verbose: bool, } -fn crawler_options_for(common: &GlobalArgs) -> CrawlerOptions { - CrawlerOptions { - cwd: common.cwd.clone(), - global: common.global, - global_prefix: common.global_prefix.clone(), - } -} - /// Narrow a selection of patches down to the release variant(s) present /// in each locally-installed distribution. /// @@ -1354,10 +1251,9 @@ async fn filter_to_installed_releases( // `variant_groups` is a HashMap, so both drains above are in bucket // order — which is this function's OUTPUT order, and therefore the // order the download loop emits `download.patches` / `apply.patches` - // in. Two identical runs produced different JSON. Sort the multi- - // variant bases so their warnings and kept variants are stable, and - // sort the whole kept list by purl before returning (below and at the - // early return): every sibling collection in the same envelope — + // in. Sort the multi-variant bases so their warnings and kept variants + // are stable, and sort the whole kept list by purl before returning + // (below and at the early return): every sibling collection in the same envelope — // scan's `packages`, the agent flow's `skip_records` — is purl-sorted. multi.sort_by(|a, b| a.0.cmp(&b.0)); @@ -1375,7 +1271,8 @@ async fn filter_to_installed_releases( .iter() .flat_map(|(_, variants)| variants.iter().map(|s| s.purl.clone())) .collect(); - // All collected PURLs are PyPI; no ecosystem filter needed. + // Release-variant PURLs only (PyPI / RubyGems / Maven); partition_purls + // splits them by ecosystem, so no filter is needed. let partitioned = partition_purls(&all_qualified, None); let paths = find_packages_for_rollback(&partitioned, crawler_options, true).await; @@ -1383,7 +1280,7 @@ async fn filter_to_installed_releases( // `api_concurrency` in flight) in the order the loop below consumes // them: bases in `multi` order, skipping the uninstalled ones, each // base's variants in order. Nothing here prints between fetches, and - // each request's `--debug` lines are released at its old turn. + // each request's `--debug` lines are released at its turn in that order. let installed_variants: Vec = multi .iter() .filter(|(_, variants)| variants.iter().any(|s| paths.contains_key(&s.purl))) @@ -1440,7 +1337,7 @@ async fn filter_to_installed_releases( views.insert(s.uuid.clone(), patch); } // On a fetch error/miss, keep the variant so the main - // download loop can record the failure as it would today. + // download loop records the failure. _ => candidates.push((s.purl.clone(), HashMap::new())), } } @@ -1508,47 +1405,6 @@ fn purl_has_version(purl: &str) -> bool { }) } -/// Does the raw pnpm-lock text RESOLVE `name@version`? Boundary-anchored -/// probes over the three lock grammars — a plain `contains` collided on -/// version prefixes (`left-pad@1.3.0` matched inside -/// `left-pad@1.3.0-beta.1`), name suffixes (`pad@1.3.0` inside -/// `left-pad@1.3.0`), and unscoped-inside-scoped names (`name@1.0.0` inside -/// `@scope/name@1.0.0`). The needles cover v6/v9's `name@version` and v5's -/// `/name/version` key spellings; a match counts only when the preceding -/// char cannot extend the name (start/whitespace/quote, or a `/` delimiter -/// itself preceded by such a boundary) and the following char cannot extend -/// the version (so `:`, `'`, `(`, and v5's `_peer` suffix all accept). -/// Heuristic by design: a false negative degrades to a calm skip, a false -/// positive costs one grant request the rewriter's per-dep confirmation -/// then ignores. -fn pnpm_lock_resolves(text: &str, name: &str, version: &str) -> bool { - let version_boundary = |c: char| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+')); - let name_boundary = |c: char| matches!(c, ' ' | '\t' | '\n' | '\r' | '\'' | '"'); - for needle in [format!("{name}@{version}"), format!("/{name}/{version}")] { - for (pos, _) in text.match_indices(needle.as_str()) { - let before_ok = match text[..pos].chars().next_back() { - None => true, - // v5/v6's leading key delimiter — legitimate only when the - // char before it is itself a boundary (otherwise this is a - // scoped `@scope/` tail: a DIFFERENT package). - Some('/') => text[..pos - 1] - .chars() - .next_back() - .is_none_or(name_boundary), - Some(c) => name_boundary(c), - }; - let after_ok = text[pos + needle.len()..] - .chars() - .next() - .is_none_or(version_boundary); - if before_ok && after_ok { - return true; - } - } - } - false -} - /// Outcome of the coarse installed-VERSION narrowing over a CVE/GHSA/PURL /// search fan-out (see [`filter_to_installed_purls`]). struct InstalledNarrowing { @@ -1577,16 +1433,17 @@ struct InstalledNarrowing { /// installed copy (CI manifest-maintenance); /// * hosted/vendored modes only: resolved in the project lockfile(s) /// (hosted rewrites the lock; vendored auto-fetches pristine) or claimed -/// by the vendor ledger (fresh-clone re-vendor) — mirroring scan's -/// lockfile/vendored-ledger discovery supplements, including their -/// global-scan gate. +/// by the vendor ledger (fresh-clone re-vendor) — scan's own +/// lockfile/vendored-ledger discovery supplements (a corrupt vendor +/// ledger falls back to the committed artifacts, as in scan), including +/// their global-scan gate. /// /// PnP layouts are surfaced, never silently misreported: yarn PnP packages /// are structurally unpatchable in every mode (skip records carry /// `yarn_pnp_unsupported`, not a false "not installed"). pnpm PnP skips /// carry `pnpm_pnp_unsupported` in agent/vendored modes; hosted mode — the /// refusal's own remedy — keeps the versions the raw pnpm-lock.yaml text -/// resolves ([`pnpm_lock_resolves`]), labels a judged miss +/// resolves ([`PnpmLock::resolves`]), labels a judged miss /// `package_not_installed` like any other mode, and reserves the layout /// code for an unreadable lock (no judgment possible). /// @@ -1615,27 +1472,30 @@ async fn filter_to_installed_purls( .collect() }; let partitioned = partition_purls(&bases, None); - let found = find_packages_for_rollback(&partitioned, &crawler_options_for(common), true).await; + let found = find_packages_for_rollback(&partitioned, &common.crawler_options(), true).await; let mut present: HashSet = found.keys().map(|k| canon(k)).collect(); + let ctx = super::context::ProjectContext::rooted(common, common.cwd.clone()); // Manifest membership counts as presence (read-only probe: a corrupt // manifest degrades to "no extension" here — the download path's // fail-closed read still guards every write). - if let Ok(Some(manifest)) = read_manifest(&common.resolved_manifest_path()).await { + if let Some(manifest) = ctx.ledgers().await.manifest { present.extend(manifest.patches.keys().map(|k| canon(k))); } - // Lockfile + vendor-ledger supplements (scan's discovery gate: never on - // global scans, which target the machine tree, not this project). + // scan's lockfile + vendored-ledger discovery supplements (and their + // gate: never on global scans, which target the machine tree, not this + // project). let mut pnp_diags: Vec = Vec::new(); - if !common.global && common.global_prefix.is_none() { - let (entries, unsupported) = lock_inventory::inventory_project_diagnosed(&common.cwd).await; - pnp_diags = unsupported; + if !common.is_global() { + let supplement = super::scan::project_lockfile_supplement(&ctx, &[], None).await; + pnp_diags = supplement.unsupported; if mode != super::scan::ScanMode::Agent { - present.extend(entries.iter().map(|e| canon(&e.purl))); - if let Ok(state) = socket_patch_core::vendor::load_state(&common.cwd).await { - present.extend(state.entries.values().map(|e| canon(&e.base_purl))); - } + present.extend(supplement.entries.iter().map(|e| canon(&e.purl))); + let vendored = + super::scan::project_vendored_supplement(common, &[], &ctx.loaded().await.vendor) + .await; + present.extend(vendored.iter().map(|p| canon(&p.purl))); } } @@ -1655,6 +1515,7 @@ async fn filter_to_installed_purls( let pnpm_pnp_lock_text: Option = (pnp_pnpm && mode == super::scan::ScanMode::Hosted) .then(|| std::fs::read_to_string(common.cwd.join("pnpm-lock.yaml")).ok()) .flatten(); + let pnpm_pnp_lock = pnpm_pnp_lock_text.as_deref().map(PnpmLock::parse); let mut out = InstalledNarrowing { kept: Vec::new(), @@ -1684,8 +1545,8 @@ async fn filter_to_installed_purls( // The pnpm PnP refusal's own remedy is the hosted lockfile // rewrite — but only for versions the lock ACTUALLY resolves: // keeping the whole fan-out would request grants for every - // version ever patched. Anchored probe over the raw lock text - // (see `pnpm_lock_resolves`); a hit is kept (the rewriter's + // version ever patched. The lock model's key probe + // (`PnpmLock::resolves`); a hit is kept (the rewriter's // per-dep confirmation still decides). A judged MISS is a // genuine "version not resolved" verdict — the layout blocked // nothing — so it carries the same `package_not_installed` code @@ -1694,9 +1555,9 @@ async fn filter_to_installed_purls( let decoded = canon(&result.purl); let coord = decoded.strip_prefix("pkg:npm/").unwrap_or(&decoded); if mode == super::scan::ScanMode::Hosted { - match (pnpm_pnp_lock_text.as_deref(), coord.rsplit_once('@')) { - (Some(text), Some((name, version))) => { - if pnpm_lock_resolves(text, name, version) { + match (&pnpm_pnp_lock, coord.rsplit_once('@')) { + (Some(lock), Some((name, version))) => { + if lock.resolves(name, version) { out.kept.push(result.clone()); continue; } @@ -1864,10 +1725,9 @@ type LockRefusals = HashMap; /// classic / yarn berry gates and cargo's locked-version gate), over the /// patches the phase would otherwise fetch a view for — past the Bun /// refusal and the ledger's idempotency skip, which take precedence in the -/// fetch loop. A purl the hosted redirect ledger claims is left to the -/// vendor loop: its takeover reverts the hosted lock edits first, and the -/// revert rewrites the very text the gates read. A redirect ledger that -/// cannot be read leaves every purl to the loop. +/// fetch loop. A purl the lockfiles pin hosted is left to the vendor loop: +/// its takeover restores the upstream lock entry first, and the restore +/// rewrites the very text the gates read. /// /// Only a package the vendor loop would hand to its backend is refused /// here (see [`crate::commands::vendor::lock_refusals_reaching_backend`]): @@ -1885,11 +1745,23 @@ async fn lock_text_refusals_for( ) -> LockRefusals { let cwd = params.cwd.as_path(); let claimed: Vec = - match socket_patch_core::patch::redirect::load_redirect_state(cwd).await { - Ok(Some(state)) => state.records.keys().map(|k| canonical_purl(k)).collect(), - Ok(None) => Vec::new(), - Err(_) => return HashMap::new(), - }; + socket_patch_core::patch::redirect::upstream::HostedPin::all( + &socket_patch_core::vex::discover_patched_refs_with( + cwd, + &socket_patch_core::vex::DiscoverOptions { + patch_server_origins: params + .patch_server_url + .iter() + .filter(|url| !url.trim().is_empty()) + .cloned() + .collect(), + }, + ) + .await, + ) + .into_iter() + .map(|pin| canonical_purl(&pin.purl)) + .collect(); let candidates: Vec<(&str, &str)> = selected .iter() .filter(|sr| bun_refusal.filter(|r| r.applies_to(&sr.purl)).is_none()) @@ -1901,11 +1773,7 @@ async fn lock_text_refusals_for( .map(|sr| (sr.purl.as_str(), sr.uuid.as_str())) .collect(); let refused = socket_patch_core::vendor::lock_text_refusals(cwd, &candidates).await; - let options = CrawlerOptions { - cwd: params.cwd.clone(), - global: params.global, - global_prefix: params.global_prefix.clone(), - }; + let options = params.crawler_options(); crate::commands::vendor::lock_refusals_reaching_backend( cwd, refused, @@ -1955,7 +1823,7 @@ async fn fetch_selected_patches( // --all-releases was passed (a no-op for non-variant ecosystems and // single-variant packages). The views it fetched serve the loop below. // The narrowing queries the API: show that something is happening - // right after the confirm prompt. + // once selection (and get's confirm prompt) is done. let mut status = crate::ui::StatusLine::stderr(params.json, params.silent); status.set("Preparing download..."); let (selected, warnings, views) = filter_to_installed_releases( @@ -1968,7 +1836,8 @@ async fn fetch_selected_patches( .await; status.finish(); prefetched.extend(views); - // No leading blank line: the prompt's answer already ended its line. + // No leading blank line: the caller's prompt or summary already ended + // its line. if matches!(store, RecordStore::Manifest(_)) && !quiet { eprintln!( "Downloading {}...", @@ -1991,10 +1860,9 @@ async fn fetch_selected_patches( // (the same three checks, in the loop's order, over inputs the loop // never mutates) — run concurrently ahead of it, at most // `api_concurrency` in flight, and come back in selection order. The - // loop takes the next one exactly where it used to await the request, - // and each request's `--debug` lines print there too, so stdout, the - // per-patch stderr lines and the JSON records fold exactly as the - // serial loop's did. + // loop takes the next one where it would await the request, and each + // request's `--debug` lines print there too, so stdout, the per-patch + // stderr lines and the JSON records fold in selection order. let mut held: std::collections::HashSet<&str> = prefetched.keys().map(String::as_str).collect(); let to_fetch: Vec<&str> = selected .iter() @@ -2240,8 +2108,8 @@ pub(crate) type DetachedDownload = ( /// /// `api_client` is the run's client (built once, proxy fallback included). /// `prefetched` maps uuid → an already-fetched view: the `get ` path -/// resolved its identifier by fetching the view, and scan's interactive -/// arm pre-verified baselines from the views — neither must fetch again (a +/// resolved its identifier by fetching the view, and scan's vendored arm +/// pre-verified baselines from the views — neither must fetch again (a /// fresh fetch could re-hit the 401 the proxy fallback just recovered /// from). The ledger idempotency check runs before the cache lookup, and a /// cache miss still fetches. @@ -2276,9 +2144,9 @@ pub(crate) async fn download_patch_records_reusing( let vendor_state = load_state(¶ms.cwd).await; // Bun preflight (see `BunVendorRefusal`): this phase feeds the vendor // engine, so it must refuse the same projects BEFORE fetching — - // otherwise the view was downloaded for nothing and a package - // resolvable only through the unreadable bun.lockb inventory - // misreported `package_not_installed` instead of the real + // otherwise the view is downloaded for nothing and a package + // resolvable only through the unreadable bun.lockb inventory is + // misreported as `package_not_installed` instead of the real // `vendor_bun_*` code. npm-only, so release narrowing (PyPI / RubyGems / // Maven variants) cannot change its verdict. let bun_refusal = bun_vendor_preflight_with_ledger( @@ -2453,10 +2321,9 @@ fn nested_apply_args_from_params( global_prefix: params.global_prefix.clone(), download_mode: params.download_mode.clone(), strict: params.strict, - // Scope the nested apply like the caller was scoped: leaving this - // at the default `None` made `scan --ecosystems gem --sync` apply - // the WHOLE manifest, mutating other ecosystems' packages the user - // filtered out. + // Scope the nested apply like the caller was scoped: `None` would + // apply the WHOLE manifest, mutating other ecosystems' packages the + // user filtered out. ecosystems: params.ecosystems.clone(), lock_timeout: run.lock_timeout, verbose: run.verbose, @@ -2472,9 +2339,9 @@ fn nested_apply_args_from_params( /// its last mutation is done. Returns whether apply exited 0. Callers print /// their own "Applying patches..." line. `json` is the caller's flag: a /// JSON caller gets no human error lines, from this function or from the -/// nested apply (`common` itself is never JSON). The read-only cargo-redirect verifier stays off -/// and embedded VEX is opt-in on the top-level command only, never on this -/// internal invocation. +/// nested apply (`common` itself is never JSON). The read-only `--check` +/// redirect verifier stays off and embedded VEX is opt-in on the top-level +/// command only, never on this internal invocation. async fn run_nested_apply( common: GlobalArgs, json: bool, @@ -2500,7 +2367,7 @@ async fn run_nested_apply( /// Download the selected patches into `.socket/` (manifest records + /// blobs) and, unless `save_only`, apply them in place — the agent-mode -/// engine behind `get` and `scan --apply/--sync`, over the caller's +/// engine behind `get` and `scan --mode agent`, over the caller's /// run-level context (`run`: the client the run already built, plus the /// `--lock-timeout` / `--verbose` the manifest lock and the nested apply /// honor). Returns `(exit_code, json)`. @@ -2516,8 +2383,8 @@ pub async fn download_and_apply_patches_with( // The manifest read-modify-write — and the blob writes it records — // runs under the apply lock: `remove`/`rollback` RMW the same file under - // it, and an unlocked writer here lost their update or had its own - // record clobbered. `acquire` creates `.socket/` itself; the guard's + // it, and an unlocked writer here would lose their update or have its + // own record clobbered. `acquire` creates `.socket/` itself; the guard's // drop removes `apply.lock` and prunes an otherwise-empty `.socket/`, so // a run that records nothing leaves no residue. The nested apply runs // under this SAME guard (one lock window; see `run_nested_apply`). @@ -2679,20 +2546,16 @@ pub async fn run(args: GetArgs) -> i32 { args.common.json, "Only one of --id, --cve, --ghsa, or --package can be specified", ); - return 1; + return 2; } - if args.one_off && args.save_only { - report_error( - args.common.json, - "--one-off and --save-only cannot be used together", - ); - return 1; - } - // Mode resolution mirrors scan's enum (default = agent, today's - // behavior). Conflicts use get's established exit-1 report_error style - // (scan's self-enforced conflicts exit 2; get's have always been 1 — - // documented carve-out in CLI_CONTRACT.md). - let mode = args.mode.unwrap_or(super::scan::ScanMode::Agent); + // v5: hosted by default, like scan. `--save-only` (records a manifest + // entry) and global installs (no project lockfile) mean agent mode. + // Usage errors exit 2, like clap's and scan's (v5.0). + let mode = args.mode.unwrap_or(if args.save_only || args.common.is_global() { + super::scan::ScanMode::Agent + } else { + super::scan::ScanMode::Hosted + }); if args.save_only && mode != super::scan::ScanMode::Agent { report_error( args.common.json, @@ -2703,16 +2566,7 @@ pub async fn run(args: GetArgs) -> i32 { mode.cli_name() ), ); - return 1; - } - if args.one_off { - // Honest failure instead of the historical silent no-op: the flag - // parsed but was never implemented, so the patch was saved to the - // manifest anyway — lying to the user about persistence. Mirrors - // `rollback --one-off`'s not-yet-implemented contract; rejected - // before any network or disk activity. - report_error(args.common.json, "One-off get mode is not yet implemented"); - return 1; + return 2; } // Strict airgap (CLI_CONTRACT.md `--offline`: never contact the // network; operations that need remote data fail loudly). Every `get` @@ -2746,7 +2600,7 @@ pub async fn run(args: GetArgs) -> i32 { if args.id || args.cve || args.ghsa { if let Some(err) = forced_identifier_error(&args.identifier, id_type) { report_error(args.common.json, err); - return 1; + return 2; } } @@ -2851,7 +2705,7 @@ pub async fn run(args: GetArgs) -> i32 { // 401/403 the fallback just recovered from. An explicit // UUID is exempt from installed narrowing (exact intent). return match mode { - // Save to manifest and apply in place (today's flow). + // Save to manifest and apply in place. super::scan::ScanMode::Agent => { save_and_apply_patch(&args, &api_client, &patch).await } @@ -2956,7 +2810,7 @@ pub async fn run(args: GetArgs) -> i32 { IdentifierType::Package => { status.set("Enumerating packages..."); let (all_packages, _, _) = - crawl_all_ecosystems(&crawler_options_for(&args.common)).await; + crawl_all_ecosystems(&args.common.crawler_options()).await; if all_packages.is_empty() { status.finish(); @@ -3058,8 +2912,8 @@ pub async fn run(args: GetArgs) -> i32 { "{}", format_search_results(&all, search_response.can_access_paid_patches, color) ); - println!("All available patches require a paid subscription."); - println!(" Upgrade at: https://socket.dev/pricing"); + println!("All available patches require a paid Socket plan."); + println!("{}", crate::ui::PAID_UPGRADE); } return 0; } @@ -3081,7 +2935,7 @@ pub async fn run(args: GetArgs) -> i32 { // included, so the listing can still show an installed package's paid // fix as `[PAID] (no access)`; selection, the skip records and the // JSON envelope only ever see the accessible share. - let (accessible, listed, narrow_skips, narrow_warnings) = if narrowing_exempt { + let (accessible, listed, narrow_skips, mut narrow_warnings) = if narrowing_exempt { let listed: Vec = search_response.patches.clone(); (accessible, listed, Vec::new(), Vec::new()) } else { @@ -3102,12 +2956,14 @@ pub async fn run(args: GetArgs) -> i32 { .collect(); (kept_accessible, narrowing.kept, skips, narrowing.warnings) }; + // `get` bypasses the repo's socket.yml policy, but says so. + narrow_warnings.extend(super::scan::policy::policy_bypass_warnings(&args.common, &accessible)); // Layout refusals print even when informational output is quieted only // by --json (stderr; the envelope carries them too) — but --silent // mutes them like scan does. if !args.common.silent { - for (code, detail) in &narrow_warnings { - eprintln!("Warning ({code}): {detail}"); + for (_, detail) in &narrow_warnings { + eprintln!("Warning: {detail}"); } } if accessible.is_empty() { @@ -3165,10 +3021,18 @@ pub async fn run(args: GetArgs) -> i32 { // Smart patch selection: pick one patch per PURL. `accessible` is // non-empty here and every entry passes the selector's tier filter, so // the selection is never empty (one patch per purl group, or `Err`). + // Hosted and vendored `get` never prompt (v5.0): like `scan`, they take + // the top-ranked accessible patch per package, in JSON mode too. + let auto_pick = mode != super::scan::ScanMode::Agent; + let select_common = if auto_pick { + super::scan::selection_args(&args.common) + } else { + args.common.clone() + }; let selected = match select_patches( &accessible, - search_response.can_access_paid_patches, - &args.common, + auto_pick || search_response.can_access_paid_patches, + &select_common, ) { Ok(s) => s, Err(code) => return code, @@ -3183,7 +3047,7 @@ pub async fn run(args: GetArgs) -> i32 { && !selection_prompted( &accessible, search_response.can_access_paid_patches, - &args.common, + &select_common, ) { print!("{}", format_selected_patches(&selected, color)); @@ -3198,7 +3062,7 @@ pub async fn run(args: GetArgs) -> i32 { let (selected, variant_warnings, _views) = filter_to_installed_releases( &selected, args.all_releases, - &crawler_options_for(&args.common), + &args.common.crawler_options(), quiet, &api_client, ) @@ -3212,14 +3076,17 @@ pub async fn run(args: GetArgs) -> i32 { return agent_dry_run(&args, &selected, &narrow_skips, &narrow_warnings).await; } - // Confirm before acting (default YES), with mode-appropriate wording. - // Dry runs skip the prompt: nothing mutates, so nothing to confirm. - let prompt = format_confirm_prompt(mode, selected.len(), args.save_only); - if !args.common.dry_run && !crate::ui::confirm(&prompt, true, &args.common) { - if !quiet { - eprintln!("Cancelled; no changes made."); + // Agent mode confirms before acting (default YES). Dry runs skip the + // prompt: nothing mutates, so nothing to confirm. Hosted and vendored + // runs never prompt (v5.0), like `scan`. + if mode == super::scan::ScanMode::Agent && !args.common.dry_run { + let prompt = format_confirm_prompt(args.save_only, selected.len()); + if !crate::ui::confirm(&prompt, true, &args.common) { + if !quiet { + eprintln!("{}", crate::ui::CANCELLED); + } + return 0; } - return 0; } match mode { @@ -3237,7 +3104,7 @@ pub async fn run(args: GetArgs) -> i32 { let (selected, variant_warnings, _views) = filter_to_installed_releases( &selected, args.all_releases, - &crawler_options_for(&args.common), + &args.common.crawler_options(), quiet, &api_client, ) @@ -3678,12 +3545,13 @@ fn get_download_params(args: &GetArgs, save_only: bool, persist_blobs: bool) -> strict: args.common.strict, ecosystems: args.common.ecosystems.clone(), persist_blobs, + patch_server_url: args.common.patch_server_url.clone(), } } /// `get … --mode hosted`: hand the selected (purl, uuid) pairs to scan's /// hosted engine ([`super::scan::boxed_run_redirect_selected`]) — lockfile -/// rewrite + redirect ledger, no manifest, no blobs — so the on-disk result +/// rewrite only, no manifest, no blobs, no ledger — so the on-disk result /// matches `scan --mode hosted` selecting the same patches. The engine owns /// all output (and honors `--dry-run` internally); in JSON mode it nests its /// `redirect` block into the get base envelope passed as `scan_result`. @@ -3722,6 +3590,8 @@ async fn run_get_hosted( &pairs, scan_result, None, + // `get` is explicit intent: the rollout cap never applies. + None, ) .await } @@ -3884,36 +3754,28 @@ async fn run_get_vendored( )) .await }; - let mut has_errors = dl_code != 0; fold_narrowing_into_result(&mut result, narrow_skips, narrow_warnings); // The vendor step (scan's, verbatim): apply lock, in-memory staging // seeded with the blobs fetched above, the engine over exactly the // records fetched above (moved in — nothing here needs them afterwards) - // and over this run's client. A per-patch download failure does not - // skip it (scan parity). - match super::scan::boxed_scan_vendor_step( - &args.common, + // and over this run's client, then the run's telemetry. A per-patch + // download failure does not skip it (scan parity). + match super::scan::boxed_vendor_step(super::scan::VendorStep { + common: &args.common, records, - blobs, - api_client.clone(), + seed: blobs, + client: api_client.clone(), use_public_proxy, - ) + report_empty: true, + prior: None, + download_errors: dl_code != 0, + telemetry_token, + telemetry_org, + }) .await { - Ok((vendor_errors, venv)) => { - has_errors |= vendor_errors; - // Telemetry follows the RUN outcome, not the vendor step alone: - // a download-phase refusal/failure exits 1 and must not report - // a successful vendoring of zero patches (scan's arms agree). - crate::commands::vendor::track_outcomes_for_vendor( - has_errors, - &venv, - args.common.dry_run, - telemetry_token, - telemetry_org, - ) - .await; + Ok((has_errors, venv)) => { if args.common.json { result["status"] = serde_json::json!(if has_errors { "partial_failure" @@ -3927,13 +3789,6 @@ async fn run_get_vendored( i32::from(has_errors) } Err((code, message, venv)) => { - socket_patch_core::telemetry::track_patch_vendor_failed( - &message, - args.common.dry_run, - telemetry_token, - telemetry_org, - ) - .await; if args.common.json { // A vendor envelope built before the failure (events // included) must reach the JSON consumer even though the @@ -3957,10 +3812,8 @@ async fn run_get_vendored( } /// Decode a patch view's `blobContent` (canonical, padded base64 as the API -/// produces it). Hand-rolled only because `base64` is a dev-dependency of -/// this crate today — once it is a plain dependency (it already is one of -/// `socket-patch-core`, pinned workspace-wide), this body should become -/// `base64::engine::general_purpose::STANDARD.decode(input)` with +/// produces it). Hand-rolled; swapping in +/// `base64::engine::general_purpose::STANDARD.decode(input)` must keep /// `DecodeError::InvalidByte(_, b)` mapped to the /// `Invalid base64 character: ` message below (pinned by a unit test). pub(crate) fn base64_decode(input: &str) -> Result, String> { @@ -4000,77 +3853,6 @@ pub(crate) fn base64_decode(input: &str) -> Result, String> { mod tests { use super::*; - /// The pnpm-PnP hosted lock probe must be boundary-anchored: plain - /// substring matching collided on version prefixes, name suffixes, and - /// unscoped-inside-scoped names (follow-up review finding). - #[test] - fn pnpm_lock_resolves_is_boundary_anchored() { - // v9/v6/v5 key spellings all resolve. - assert!(pnpm_lock_resolves( - "lockfileVersion: '9.0'\n\nsnapshots:\n\n left-pad@1.3.0:\n", - "left-pad", - "1.3.0" - )); - assert!(pnpm_lock_resolves( - " /left-pad@1.3.0:\n resolution: {}\n", - "left-pad", - "1.3.0" - )); - assert!(pnpm_lock_resolves( - " /left-pad/1.3.0:\n resolution: {}\n", - "left-pad", - "1.3.0" - )); - // Peer-qualified keys still resolve: v9 `(peer)` and v5 `_peer`. - assert!(pnpm_lock_resolves( - " 'left-pad@1.3.0(react@18.0.0)':\n", - "left-pad", - "1.3.0" - )); - assert!(pnpm_lock_resolves( - " /left-pad/1.3.0_react@18.0.0:\n", - "left-pad", - "1.3.0" - )); - // Scoped names resolve in both quoted-v9 and v6 spellings. - assert!(pnpm_lock_resolves( - " '@scope/name@1.0.0':\n", - "@scope/name", - "1.0.0" - )); - assert!(pnpm_lock_resolves( - " /@scope/name@1.0.0:\n", - "@scope/name", - "1.0.0" - )); - - // Version-prefix collision: 1.3.0 must NOT match 1.3.0-beta.1. - assert!(!pnpm_lock_resolves( - " left-pad@1.3.0-beta.1:\n", - "left-pad", - "1.3.0" - )); - // Name-suffix collision: `pad` must NOT match inside `left-pad`. - assert!(!pnpm_lock_resolves(" left-pad@1.3.0:\n", "pad", "1.3.0")); - assert!(!pnpm_lock_resolves(" /left-pad/1.3.0:\n", "pad", "1.3.0")); - // Unscoped-inside-scoped: `name` must NOT match `@scope/name`. - assert!(!pnpm_lock_resolves( - " '@scope/name@1.0.0':\n", - "name", - "1.0.0" - )); - assert!(!pnpm_lock_resolves( - " /@scope/name@1.0.0:\n", - "name", - "1.0.0" - )); - // Absent version: never resolves. - assert!(!pnpm_lock_resolves( - " left-pad@1.3.0:\n", - "left-pad", - "2.0.0" - )); - } use socket_patch_core::api::types::{PatchFileResponse, VulnerabilityResponse}; use std::collections::HashMap; @@ -4209,9 +3991,9 @@ mod tests { #[test] fn select_paid_user_picks_highest_severity_not_most_recent() { - // The reported bug. An authorized user's package has a fresh `low` - // patch and an older `critical` one; the old selector took the - // newest and silently left the critical unfixed. + // An authorized user's package has a fresh `low` patch and an older + // `critical` one: taking the newest would leave the critical + // unfixed. let patches = vec![ mk_patch_sev("new_low", "pkg:npm/foo@1.0", "paid", "2026-06-01", "low"), mk_patch_sev( @@ -4295,36 +4077,34 @@ mod tests { } #[test] - fn select_prefers_a_higher_severity_patch_over_the_merged_one() { - // The exception. A merged patch must not shadow a worse - // vulnerability: `z_critical` addresses a CRITICAL the merged patch - // does not cover, so it wins despite being older, single-advisory, - // and last by uuid. + fn select_prefers_the_merged_patch_over_a_higher_severity_one() { + // A merged patch is the cumulative fix, so it wins even against a + // newer single-advisory CRITICAL. let patches = vec![ - mk_patch_multi( - "a_merged", + mk_patch_sev( + "a_critical", "pkg:npm/foo@1.0", "free", "2026-06-01", - &["high", "high"], + "critical", ), - mk_patch_sev( - "z_critical", + mk_patch_multi( + "z_merged", "pkg:npm/foo@1.0", "free", "2020-01-01", - "critical", + &["high", "high"], ), ]; let out = select_patches(&patches, true, &human_args()).expect("ok"); assert_eq!(out.len(), 1); - assert_eq!(out[0].uuid, "z_critical"); + assert_eq!(out[0].uuid, "z_merged"); } #[test] fn select_recency_is_chronological_not_lexicographic() { - // `publishedAt` is RFC 2822 on the wire, so the old raw-string - // compare ordered by weekday name. With equal severities the newer + // `publishedAt` is RFC 2822 on the wire, so a raw-string compare + // would order by weekday name. With equal severities the newer // patch must win regardless of which weekday it fell on. let older = "Wed, 01 Jan 2025 00:00:00 GMT"; let newer = "Fri, 01 Aug 2026 00:00:00 GMT"; @@ -4769,10 +4549,8 @@ mod tests { // --- run_outcome ----------------------------------------------------- // The `status` field and the process exit code are derived from the - // same predicate. Regression guard: a failed *apply* step (no download - // failures) must still report `partial_failure` AND exit 1 — the old - // code keyed `status` only on download failures, so it printed - // `success` next to a non-zero exit code. + // same predicate: a failed *apply* step (no download failures) must + // still report `partial_failure` AND exit 1. #[test] fn run_outcome_clean_is_success_exit_zero() { @@ -4919,12 +4697,11 @@ mod tests { } // --- files_for_manifest / files_with_both_hashes --------------------- - // Regression guards for the download/scan/vendor record builder: a - // net-new file (afterHash, NO beforeHash) that the patch ADDS must be - // retained in the manifest record, not silently dropped. Real prod - // repro: the whole-crate cargo export for `pkg:cargo/traitobject@0.1.1` - // publishes ALL files with only an afterHash — the old both-hashes rule - // recorded `files:{}` and reported `applied:1` while writing nothing. + // The download/scan/vendor record builder: a net-new file (afterHash, NO + // beforeHash) that the patch ADDS must be retained in the manifest + // record, not silently dropped. E.g. the whole-crate cargo export for + // `pkg:cargo/traitobject@0.1.1` publishes ALL files with only an + // afterHash. fn file_resp(before: Option<&str>, after: Option<&str>) -> PatchFileResponse { PatchFileResponse { @@ -4974,8 +4751,8 @@ mod tests { assert_eq!(added.before_hash, ""); assert_eq!(added.after_hash, "a".repeat(64)); - // The old both-hashes rule (still used for installed-variant - // matching) DROPS the added file — this is the behavior we fixed. + // The both-hashes rule (used only for installed-variant matching) + // drops the added file. let strict = files_with_both_hashes(&patch); assert_eq!(strict.len(), 1); assert!(!strict.contains_key("lib/rubygems_plugin.rb")); @@ -4983,9 +4760,8 @@ mod tests { #[test] fn files_for_manifest_keeps_all_new_file_whole_crate_export() { - // The P0 cargo case: EVERY file is a whole-crate export with only - // an afterHash. The old rule produced `files:{}`; the fix retains - // all 9 so the record is non-empty and can actually be applied. + // EVERY file is a whole-crate export with only an afterHash: all 9 + // are retained so the record is non-empty and can be applied. let mut files = HashMap::new(); for i in 0..9 { files.insert( @@ -4999,7 +4775,7 @@ mod tests { assert_eq!(kept.len(), 9, "all whole-crate-export files must be kept"); assert!(kept.values().all(|f| f.before_hash.is_empty())); - // Guardrail precondition: with the old rule this map was empty. + // Guardrail precondition: the both-hashes rule yields an empty map. assert!(files_with_both_hashes(&patch).is_empty()); } @@ -5055,36 +4831,6 @@ mod tests { ); } - // --- pnpm_lock_resolves: needle at byte 0 ------------------------------ - // The boundary probe reads the char BEFORE the match; a match at the very - // start of the text has none (`None => true`). A regression that indexes - // `text[..pos - 1]` unconditionally would underflow/panic here. - - #[test] - fn pnpm_lock_resolves_needle_at_start_of_text() { - // pos == 0, plain v9 spelling: no preceding char is a valid boundary. - assert!(pnpm_lock_resolves("left-pad@1.3.0:\n", "left-pad", "1.3.0")); - // pos == 0, v5/v6 `/name/version` and `/name@version` spellings: the - // leading `/` delimiter itself has nothing before it. - assert!(pnpm_lock_resolves( - "/left-pad/1.3.0:\n", - "left-pad", - "1.3.0" - )); - assert!(pnpm_lock_resolves( - "/left-pad@1.3.0:\n", - "left-pad", - "1.3.0" - )); - // Still boundary-checked at the start of text: a scoped tail whose - // name begins mid-token must NOT match. - assert!(!pnpm_lock_resolves( - "@scope/left-pad@1.3.0:\n", - "left-pad", - "1.3.0" - )); - } - // --- write_all_patch_blobs --------------------------------------------- // The per-patch fan-out over write_blob_entry: the FIRST bad entry must // fail the whole patch (Err(())) and leave nothing outside the blobs @@ -5246,6 +4992,7 @@ mod tests { strict: false, ecosystems: None, persist_blobs: false, + patch_server_url: None, } } @@ -5564,32 +5311,10 @@ mod tests { } #[test] - fn confirm_prompts_per_mode() { - use super::super::scan::ScanMode; - assert_eq!( - format_confirm_prompt(ScanMode::Agent, 1, false), - "Download and apply 1 patch?" - ); - assert_eq!( - format_confirm_prompt(ScanMode::Agent, 2, false), - "Download and apply 2 patches?" - ); - assert_eq!( - format_confirm_prompt(ScanMode::Agent, 1, true), - "Download 1 patch?" - ); - assert_eq!( - format_confirm_prompt(ScanMode::Vendored, 3, false), - "Download and vendor 3 patches?" - ); - assert_eq!( - format_confirm_prompt(ScanMode::Hosted, 1, false), - "Redirect 1 package to the hosted patch server?" - ); - assert_eq!( - format_confirm_prompt(ScanMode::Hosted, 0, false), - "Redirect 0 packages to the hosted patch server?" - ); + fn confirm_prompts_agent_mode() { + assert_eq!(format_confirm_prompt(false, 1), "Download and apply 1 patch?"); + assert_eq!(format_confirm_prompt(false, 2), "Download and apply 2 patches?"); + assert_eq!(format_confirm_prompt(true, 1), "Download 1 patch?"); } #[test] @@ -5617,9 +5342,9 @@ mod tests { fn paid_required_text() { assert_eq!( format_paid_required("pkg:npm/a@1"), - "This patch requires a paid subscription to download.\n \ - Patch: pkg:npm/a@1\n \ - Upgrade at: https://socket.dev/pricing" + "This patch requires a paid Socket plan.\n \ + Patch: pkg:npm/a@1\n\ + Upgrade to a paid Socket plan to access all patches: https://socket.dev/pricing" ); } @@ -5856,8 +5581,6 @@ mod tests { "parse_bool_flag", "No env binding", "locally- installed", - "SOCKET_ONE_OFF", - "--one-off", ] { assert!(!help.contains(leak), "get --help leaks {leak:?}:\n{help}"); } @@ -5914,6 +5637,7 @@ mod tests { ecosystems: None, // The vendor-detached posture this fn exists for. persist_blobs: false, + patch_server_url: None, } } @@ -5951,7 +5675,7 @@ mod tests { use wiremock::matchers::{method, path as wm_path}; use wiremock::{Mock, MockServer, ResponseTemplate}; - let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL"]); let server = MockServer::start().await; let uuid = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; let purl = "pkg:npm/covgap-no-after@1.0.0"; @@ -5995,7 +5719,7 @@ mod tests { async fn download_patch_records_view_404_is_fetch_miss() { use wiremock::MockServer; - let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL"]); // No view mock mounted: wiremock answers 404, which the API client // maps to Ok(None) — the "could not fetch details" fetch-miss arm. let server = MockServer::start().await; @@ -6022,7 +5746,7 @@ mod tests { async fn download_patch_records_uninstalled_variant_base_warns_and_keeps_all() { use wiremock::MockServer; - let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL"]); // Two qualified PyPI variants sharing an UNINSTALLED base: release // narrowing must keep both (with the not-installed warning), and the // warnings key must ride the detached envelope. Views stay unmounted @@ -6064,7 +5788,7 @@ mod tests { ); } - // --- coverage mop-up (2026-09 final wave) ------------------------------- + // --- misc edge cases ----------------------------------------------------- /// `merge_metadata` is a best-effort splice: a non-object record (or a /// non-object metadata value) must be left untouched, never panic — @@ -6309,7 +6033,7 @@ mod tests { use wiremock::matchers::{method, path as wm_path}; use wiremock::{Mock, MockServer, ResponseTemplate}; - let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL"]); let server = MockServer::start().await; let uuid = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; let purl = "pkg:npm/covgap-blobfail@1.0.0"; @@ -6360,7 +6084,7 @@ mod tests { use wiremock::matchers::{method, path as wm_path}; use wiremock::{Mock, MockServer, ResponseTemplate}; - let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL"]); let server = MockServer::start().await; let uuid = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; let purl = "pkg:npm/covgap-badblob@1.0.0"; @@ -6414,7 +6138,7 @@ mod tests { use wiremock::matchers::{method, path as wm_path}; use wiremock::{Mock, MockServer, ResponseTemplate}; - let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL"]); let server = MockServer::start().await; let good_uuid = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; let good_purl = "pkg:npm/covgap-good@1.0.0"; @@ -6494,7 +6218,7 @@ mod tests { async fn download_patch_records_already_vendored_detached_skips_offline() { use wiremock::MockServer; - let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL"]); let server = MockServer::start().await; // trap: no mounts let tmp = tempfile::tempdir().unwrap(); let uuid = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; @@ -6563,7 +6287,7 @@ mod tests { // The detached download phase must refuse the same Bun projects the // manifest-tracked one does, BEFORE any view fetch (request-log oracle), // and with the vendor code (never the downstream `package_not_installed` - // the alias-shaped lockb project used to degrade to). + // the alias-shaped lockb project would otherwise degrade to). /// A real bun 1.3.14 lockfileVersion-1 workspace lock (matrix capture /// grammar): 1-tuple `workspace:` entry, blank line between entries, @@ -6600,7 +6324,7 @@ mod tests { use wiremock::matchers::{method, path as wm_path}; use wiremock::{Mock, MockServer, ResponseTemplate}; - let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL"]); let server = MockServer::start().await; let uuid = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; let purl = "pkg:npm/covgap-bun@1.0.0"; @@ -6657,7 +6381,7 @@ mod tests { async fn download_patch_records_bun_v1_workspace_refuses_before_fetch() { use wiremock::MockServer; - let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL"]); let server = MockServer::start().await; // trap: no mounts let tmp = tempfile::tempdir().unwrap(); std::fs::write(tmp.path().join("bun.lock"), BUN_V1_WORKSPACE_LOCK).unwrap(); @@ -6698,7 +6422,7 @@ mod tests { async fn download_patch_records_bun_refusal_skips_non_npm_purls() { use wiremock::MockServer; - let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL"]); let server = MockServer::start().await; let tmp = tempfile::tempdir().unwrap(); std::fs::write(tmp.path().join("bun.lockb"), b"\x00binary").unwrap(); @@ -6729,7 +6453,7 @@ mod tests { async fn download_patch_records_bun_refusal_rejects_unwired_ledger_entries() { use wiremock::MockServer; - let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL"]); let server = MockServer::start().await; let tmp = tempfile::tempdir().unwrap(); std::fs::write(tmp.path().join("bun.lock"), BUN_V1_WORKSPACE_LOCK).unwrap(); @@ -6882,9 +6606,8 @@ mod tests { ); } - /// The nested apply inherits the caller's flags verbatim (`--verbose` - /// and `--strict` were dropped when its args were rebuilt from Default), - /// with `json`/`dry_run` forced off — one JSON document per run, and + /// The nested apply inherits the caller's flags verbatim (`--verbose`, + /// `--strict`, …), with `json`/`dry_run` forced off — one JSON document per run, and /// agent-mode `get` ignores `--dry-run` — `silent` following the caller's /// quiet gate, and the manifest path absolutized so apply does not /// re-resolve it against its own `--cwd`. @@ -6953,7 +6676,7 @@ mod tests { async fn download_patch_records_with_prefetched_view_never_fetches() { use wiremock::MockServer; - let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL"]); let server = MockServer::start().await; // trap: no mounts let tmp = tempfile::tempdir().unwrap(); // Two files: one with served `blobContent` (→ the blob seed), one @@ -7021,7 +6744,7 @@ mod tests { use wiremock::matchers::{method, path as wm_path}; use wiremock::{Mock, MockServer, ResponseTemplate}; - let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL"]); let server = MockServer::start().await; let purl = "pkg:npm/covgap-supersede@1.0.0"; let old_uuid = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; @@ -7091,7 +6814,7 @@ mod tests { use wiremock::matchers::{method, path as wm_path}; use wiremock::{Mock, MockServer, ResponseTemplate}; - let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL"]); let server = MockServer::start().await; let uuid = |c: char| { format!("{0}{0}{0}{0}{0}{0}{0}{0}-{0}{0}{0}{0}-4{0}{0}{0}-8{0}{0}{0}-{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}", c) @@ -7266,7 +6989,7 @@ mod tests { use wiremock::matchers::{method, path as wm_path}; use wiremock::{Mock, MockServer, ResponseTemplate}; - let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL"]); let site = tempfile::tempdir().unwrap(); // Two installed pypi distributions, each with its own bytes. let installed = |name: &str, body: &[u8]| { @@ -7482,7 +7205,7 @@ mod tests { async fn download_patches_json_is_purl_ordered() { use wiremock::MockServer; - let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL"]); let server = MockServer::start().await; let tmp = tempfile::tempdir().unwrap(); let names = [ diff --git a/crates/socket-patch-cli/src/commands/hosted_bundle.rs b/crates/socket-patch-cli/src/commands/hosted_bundle.rs index 67fe2bb10..a054e7e78 100644 --- a/crates/socket-patch-cli/src/commands/hosted_bundle.rs +++ b/crates/socket-patch-cli/src/commands/hosted_bundle.rs @@ -9,9 +9,13 @@ //! //! Stdin: `{"files": {path: text}, "binaryFiles"?: {path: base64}, //! "presentOnly"?: [path], "symlinks"?: [path], "projectRoots"?: [dir], -//! "pipenvMajor"?: n, "batchSize"?: n}`. Stdout: the engine result +//! "pipenvMajor"?: n, "batchSize"?: n, "noSocketYml"?: bool, +//! "minSeverity"?: severity, "policyPaths"?: [path], +//! "maxNewPatches"?: n | "none", "maxNewPatchesCap"?: n, +//! "inFlightPatches"?: [purl]}`. Stdout: the engine result //! (`HostedScanResult`, binary contents base64), or -//! `{"status":"error","error":{"code","message"}}` with exit 1. +//! `{"status":"error","error":{"code","message"}}` with exit 2 for bad +//! credentials/bundle input, or exit 1 for an engine failure. use std::collections::BTreeMap; use std::io::Read; @@ -52,6 +56,20 @@ struct Bundle { pipenv_major: Option, #[serde(default)] batch_size: Option, + #[serde(default)] + no_socket_yml: Option, + #[serde(default)] + min_severity: Option, + #[serde(default)] + policy_paths: Option>, + #[serde(default)] + policy_sha256: Option, + #[serde(default)] + max_new_patches: Option, + #[serde(default)] + max_new_patches_cap: Option, + #[serde(default)] + in_flight_patches: Option>, } fn print_error(code: &str, message: &str) { @@ -120,6 +138,13 @@ pub async fn run(args: HostedBundleArgs) -> i32 { trust_lockfile_config: Some(!common.no_trust_lockfile_config), npm_allow_remote_config: Some(!common.no_npm_allow_remote_config), project_roots: bundle.project_roots.clone(), + no_socket_yml: bundle.no_socket_yml, + min_severity: bundle.min_severity.clone(), + policy_paths: bundle.policy_paths.clone(), + policy_sha256: bundle.policy_sha256.clone(), + max_new_patches: bundle.max_new_patches, + max_new_patches_cap: bundle.max_new_patches_cap, + in_flight_patches: bundle.in_flight_patches.clone(), ..HostedScanOptions::default() }; let input = match build_input(bundle, options) { diff --git a/crates/socket-patch-cli/src/commands/list.rs b/crates/socket-patch-cli/src/commands/list.rs index 65ab4bc5a..8fc77fab1 100644 --- a/crates/socket-patch-cli/src/commands/list.rs +++ b/crates/socket-patch-cli/src/commands/list.rs @@ -1,11 +1,11 @@ use std::path::Path; use clap::Args; -use socket_patch_core::manifest::operations::read_manifest; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; -use socket_patch_core::patch::redirect::{RedirectState, REDIRECT_STATE_REL}; +use socket_patch_core::patch::redirect::upstream::HostedPin; +use socket_patch_core::patch::redirect::RedirectState; use socket_patch_core::telemetry::track_patch_listed; -use socket_patch_core::vendor::state::{VendorEntry, VENDOR_STATE_REL}; +use socket_patch_core::vendor::state::{VendorState, VENDOR_STATE_REL}; use crate::args::{apply_env_toggles, GlobalArgs}; use crate::json_envelope::{ @@ -18,37 +18,31 @@ pub struct ListArgs { pub common: GlobalArgs, } -/// Where a listed record lives. Declaration order is the tie-break order -/// when one purl appears in several stores: coexistence is real state (e.g. -/// an agent-applied patch alongside live hosted wiring), so every copy is -/// shown, labeled apart. -#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -enum Source { - /// A `.socket/manifest.json` entry (agent mode). - Manifest, - /// A hosted redirect-ledger record: `scan --mode hosted` records its - /// patches ONLY in `.socket/vendor/redirect-state.json` and never - /// writes the manifest — without these, a purely hosted-wired project - /// listed as `manifest_not_found` while its patches were demonstrably - /// live. - Hosted, - /// A vendor-ledger record: vendored mode is manifest-free, so every - /// `scan`/`get --mode vendored` patch lives ONLY in - /// `.socket/vendor/state.json`, as a `detached` entry's embedded record - /// (the hosted rule again — a vendored-only project lists and exits 0). - /// A standalone `vendor` entry's fallback copy lists here too once no - /// manifest entry covers it — the checkout `vex` attests from it. - Vendored, +// Where a listed record lives (see `socket_patch_core::ledgers`): the +// manifest (agent mode); the hosted redirect ledger, where `scan --mode +// hosted` recorded its patches; or the vendor ledger, where vendored mode +// keeps every `scan`/`get --mode vendored` patch as a `detached` entry's +// embedded record (a standalone `vendor` entry's fallback copy lists too +// once no manifest entry covers it — the checkout `vex` attests from it). +use socket_patch_core::ledgers::Store as Source; + +/// The display order of one purl's copies: manifest, hosted, vendored. +fn display_rank(source: Source) -> u8 { + match source { + Source::Manifest => 0, + Source::Hosted => 1, + Source::Vendored => 2, + } } -/// The `(mode, ledger)` label pair for a ledger-sourced record — the shared -/// constant labels, never a ledger's own opaque `mode` string (see -/// `HOSTED_MODE_LABEL`'s docs) — or `None` for a manifest entry. Shared by -/// the JSON `details` and the human `Mode:` line. +/// The `(mode, ledger)` label pair for a vendor-ledger record — the shared +/// constant label, never a ledger's own opaque `mode` string (see +/// `HOSTED_MODE_LABEL`'s docs) — or `None` for a manifest entry or a hosted +/// pin (which has no ledger; see [`ListEntry::lockfiles`]). Shared by the +/// JSON `details` and the human `Mode:` line. fn ledger_label(source: Source) -> Option<(&'static str, &'static str)> { match source { - Source::Manifest => None, - Source::Hosted => Some((crate::commands::HOSTED_MODE_LABEL, REDIRECT_STATE_REL)), + Source::Manifest | Source::Hosted => None, Source::Vendored => Some((crate::commands::VENDORED_MODE_LABEL, VENDOR_STATE_REL)), } } @@ -58,54 +52,98 @@ struct ListEntry<'a> { purl: &'a str, record: &'a PatchRecord, source: Source, + /// The lockfiles wiring a hosted pin (empty for the other sources). + lockfiles: &'a [String], +} + +/// A hosted pin as `list` shows it: the lockfiles wiring it, and its +/// record — from a pre-v5 redirect ledger when one still describes this +/// exact pin (read for migration only), else just the uuid (the details +/// live on the API; `vex` fetches them). +pub(crate) struct HostedListing { + pub purl: String, + pub record: PatchRecord, + pub lockfiles: Vec, } -/// Every listable record from all three stores, in a stable order: by -/// PURL, then manifest < hosted < vendored when one purl appears in more -/// than one. The record maps (`HashMap` manifest and vendor ledger / -/// `BTreeMap` redirect ledger) never impose an order shared consumers could -/// diff, so the sort here is the contract. Only vendor entries whose -/// embedded record stands on its own fold in -/// ([`crate::commands::vendor_record_is_unowned`], the rule `vex` attests -/// by): a `detached` entry always (coexisting with a manifest entry is real -/// state, shown labeled apart), a standalone `vendor` entry's fallback copy -/// only when the manifest does not cover it — while it does, the manifest's -/// record IS that entry's record and listing the copy would double-list the -/// purl. A legacy entry with no embedded record never folds in. +impl HostedListing { + /// One listing per hosted pin in `pins`, detailed from `legacy` where + /// it records the same purl and uuid. + pub(crate) fn from_pins(pins: &[HostedPin], legacy: Option<&RedirectState>) -> Vec { + let canon = |p: &str| { + socket_patch_core::utils::purl::normalize_purl( + socket_patch_core::utils::purl::strip_purl_qualifiers(p), + ) + .into_owned() + }; + pins.iter() + .map(|pin| { + let record = legacy + .and_then(|l| { + l.records + .iter() + .find(|(k, r)| canon(k) == canon(&pin.purl) && r.uuid == pin.uuid) + .map(|(_, r)| r.clone()) + }) + .unwrap_or_else(|| PatchRecord { + uuid: pin.uuid.clone(), + exported_at: String::new(), + files: Default::default(), + vulnerabilities: Default::default(), + description: String::new(), + license: String::new(), + tier: String::new(), + }); + HostedListing { + purl: pin.purl.clone(), + record, + lockfiles: pin.files.clone(), + } + }) + .collect() + } +} + +/// Every listable record, in a stable order: by PURL, then +/// [`display_rank`] when one purl appears in more than one store. The +/// manifest and the vendor ledger go through the shared owner rule +/// ([`socket_patch_core::ledgers::Ledgers::listed`]: coexisting copies are +/// real state, shown labeled apart; a claimed fallback copy and a legacy +/// entry with no embedded record never list); every hosted pin lists as +/// its own copy (the lockfiles are the only hosted record). The record +/// maps never impose an order shared consumers could diff, so the sort +/// here is the contract. fn combined_entries<'a>( manifest: Option<&'a PatchManifest>, - redirect: Option<&'a RedirectState>, - vendor: Option<&'a std::collections::HashMap>, + hosted: &'a [HostedListing], + vendor: Option<&'a VendorState>, ) -> Vec> { - let mut entries: Vec> = Vec::new(); - if let Some(manifest) = manifest { - entries.extend(manifest.patches.iter().map(|(purl, record)| ListEntry { - purl, - record, - source: Source::Manifest, - })); - } - if let Some(redirect) = redirect { - entries.extend(redirect.records.iter().map(|(purl, record)| ListEntry { - purl, - record, - source: Source::Hosted, - })); - } - if let Some(vendor) = vendor { - entries.extend(vendor.iter().filter_map(|(purl, entry)| { - let record = entry - .record - .as_ref() - .filter(|_| crate::commands::vendor_record_is_unowned(purl, entry, manifest))?; - Some(ListEntry { - purl, - record, - source: Source::Vendored, - }) - })); - } - entries.sort_by(|a, b| a.purl.cmp(b.purl).then(a.source.cmp(&b.source))); + let ledgers = socket_patch_core::ledgers::Ledgers { + manifest, + vendor, + redirect: None, + }; + let mut entries: Vec> = ledgers + .listed() + .into_iter() + .map(|l| ListEntry { + purl: l.key, + record: l.record, + source: l.store, + lockfiles: &[], + }) + .collect(); + entries.extend(hosted.iter().map(|h| ListEntry { + purl: &h.purl, + record: &h.record, + source: Source::Hosted, + lockfiles: &h.lockfiles, + })); + entries.sort_by(|a, b| { + a.purl + .cmp(b.purl) + .then(display_rank(a.source).cmp(&display_rank(b.source))) + }); entries } @@ -120,14 +158,8 @@ fn combined_entries<'a>( /// /// Events are emitted in the entries' given order — [`combined_entries`] /// owns the by-PURL event sort; this builder sorts each event's -/// vulnerabilities (by advisory ID) and files (by path). `HashMap` -/// iteration is otherwise nondeterministic, so without these sorts the -/// vuln/file ordering would change run-to-run — breaking consumers that -/// diff this output in CI logs. Mirrors the stable-ordering guarantee -/// `get` already provides for its vulnerability lists. -/// -/// Shared by `run` and the unit tests so the tests exercise the exact code -/// path `list --json` uses, rather than a hand-copied duplicate. +/// vulnerabilities (by advisory ID) and files (by path) so the output is +/// stable across runs (`HashMap` iteration is not). fn build_list_envelope(entries: &[ListEntry<'_>]) -> Envelope { let mut env = Envelope::new(Command::List); @@ -170,6 +202,10 @@ fn build_list_envelope(entries: &[ListEntry<'_>]) -> Envelope { details["mode"] = serde_json::json!(mode); details["ledger"] = serde_json::json!(ledger); } + if entry.source == Source::Hosted { + details["mode"] = serde_json::json!(crate::commands::HOSTED_MODE_LABEL); + details["lockfiles"] = serde_json::json!(entry.lockfiles); + } env.record( PatchEvent::new(PatchAction::Discovered, entry.purl.to_string()) @@ -253,19 +289,21 @@ fn format_entry(entry: &ListEntry<'_>, color: bool) -> String { let mut lines = vec![format!("Package: {}", sanitize(entry.purl))]; lines.extend(field(" ", "UUID", &patch.uuid)); if let Some((mode, ledger)) = ledger_label(entry.source) { - // Same labeling rule as the JSON details: the record comes from a - // ledger, not the manifest — hosted installs resolve the package - // to the hosted patch server, vendored ones to the committed - // `.socket/vendor/` artifact; no manifest entry exists or is - // needed. + // Same labeling rule as the JSON details. lines.push(format!(" Mode: {mode} (recorded in {ledger})")); } + if entry.source == Source::Hosted { + lines.push(format!( + " Mode: {} (wired in {})", + crate::commands::HOSTED_MODE_LABEL, + sanitize(&entry.lockfiles.join(", ")) + )); + } lines.extend(field(" ", "Tier", &patch.tier)); lines.extend(field(" ", "License", &patch.license)); lines.extend(field(" ", "Exported", &patch.exported_at)); lines.extend(field(" ", "Description", &patch.description)); - // Sort vulnerabilities by advisory ID for stable output. let mut vuln_entries: Vec<_> = patch.vulnerabilities.iter().collect(); vuln_entries.sort_by(|a, b| a.0.cmp(b.0)); if !vuln_entries.is_empty() { @@ -290,7 +328,6 @@ fn format_entry(entry: &ListEntry<'_>, color: bool) -> String { } } - // Sort patched files by path for stable output. let mut file_list: Vec<_> = patch.files.keys().collect(); file_list.sort(); if !file_list.is_empty() { @@ -302,11 +339,15 @@ fn format_entry(entry: &ListEntry<'_>, color: bool) -> String { lines.join("\n") } +/// The human line for a project with nothing to list (an empty manifest, or +/// no manifest and no ledger records at all). +const NO_PATCHES: &str = "No patches in this project. Run `socket-patch scan`."; + /// The whole human listing for stdout: a count header, then the entries /// separated by one blank line (none after the last). fn format_listing(entries: &[ListEntry<'_>], color: bool) -> String { if entries.is_empty() { - return "No patches found in manifest.".to_string(); + return NO_PATCHES.to_string(); } let mut out = format!( "Found {}:\n\n", @@ -323,23 +364,20 @@ pub async fn run(args: ListArgs) -> i32 { // `read_manifest` is the single source of truth for the three error // states: `Ok(None)` (file absent), `Err(InvalidData)` (present but - // unparseable), and any other `Err` (genuine I/O failure). We deliberately - // do NOT stat the path first: a `metadata` pre-check is both redundant and - // wrong — it reports *any* stat failure (e.g. an unreadable parent dir) as - // `manifest_not_found`, masking real I/O errors that owe a - // `manifest_unreadable`, and it opens a TOCTOU window where a file removed - // between the stat and the read lands in the wrong error arm. - let manifest = match read_manifest(&manifest_path).await { - Ok(manifest) => manifest, + // unparseable), and any other `Err` (genuine I/O failure). No stat + // pre-check: it would report any stat failure as `manifest_not_found` + // and open a TOCTOU window. + // One load of the three stores, all from the SAME project as the + // manifest (see below); each keeps list's own posture. + let ctx = crate::commands::context::ProjectContext::new(&args.common); + let loaded = ctx.loaded().await; + let manifest = match &loaded.manifest { + Ok(manifest) => manifest.as_ref(), Err(e) => { - // A manifest that exists but is unparseable (bad JSON or a - // schema violation) surfaces as `ErrorKind::InvalidData` — the - // contract's `manifest_invalid`. Everything else is a genuine - // I/O failure (`manifest_unreadable`). Conflating the two would - // tell a consumer to retry on a corrupt file, or to give up on a - // transient I/O error. See CLI_CONTRACT.md error-code table. - // Hosted-ledger records never mask either: a present-but-broken - // manifest is an error state, not a hosted-only project. + // `InvalidData` (bad JSON or schema) is the contract's + // `manifest_invalid`; everything else is `manifest_unreadable` + // (see CLI_CONTRACT.md error-code table). Ledger records never + // mask either: a present-but-broken manifest is an error state. let code = if e.kind() == std::io::ErrorKind::InvalidData { "manifest_invalid" } else { @@ -348,81 +386,76 @@ pub async fn run(args: ListArgs) -> i32 { emit_error( &args, code, - manifest_error_message(&manifest_path, &e), + manifest_error_message(&manifest_path, e), Vec::new(), ); return 1; } }; - // Hosted-mode patches live ONLY in the redirect ledger and vendored-mode - // patches ONLY in the vendor ledger, so `list` consults both alongside - // the manifest — leniently (a malformed ledger degrades to "nothing to - // consult", surfaced on stderr unless --silent; the write paths - // hard-error on it instead), and always from the SAME project as the - // manifest (`project_root` steps out of the manifest's `.socket/`): - // with `--manifest-path` pointing at another project, reading the LOCAL - // cwd's ledgers would interleave two projects' patch state (and a local - // ledger could suppress the flagged project's manifest_not_found). + // Hosted-mode patches live ONLY in the lockfiles (v5 keeps no hosted + // ledger) and vendored-mode patches ONLY in the vendor ledger, so + // `list` consults both alongside the manifest — always from the SAME + // project as the manifest (`project_root` steps out of the manifest's + // `.socket/`): with `--manifest-path` pointing at another project, + // reading the LOCAL cwd's state would interleave two projects' patches. // - // Under --json a corrupt redirect ledger rides the envelope's - // `warnings[]` (stdout is the machine channel; a stderr-only warning - // would vanish for JSON consumers), the same split `update` uses. - let project_root = args.common.project_root(); + // A pre-v5 redirect ledger is read (never written) only to detail the + // hosted pins it still describes; a malformed one degrades to "nothing + // to consult", surfaced on stderr unless --silent, or in the envelope's + // `warnings[]` under --json. let mut warnings: Vec = Vec::new(); - let redirect_state = - match socket_patch_core::patch::redirect::load_redirect_state(&project_root).await { - Ok(state) => state, - Err(corrupt) => { - if args.common.json { - warnings.push(RunWarning { - code: "redirect_ledger_corrupt".to_string(), - detail: corrupt.to_string(), - }); - } else if !args.common.silent { - eprintln!("Warning: {corrupt}"); - } - None + let legacy_redirect = match &loaded.redirect { + Ok(state) => state.as_ref(), + Err(corrupt) => { + if args.common.json { + warnings.push(RunWarning { + code: "redirect_ledger_corrupt".to_string(), + detail: corrupt.to_string(), + }); + } else if !args.common.silent { + eprintln!("Warning: {corrupt}"); } - }; - let vendor_state = - crate::commands::load_vendor_state_lenient(&project_root, args.common.silent).await; - - // `combined_entries` folds only ledger RECORDS in (an edits-only - // redirect ledger — post-takeover residue / a degraded record-fetch- - // failed run — and a record-less legacy vendor entry assert no - // patches), so entry emptiness is the whole exit predicate. - let entries = combined_entries( - manifest.as_ref(), - redirect_state.as_ref(), - vendor_state.as_ref().map(|s| &s.entries), - ); + None + } + }; + let inventory = crate::commands::hosted_inventory(&args.common, &ctx.root).await; + let hosted = HostedListing::from_pins(&inventory.pins, legacy_redirect); + // Contested hosted wiring cannot be listed as patches, but it is hosted + // state: surface it (stderr / `warnings[]`), never hide it. + let contested = inventory.contested_refusal(); + if let Some(detail) = &contested { + if args.common.json { + warnings.push(RunWarning { + code: "hosted_wiring_contested".to_string(), + detail: detail.clone(), + }); + } else if !args.common.silent { + eprintln!("Warning: {}", crate::commands::rollback::capitalize_first(detail)); + } + } + let vendor_state = crate::commands::vendor_state_lenient(&loaded.vendor, args.common.silent); + + // `combined_entries` folds only real records in (a record-less legacy + // vendor entry asserts no patch), so entry emptiness is the whole exit + // predicate. + let entries = combined_entries(manifest, &hosted, vendor_state); if manifest.is_none() && entries.is_empty() { - // No manifest AND no ledger records: nothing is listable anywhere — - // the classic missing-manifest error. `read_manifest` returns - // `Ok(None)` only when the file does not exist (its documented - // contract), so this is `manifest_not_found`, NOT `manifest_invalid` - // (which means the file is present but corrupt). See CLI_CONTRACT.md - // error-code table. - emit_error( - &args, - "manifest_not_found", - format!("Manifest not found at {}", manifest_path.display()), - warnings, - ); - return 1; + if let Some(detail) = contested { + emit_error(&args, "hosted_wiring_contested", detail, warnings); + return 1; + } } - // Records found (either store) ⇒ a successful list, exit 0 — including - // the purely hosted-wired project that used to hard-fail here. + // A successful list, exit 0, with or without records. No manifest + // and no ledger record is just an empty project (normal for hosted + // mode, which writes no manifest); only an unreadable or invalid + // manifest or contested hosted wiring fails. // - // Telemetry: `patch_listed`'s `patches_count` predates the hosted - // folding and its consumers read it as "manifest patches", so it keeps - // counting the manifest ONLY (0 on a hosted-only project) — folding the - // listed entries in would silently redefine the metric and double-count - // purls present in both stores. Hosted visibility, if wanted, belongs - // in a new dedicated field. - let manifest_patch_count = manifest.as_ref().map_or(0, |m| m.patches.len()); + // Telemetry: `patch_listed`'s `patches_count` means "manifest patches" + // to its consumers, so it counts the manifest ONLY (0 on a ledger-only + // project) rather than the listed entries. + let manifest_patch_count = manifest.map_or(0, |m| m.patches.len()); let (api_token, org_slug) = args.common.telemetry_credentials(); track_patch_listed( manifest_patch_count, @@ -436,9 +469,7 @@ pub async fn run(args: ListArgs) -> i32 { env.warnings = warnings; println!("{}", env.to_pretty_json()); } else if args.common.silent { - // `--silent` is "errors only" (CLI_CONTRACT.md): suppress the - // entire human-readable listing, mirroring `get`/`repair`. - // The exit code still distinguishes the manifest states. + // `--silent` is "errors only" (CLI_CONTRACT.md). } else { println!("{}", format_listing(&entries, crate::ui::stdout_color())); } @@ -448,17 +479,17 @@ pub async fn run(args: ListArgs) -> i32 { #[cfg(test)] mod tests { - //! Inline tests for `list` JSON output. Pin the new envelope shape - //! so downstream consumers (PR bots, dashboards) can rely on it. + //! Inline tests for `list` output. Pin the envelope shape so downstream + //! consumers (PR bots, dashboards) can rely on it. use super::*; use socket_patch_core::manifest::schema::{PatchFileInfo, PatchRecord, VulnerabilityInfo}; + use socket_patch_core::vendor::state::VendorEntry; use std::collections::HashMap; - /// Envelope for a manifest-only listing (no redirect ledger) — the shape - /// most tests below need; the hosted tests call `combined_entries` - /// directly with a `RedirectState`. + /// Envelope for a manifest-only listing (no hosted pins, no vendor + /// ledger) — the shape most tests below need. fn manifest_envelope(manifest: &PatchManifest) -> Envelope { - build_list_envelope(&combined_entries(Some(manifest), None, None)) + build_list_envelope(&combined_entries(Some(manifest), &[], None)) } fn sample_manifest() -> PatchManifest { @@ -601,11 +632,9 @@ mod tests { assert_eq!(v["summary"]["discovered"], 0); } - // -- Regression: stable ordering ------------------------------------- - // `HashMap` iteration order is randomized per run, so without explicit - // sorting the events / vulnerabilities / files arrays would shuffle - // between invocations. These pin the sorted contract so consumers can - // diff `list --json` output in CI logs. + // -- Stable ordering ------------------------------------------------- + // Pin the sorted events / vulnerabilities / files contract so consumers + // can diff `list --json` output. #[test] fn events_are_sorted_by_purl() { @@ -666,25 +695,30 @@ mod tests { assert_eq!(paths, vec!["z/a.js", "z/b.js"]); } - /// Hosted redirect-ledger records fold into the envelope labeled apart - /// from manifest entries: `details.mode` / `details.ledger` ride the - /// hosted events ONLY (additive keys), and the global purl sort holds - /// with the manifest entry first when one purl appears in both stores. + fn hosted(purl: &str, record: PatchRecord) -> HostedListing { + HostedListing { + purl: purl.to_string(), + record, + lockfiles: vec!["package-lock.json".to_string()], + } + } + + /// Hosted pins fold into the envelope labeled apart from manifest + /// entries: `details.mode` / `details.lockfiles` ride the hosted events + /// ONLY (additive keys), and the global purl sort holds with the + /// manifest entry first when one purl appears in both stores. #[test] - fn hosted_ledger_records_are_labeled_and_interleaved() { + fn hosted_pins_are_labeled_and_interleaved() { let manifest = sample_manifest(); - let mut redirect = RedirectState::new(); let mut hosted_record = manifest.patches["pkg:npm/minimist@1.2.2"].clone(); hosted_record.uuid = "22222222-2222-4222-8222-222222222222".to_string(); // Same purl as the manifest entry (coexistence) + a distinct one. - redirect - .records - .insert("pkg:npm/minimist@1.2.2".to_string(), hosted_record.clone()); - redirect - .records - .insert("pkg:npm/aaa-hosted@1.0.0".to_string(), hosted_record); + let pins = vec![ + hosted("pkg:npm/minimist@1.2.2", hosted_record.clone()), + hosted("pkg:npm/aaa-hosted@1.0.0", hosted_record), + ]; - let env = build_list_envelope(&combined_entries(Some(&manifest), Some(&redirect), None)); + let env = build_list_envelope(&combined_entries(Some(&manifest), &pins, None)); let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap(); assert_eq!(v["summary"]["discovered"], 3); let events = v["events"].as_array().unwrap(); @@ -712,9 +746,41 @@ mod tests { "manifest entries must NOT carry the hosted labels: {v}" ); assert_eq!( - events[0]["details"]["ledger"], - ".socket/vendor/redirect-state.json" + events[0]["details"]["lockfiles"], + serde_json::json!(["package-lock.json"]) + ); + assert!( + events[0]["details"].get("ledger").is_none(), + "a hosted pin names no ledger: {v}" + ); + } + + /// A pre-v5 redirect ledger details only the pins it records with the + /// same uuid; any other pin lists with its uuid alone. + #[test] + fn legacy_ledger_details_only_matching_pins() { + let manifest = sample_manifest(); + let record = manifest.patches["pkg:npm/minimist@1.2.2"].clone(); + let mut legacy = RedirectState::new(); + legacy + .records + .insert("pkg:npm/minimist@1.2.2".to_string(), record.clone()); + let pin = |purl: &str, uuid: &str| HostedPin { + purl: purl.to_string(), + uuid: uuid.to_string(), + files: vec!["yarn.lock".to_string()], + }; + let listings = HostedListing::from_pins( + &[ + pin("pkg:npm/minimist@1.2.2", &record.uuid), + pin("pkg:npm/other@1.0.0", "33333333-3333-4333-8333-333333333333"), + ], + Some(&legacy), ); + assert_eq!(listings[0].record, record); + assert_eq!(listings[1].record.uuid, "33333333-3333-4333-8333-333333333333"); + assert!(listings[1].record.vulnerabilities.is_empty()); + assert_eq!(listings[1].lockfiles, vec!["yarn.lock".to_string()]); } /// A hosted-only listing (no manifest at all) — the shape a purely @@ -722,12 +788,11 @@ mod tests { #[test] fn hosted_only_entries_build_a_success_envelope() { let manifest = sample_manifest(); - let mut redirect = RedirectState::new(); - redirect.records.insert( - "pkg:npm/minimist@1.2.2".to_string(), + let pins = vec![hosted( + "pkg:npm/minimist@1.2.2", manifest.patches["pkg:npm/minimist@1.2.2"].clone(), - ); - let env = build_list_envelope(&combined_entries(None, Some(&redirect), None)); + )]; + let env = build_list_envelope(&combined_entries(None, &pins, None)); let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap(); assert_eq!(v["status"], "success"); assert_eq!(v["summary"]["discovered"], 1); @@ -738,6 +803,13 @@ mod tests { /// `record` is given (the manifest-free vendored posture), a legacy /// manifest-tracked entry (no record of its own) otherwise. Built from /// the on-disk JSON shape so the fixture follows the ledger schema. + fn as_state(entries: &HashMap) -> VendorState { + VendorState { + entries: entries.clone(), + ..VendorState::new() + } + } + fn vendor_entry(purl: &str, record: Option) -> VendorEntry { serde_json::from_value(serde_json::json!({ "ecosystem": "npm", @@ -762,10 +834,7 @@ mod tests { fn vendored_ledger_records_are_labeled_and_sorted_last() { let manifest = sample_manifest(); let record = manifest.patches["pkg:npm/minimist@1.2.2"].clone(); - let mut redirect = RedirectState::new(); - redirect - .records - .insert("pkg:npm/minimist@1.2.2".to_string(), record.clone()); + let pins = vec![hosted("pkg:npm/minimist@1.2.2", record.clone())]; let mut detached = record.clone(); detached.uuid = "44444444-4444-4444-8444-444444444444".to_string(); let mut vendor = HashMap::new(); @@ -785,8 +854,8 @@ mod tests { let env = build_list_envelope(&combined_entries( Some(&manifest), - Some(&redirect), - Some(&vendor), + &pins, + Some(&as_state(&vendor)), )); let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap(); let listed: Vec<(&str, &str)> = v["events"] @@ -820,7 +889,7 @@ mod tests { "the ledger's embedded record is the one listed: {v}" ); - let only = build_list_envelope(&combined_entries(None, None, Some(&vendor))); + let only = build_list_envelope(&combined_entries(None, &[], Some(&as_state(&vendor)))); let v: serde_json::Value = serde_json::from_str(&only.to_pretty_json()).unwrap(); assert_eq!(v["status"], "success", "{v}"); assert_eq!(v["summary"]["discovered"], 2, "{v}"); @@ -884,7 +953,11 @@ mod tests { }; assert_eq!( - listed(&combined_entries(Some(&manifest), None, Some(&vendor))), + listed(&combined_entries( + Some(&manifest), + &[], + Some(&as_state(&vendor)) + )), vec![ ( "pkg:npm/left-pad@1.3.0".to_string(), @@ -901,7 +974,7 @@ mod tests { ); // No manifest at all: every fallback copy stands on its own. - let only = listed(&combined_entries(None, None, Some(&vendor))); + let only = listed(&combined_entries(None, &[], Some(&as_state(&vendor)))); assert_eq!(only.len(), 3, "{only:?}"); assert!( only.iter().all(|(_, mode, _)| mode == "vendored"), @@ -933,6 +1006,7 @@ mod tests { purl, record, source: Source::Manifest, + lockfiles: &[], } } @@ -1007,15 +1081,15 @@ mod tests { #[test] fn format_listing_counts_and_separates_entries() { - assert_eq!(format_listing(&[], false), "No patches found in manifest."); + assert_eq!(format_listing(&[], false), NO_PATCHES); let manifest = sample_manifest(); - let one = combined_entries(Some(&manifest), None, None); + let one = combined_entries(Some(&manifest), &[], None); let out = format_listing(&one, false); assert!(out.starts_with("Found 1 patch:\n\nPackage: "), "{out}"); assert!(!out.ends_with('\n'), "no trailing blank line: {out:?}"); let multi = multi_entry_manifest(); - let many = combined_entries(Some(&multi), None, None); + let many = combined_entries(Some(&multi), &[], None); let out = format_listing(&many, false); assert!( out.starts_with(&format!("Found {} patches:\n\n", many.len())), diff --git a/crates/socket-patch-cli/src/commands/lock_cli.rs b/crates/socket-patch-cli/src/commands/lock_cli.rs index 6b804c071..3e9d01b0a 100644 --- a/crates/socket-patch-cli/src/commands/lock_cli.rs +++ b/crates/socket-patch-cli/src/commands/lock_cli.rs @@ -3,8 +3,8 @@ //! //! Mutating subcommands (`apply`, `rollback`, `repair`, `remove`, //! `vendor`) all need the same shape: acquire the lock at the top of -//! `run`, on contention emit a JSON envelope with `errorCode: -//! "lock_held"` (or stderr in human mode) and exit 1. This module +//! `run`, on contention emit a JSON envelope with `error.code: +//! "lock_held"` (status "error") (or stderr in human mode) and exit 1. This module //! centralises that emission so the call sites stay one line each. //! //! The lock itself is in `socket-patch-core` (cross-crate, also used @@ -337,13 +337,12 @@ mod tests { /// 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. Today every guard drop unlinks the file, so this is the - /// live stress test of the core protocol that makes that safe: + /// robbed of a lock it legitimately acquired: a competitor that + /// flocked (or had merely *opened*) the file before an unlink keeps a + /// valid lock on the orphaned inode while a re-acquire locks a fresh + /// one — two live holders at once. Every guard drop unlinks the file, + /// so this is the live stress test of the core protocol that makes + /// that safe: /// unlink WHILE holding the lock, and re-check the locked handle's /// identity against the path after every successful lock. /// @@ -566,8 +565,7 @@ mod tests { ); } - /// The `--json` failure envelope (previously emitted only via - /// `println!`, so untested) has the stable error shape downstream + /// The `--json` failure envelope has the stable error shape downstream /// consumers pattern-match on: top-level `status: "error"` and /// `error.code` carrying the lock reason tag. #[test] diff --git a/crates/socket-patch-cli/src/commands/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs index 407cf3434..7099e4a60 100644 --- a/crates/socket-patch-cli/src/commands/mod.rs +++ b/crates/socket-patch-cli/src/commands/mod.rs @@ -1,5 +1,6 @@ pub mod apply; pub(crate) mod bun_preflight; +pub(crate) mod context; pub(crate) mod composer_hints; pub(crate) mod fetch_stage; pub mod get; @@ -8,10 +9,9 @@ pub mod list; pub(crate) mod lock_cli; pub mod remove; pub mod repair; -pub(crate) mod repair_vendor; +pub(crate) mod vendored_backend; pub mod rollback; pub mod scan; -pub mod setup; pub mod update; pub mod vendor; pub mod vex; @@ -21,13 +21,10 @@ pub(crate) mod vlt_preflight; use std::path::Path; -/// The documented name of the mode whose ledger is -/// `.socket/vendor/redirect-state.json`. Shared by scan's `redirectState` -/// envelope block and list's hosted event labels so the two surfaces can -/// never drift, and deliberately a CONSTANT rather than an echo of the -/// ledger's own `mode` string: that string is opaque to the loader -/// (pre-rename ledgers carry `"redirect"`), and a consumer dispatching on -/// these keys must not have to know that history. +/// The documented name of hosted mode (lockfile pins to Socket-hosted +/// patched packages; no ledger). Shared by scan's `redirectState` envelope +/// block and list's hosted event labels so the two surfaces can never +/// drift. pub(crate) const HOSTED_MODE_LABEL: &str = "hosted"; /// The documented name of the mode whose ledger is @@ -52,52 +49,93 @@ pub(crate) async fn discover_wiring( common: &crate::args::GlobalArgs, root: &Path, ) -> socket_patch_core::vex::discover::Discovery { - let opts = socket_patch_core::vex::DiscoverOptions { + socket_patch_core::vex::discover_patched_refs_with(root, &discover_options(common)).await +} + +/// [`discover_wiring`] of the snapshot's root, reading through `snapshot`. +pub(crate) async fn discover_wiring_in( + common: &crate::args::GlobalArgs, + snapshot: &socket_patch_core::vendor::lock_inventory::DiskSnapshot<'_>, +) -> socket_patch_core::vex::discover::Discovery { + socket_patch_core::vex::discover_patched_refs_in(snapshot, &discover_options(common)).await +} + +fn discover_options(common: &crate::args::GlobalArgs) -> socket_patch_core::vex::DiscoverOptions { + socket_patch_core::vex::DiscoverOptions { patch_server_origins: common .patch_server_url .iter() .filter(|url| !url.trim().is_empty()) .cloned() .collect(), - }; - socket_patch_core::vex::discover_patched_refs_with(root, &opts).await + } } -/// Read-only lenient load of the hosted redirect ledger: missing → `None` -/// (a fresh start); malformed → `None` with the corruption surfaced on -/// stderr unless `silent`. This is the "read-only consumers may degrade a -/// malformed ledger to nothing-to-consult, but must surface it" posture -/// from `load_redirect_state`'s contract — the warning is advisory -/// (muted by `--silent`, "errors only"), because every path that would -/// WRITE or ATTEST from the ledger hard-errors on the same corruption -/// instead. Shared by both of scan's read-only consults. -pub(crate) async fn load_redirect_state_lenient( - cwd: &Path, - silent: bool, -) -> Option { - match socket_patch_core::patch::redirect::load_redirect_state(cwd).await { - Ok(state) => state, - Err(corrupt) => { - if !silent { - eprintln!("Warning: {corrupt}"); - } - None - } +/// The project's hosted wiring as raw inventory (core +/// [`HostedInventory`]): the attributable pins management commands act on, +/// and the contested wiring they must refuse around. VEX eligibility is a +/// separate judgment over the same discovery. +/// +/// [`HostedInventory`]: socket_patch_core::patch::redirect::upstream::HostedInventory +pub(crate) async fn hosted_inventory( + common: &crate::args::GlobalArgs, + root: &Path, +) -> socket_patch_core::patch::redirect::upstream::HostedInventory { + socket_patch_core::patch::redirect::upstream::HostedInventory::of( + &discover_wiring(common, root).await, + ) +} + +/// The project's hosted state, v5-style: v5 hosted mode keeps no ledger, +/// so the hosted pins [`discover_wiring`] finds in the lockfiles are the +/// whole record. Shaped as a [`RedirectState`] for the readers that classify +/// hosted against vendored state (one uuid-only record per pinned purl, no +/// edits) — it is never persisted. +/// +/// [`RedirectState`]: socket_patch_core::patch::redirect::RedirectState +pub(crate) async fn hosted_state_from_lockfiles( + common: &crate::args::GlobalArgs, + root: &Path, +) -> socket_patch_core::patch::redirect::RedirectState { + hosted_state_from_pins(&socket_patch_core::patch::redirect::upstream::HostedPin::all( + &discover_wiring(common, root).await, + )) +} + +/// [`hosted_state_from_lockfiles`] over already-discovered pins. A purl +/// pinned to several uuids (different lockfiles) keeps the first. +pub(crate) fn hosted_state_from_pins( + pins: &[socket_patch_core::patch::redirect::upstream::HostedPin], +) -> socket_patch_core::patch::redirect::RedirectState { + let mut state = socket_patch_core::patch::redirect::RedirectState::new(); + for pin in pins { + state + .records + .entry(pin.purl.clone()) + .or_insert_with(|| socket_patch_core::manifest::schema::PatchRecord { + uuid: pin.uuid.clone(), + exported_at: String::new(), + files: Default::default(), + vulnerabilities: Default::default(), + description: String::new(), + license: String::new(), + tier: String::new(), + }); } + state } -/// Read-only lenient load of the vendor ledger (`.socket/vendor/state.json`): +/// Read-only lenient view of a loaded vendor ledger (`.socket/vendor/state.json`): /// missing → an empty ledger; malformed/unreadable → `None` with the -/// problem surfaced on stderr unless `silent`. The vendor twin of -/// [`load_redirect_state_lenient`], with the same posture: a read-only +/// problem surfaced on stderr unless `silent`. A read-only /// consumer (`list`) degrades a broken ledger to nothing-to-consult but /// must say so, while every path that writes or attests from it fails /// closed instead. -pub(crate) async fn load_vendor_state_lenient( - root: &Path, +pub(crate) fn vendor_state_lenient( + loaded: &std::io::Result, silent: bool, -) -> Option { - match socket_patch_core::vendor::load_state(root).await { +) -> Option<&socket_patch_core::vendor::state::VendorState> { + match loaded { Ok(state) => Some(state), Err(e) => { if !silent { @@ -110,150 +148,3 @@ pub(crate) async fn load_vendor_state_lenient( } } -/// Whether a vendor-ledger entry's embedded `record` stands on its own — -/// the rule every reader of embedded records shares (`vex`'s record plan, -/// `list`, and `setup --check` through [`fold_vendor_records`]), so one -/// tree never lists "no patches" while its VEX document attests one. -/// -/// A `detached` entry (every `scan`/`get --mode vendored` entry) has no -/// manifest owner: its record is the only copy. A non-detached entry was -/// written by the manifest-driven standalone `vendor`, which embeds the -/// record as a fallback copy: the manifest record stays authoritative while -/// the manifest covers the entry — its ledger key, or its base purl (the -/// claim `vex_sources`' candidate builder applies) — and the embedded copy -/// stands in only when it does not (a checkout that never committed its -/// manifest, or one that dropped the purl while the lockfile still wires -/// the artifact). `repair` deliberately stays narrower (the copy is used -/// only with no manifest at all): it rebuilds artifacts, and a purl dropped -/// from a live manifest is the reconcile's to revert, not repair's to heal. -pub(crate) fn vendor_record_is_unowned( - key: &str, - entry: &socket_patch_core::vendor::VendorEntry, - manifest: Option<&socket_patch_core::manifest::schema::PatchManifest>, -) -> bool { - entry.detached - || manifest.is_none_or(|m| { - !m.patches.contains_key(key) && !m.patches.contains_key(&entry.base_purl) - }) -} - -/// Fold the vendor ledger's embedded records into a manifest view. -/// Vendored mode is manifest-free (every `scan`/`get --mode vendored` entry -/// carries `detached: true` plus its embedded patch `record`), so the -/// ledger is the only copy of those records: verification (`setup --check`, -/// property 4) must see them exactly like manifest entries (`vex` gathers -/// them through its own gated plan, `commands::vex_sources`). A standalone -/// `vendor` entry's fallback copy folds in under the same -/// [`vendor_record_is_unowned`] rule `vex` and `list` apply — only when the -/// manifest does not cover the entry. Keyed by the ledger key; an existing -/// manifest entry wins a collision (that purl is manifest-owned and -/// verifies against the manifest's record). Ownership is judged against -/// the manifest as given, never against records folded earlier in the same -/// pass (`HashMap` order must not decide which entries fold). -pub(crate) fn fold_vendor_records( - manifest: &mut socket_patch_core::manifest::schema::PatchManifest, - entries: &std::collections::HashMap, -) { - let folded: Vec<(String, socket_patch_core::manifest::schema::PatchRecord)> = entries - .iter() - .filter(|(key, entry)| { - vendor_record_is_unowned(key, entry, Some(&*manifest)) - && !manifest.patches.contains_key(key.as_str()) - }) - .filter_map(|(key, entry)| Some((key.clone(), entry.record.clone()?))) - .collect(); - manifest.patches.extend(folded); -} - -#[cfg(test)] -mod vendor_record_fold_tests { - use std::collections::HashMap; - - use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; - use socket_patch_core::vendor::VendorEntry; - - use super::fold_vendor_records; - - fn record(uuid: &str) -> PatchRecord { - serde_json::from_value(serde_json::json!({ - "uuid": uuid, - "exportedAt": "2026-01-01T00:00:00Z", - "files": { "package/index.js": { "beforeHash": "b", "afterHash": "a" } }, - "vulnerabilities": {}, - "description": "fixture", - "license": "MIT", - "tier": "free", - })) - .expect("record fixture deserializes") - } - - fn entry(base_purl: &str, uuid: &str, detached: bool, embedded: bool) -> VendorEntry { - serde_json::from_value(serde_json::json!({ - "ecosystem": "npm", - "basePurl": base_purl, - "uuid": uuid, - "artifact": { "path": format!(".socket/vendor/npm/{uuid}/pkg.tgz") }, - "wiring": [], - "detached": detached, - "record": embedded.then(|| record(uuid)), - })) - .expect("vendor entry fixture deserializes") - } - - /// `setup --check`'s fold follows the rule `vex` attests by: detached - /// records always fold (a manifest entry wins its own key), a standalone - /// `vendor` entry's fallback copy folds only when the manifest covers - /// neither its key nor its base purl, and a record-less legacy entry - /// never folds. Ownership is judged against the manifest as given. - #[test] - fn standalone_vendor_fallback_folds_only_when_uncovered() { - let mut entries = HashMap::new(); - entries.insert( - "pkg:npm/owned@1.0.0".to_string(), - entry("pkg:npm/owned@1.0.0", "u-stale", false, true), - ); - entries.insert( - "pkg:npm/owned@1.0.0?variant=x".to_string(), - entry("pkg:npm/owned@1.0.0", "u-variant", false, true), - ); - entries.insert( - "pkg:npm/dropped@1.0.0".to_string(), - entry("pkg:npm/dropped@1.0.0", "u-dropped", false, true), - ); - entries.insert( - "pkg:npm/detached@1.0.0".to_string(), - entry("pkg:npm/detached@1.0.0", "u-detached", true, true), - ); - entries.insert( - "pkg:npm/legacy@1.0.0".to_string(), - entry("pkg:npm/legacy@1.0.0", "u-legacy", false, false), - ); - - let mut manifest = PatchManifest::default(); - manifest - .patches - .insert("pkg:npm/owned@1.0.0".to_string(), record("u-manifest")); - fold_vendor_records(&mut manifest, &entries); - let mut folded: Vec<(&str, &str)> = manifest - .patches - .iter() - .map(|(k, r)| (k.as_str(), r.uuid.as_str())) - .collect(); - folded.sort(); - assert_eq!( - folded, - vec![ - ("pkg:npm/detached@1.0.0", "u-detached"), - ("pkg:npm/dropped@1.0.0", "u-dropped"), - ("pkg:npm/owned@1.0.0", "u-manifest"), - ], - "a newer manifest uuid keeps winning; covered and record-less entries stay out" - ); - - // No manifest records at all: every embedded copy folds. - let mut empty = PatchManifest::default(); - fold_vendor_records(&mut empty, &entries); - assert_eq!(empty.patches.len(), 4, "{:?}", empty.patches.keys()); - assert_eq!(empty.patches["pkg:npm/owned@1.0.0"].uuid, "u-stale"); - } -} diff --git a/crates/socket-patch-cli/src/commands/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs index 82e5f018c..3287aa81a 100644 --- a/crates/socket-patch-cli/src/commands/remove.rs +++ b/crates/socket-patch-cli/src/commands/remove.rs @@ -3,9 +3,7 @@ use socket_patch_core::api::client::get_api_client_with_overrides; use socket_patch_core::manifest::cleanup_blobs::format_bytes; use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; use socket_patch_core::manifest::schema::PatchManifest; -use socket_patch_core::patch::redirect::{ - load_redirect_state, persist_redirect_state, RedirectState, REDIRECT_STATE_REL, -}; +use socket_patch_core::patch::redirect::upstream::HostedPin; use socket_patch_core::telemetry::{track_patch_remove_failed, track_patch_removed}; use socket_patch_core::utils::purl::patch_matches; use socket_patch_core::vendor::{ @@ -16,37 +14,36 @@ use std::time::Duration; use super::get::short_uuid; use super::rollback::{ - pin_before_hash_blobs, revert_vendor_entry, rollback_patches_inner, run_hosted_leg, - sweep_failure, sweep_unused_artifacts, HostedLegOutcome, InnerSelection, VendorRevertStep, + pin_before_hash_blobs, rollback_patches_inner, run_hosted_leg, sweep_failure, + sweep_unused_artifacts, HostedLegOutcome, InnerSelection, }; +use crate::commands::vendored_backend::{RevertedEntry, VendorRevertStep, VendoredBackend}; use crate::args::{apply_env_toggles, GlobalArgs}; use crate::commands::lock_cli::acquire_or_emit; use crate::json_envelope::{Command, Envelope, EnvelopeError, PatchAction, PatchEvent, Status}; use crate::ui::plural; -/// Vendor-ledger entries matching a remove identifier (by ledger key, -/// base purl or uuid — `VendorEntry::matches_identifier`), sorted by key -/// for deterministic event order. +/// Vendor-ledger entries matching a remove identifier +/// ([`socket_patch_core::ledgers::Ledgers::matching`]), sorted by key for +/// deterministic event order. fn vendor_entries_matching(state: &VendorState, identifier: &str) -> Vec<(String, VendorEntry)> { - let mut matches: Vec<(String, VendorEntry)> = state - .entries - .iter() - .filter(|(key, entry)| entry.matches_identifier(key, identifier)) - .map(|(k, e)| (k.clone(), e.clone())) - .collect(); - matches.sort_by(|a, b| a.0.cmp(&b.0)); - matches + socket_patch_core::ledgers::Ledgers { + vendor: Some(state), + ..Default::default() + } + .matching(identifier) + .vendor } -/// Hosted redirect records matching a remove identifier, sorted. -fn hosted_records_matching(state: &RedirectState, identifier: &str) -> Vec { - let mut matches: Vec = state - .records +/// The lockfiles' hosted pins matching a remove identifier (by purl or +/// patch uuid), sorted by purl. +fn hosted_pins_matching(pins: &[HostedPin], identifier: &str) -> Vec { + let mut matches: Vec = pins .iter() - .filter(|(purl, rec)| patch_matches(purl, &rec.uuid, identifier)) - .map(|(purl, _)| purl.clone()) + .filter(|pin| patch_matches(&pin.purl, &pin.uuid, identifier)) + .cloned() .collect(); - matches.sort(); + matches.sort_by(|a, b| a.purl.cmp(&b.purl)); matches } @@ -97,7 +94,7 @@ async fn emit_not_found( } } -/// Print the hosted leg's run-level advisories (`Warning (): …`) on +/// Print the hosted leg's run-level advisories (`Warning: …`) on /// stderr — never under `--silent` / `--json` (JSON carries them in the /// envelope's `warnings[]`). Printed as soon as the leg returns, so a /// human run that then fails still says what it did to the files. @@ -105,8 +102,8 @@ fn print_hosted_leg_warnings(common: &GlobalArgs, warnings: &[(String, String)]) if common.silent || common.json { return; } - for (code, detail) in warnings { - eprintln!("Warning ({code}): {detail}"); + for (_, detail) in warnings { + eprintln!("Warning: {detail}"); } } @@ -184,7 +181,7 @@ fn remove_prompt( if hosted > 0 { clauses.push(format!( "unwind {}", - plural(hosted, "hosted redirect", "hosted redirects") + plural(hosted, "hosted patch", "hosted patches") )); } let question = super::rollback::as_question(&super::rollback::join_clauses(&clauses)); @@ -340,22 +337,33 @@ pub async fn run(args: RemoveArgs) -> i32 { let cwd = &args.common.cwd; // ── state discovery ───────────────────────────────────────────────── - // A ledger-only project (vendored mode keeps its records in the vendor - // ledger, hosted mode in the redirect ledger — neither writes a - // manifest) proceeds manifest-less: `remove` is the per-purl exit path - // for those entries. Only cheap EXISTENCE probes run before the lock — - // they decide the truly-empty error path, which never locks (a bare - // project must not see `.socket/` created and pruned again). The - // stores themselves are loaded under the lock below. + // A manifest-less project (vendored mode keeps its records in the + // vendor ledger; hosted mode keeps none — its lockfile pins are the + // record) proceeds manifest-less: `remove` is the per-purl exit path + // for those entries. Only cheap probes run before the lock — they + // decide the truly-empty error path, which never locks (a bare project + // must not see `.socket/` created and pruned again). The vendor ledger + // is loaded under the lock below; the hosted pins come from read-only + // lockfile discovery (the restore re-reads every file under the lock). let manifest_missing = tokio::fs::metadata(&manifest_path).await.is_err(); + let hosted_inventory = crate::commands::hosted_inventory(&args.common, cwd).await; + let hosted_pins: Vec = hosted_inventory.pins.clone(); if manifest_missing { let vendor_ledger_exists = tokio::fs::metadata(cwd.join(VENDOR_STATE_REL)) .await .is_ok(); - let redirect_ledger_exists = tokio::fs::metadata(cwd.join(REDIRECT_STATE_REL)) - .await - .is_ok(); - if !vendor_ledger_exists && !redirect_ledger_exists { + if !vendor_ledger_exists && hosted_pins.is_empty() { + // Contested hosted wiring is still hosted state: name it + // instead of reporting a bare project. + if let Some(refusal) = hosted_inventory.contested_refusal() { + emit_error_envelope( + args.common.json, + args.common.dry_run, + "hosted_wiring_contested", + refusal, + ); + return 1; + } emit_error_envelope( args.common.json, args.common.dry_run, @@ -453,23 +461,18 @@ pub async fn run(args: RemoveArgs) -> i32 { } } - // Hosted-only patches likewise have no manifest entry — the - // redirect ledger is their only persistence, and `remove` is - // their per-purl exit path (the unwind IS the removal). An - // unreadable ledger falls through to `not_found`: nothing is - // mutated on that path. - if let Ok(Some(redirect_state)) = load_redirect_state(cwd).await { - let hosted_matches = hosted_records_matching(&redirect_state, &args.identifier); - if !hosted_matches.is_empty() { - return remove_hosted_only( - &args, - hosted_matches, - redirect_state, - api_token.as_deref(), - org_slug.as_deref(), - ) - .await; - } + // Hosted-only patches likewise have no manifest entry — their + // lockfile pins are their only persistence, and `remove` is their + // per-purl exit path (restoring the upstream entry IS the removal). + let hosted_matches = hosted_pins_matching(&hosted_pins, &args.identifier); + if !hosted_matches.is_empty() { + return remove_hosted_only( + &args, + hosted_matches, + api_token.as_deref(), + org_slug.as_deref(), + ) + .await; } emit_not_found( @@ -516,8 +519,8 @@ pub async fn run(args: RemoveArgs) -> i32 { // `--dry-run` previews without mutating, so there is nothing to // confirm — skip the prompt (matching the global contract row: // "Preview, no mutations"). The prompt names every leg the removal - // will touch: the redirect ledger is probed read-only here (the legs - // below re-load it and decide for real). + // will touch: the hosted pins come from the read-only discovery above + // (the legs below decide for real). if !args.common.dry_run { let (vendored, hosted) = if args.skip_rollback { (0, 0) @@ -526,12 +529,7 @@ pub async fn run(args: RemoveArgs) -> i32 { .as_ref() .map(|st| vendor_entries_matching(st, &args.identifier).len()) .unwrap_or(0); - let hosted = load_redirect_state(cwd) - .await - .ok() - .flatten() - .map(|st| hosted_records_matching(&st, &args.identifier).len()) - .unwrap_or(0); + let hosted = hosted_pins_matching(&hosted_pins, &args.identifier).len(); (vendored, hosted) }; let prompt = remove_prompt( @@ -543,7 +541,7 @@ pub async fn run(args: RemoveArgs) -> i32 { ); if !crate::ui::confirm(&prompt, true, &args.common) { if loud { - println!("Removal cancelled."); + println!("{}", crate::ui::CANCELLED); } return 0; } @@ -692,8 +690,9 @@ pub async fn run(args: RemoveArgs) -> i32 { // intact (mirroring the `rollback_failed` contract). A corrupt ledger // is a hard error: we are about to mutate and cannot know what we // would leave wired. `--skip-rollback` ("don't touch my tree") skips - // the revert too — the wiring stays until the next `vendor` run - // reconciles the then-dropped entry. + // the revert too — the wiring stays until `vendor --revert` / a later + // `remove` undoes it (a manifest-tracked entry is also reconcile-reverted + // by the next `vendor` run; detached entries never are). let mut vendor_state = match vendor_state_result { Ok(s) => s, Err(e) => { @@ -744,75 +743,50 @@ pub async fn run(args: RemoveArgs) -> i32 { } // ── hosted leg ────────────────────────────────────────────────────── - // An identifier can also (or only) match hosted records in the - // redirect ledger. Supported ecosystems (cargo, npm-family) unwind - // per-purl; when the identifier covers EVERY record the whole-ledger - // replay serves the rest; otherwise unsupported targets fail closed - // BEFORE the manifest mutation. A corrupt ledger skips the leg with a - // warning (the identifier may still match other stores). + // An identifier can also (or only) match hosted pins in the lockfiles. + // Each is restored to its default upstream registry entry; a pin that + // cannot be fails closed BEFORE the manifest mutation. // `--skip-rollback` leaves hosted wiring untouched, like the vendor - // wiring above; `--preserve-state` still unwinds — hosted has no + // wiring above; `--preserve-state` still restores — hosted has no // preservable local state. let mut hosted_reverted_events: Vec = Vec::new(); - // The hosted leg's run-level advisories (e.g. - // `redirect_npmrc_allow_remote_modified`): printed as they arrive, + // The hosted leg's run-level advisories: printed as they arrive, // carried into the success envelope's `warnings[]`. let mut hosted_leg_warnings: Vec<(String, String)> = Vec::new(); if !args.skip_rollback { - match load_redirect_state(cwd).await { - Err(e) => { - if loud { - eprintln!( - "Warning: cannot read the hosted redirect ledger ({e}); hosted \ - redirects were not examined" - ); + let hosted_matches = hosted_pins_matching(&hosted_pins, &args.identifier); + if !hosted_matches.is_empty() { + let leg = match unwind_hosted(&args.common, &hosted_matches).await { + Ok(leg) => { + hosted_leg_warnings.extend(leg.warnings.iter().cloned()); + leg } - } - Ok(None) => {} - Ok(Some(mut redirect_state)) => { - let hosted_matches = hosted_records_matching(&redirect_state, &args.identifier); - if !hosted_matches.is_empty() { - let leg = - match unwind_hosted(&args.common, &hosted_matches, &mut redirect_state) - .await - { - Ok(leg) => { - hosted_leg_warnings.extend(leg.warnings.iter().cloned()); - leg - } - Err(err) => { - let (code, msg) = hosted_unwind_error(err, true); - emit_error_envelope( - args.common.json, - args.common.dry_run, - code, - msg, - ); - return 1; - } - }; - if args.preserve_state && !leg.reverted.is_empty() && loud { - eprintln!( - "Note: hosted redirects have no preservable local state; \ - their ledger records were dropped with the unwound wiring." - ); - } - // `run_hosted_leg` printed one line per unwound purl. - printed_progress |= loud && !leg.reverted.is_empty(); - let hosted_action = if args.common.dry_run { - PatchAction::Verified - } else { - PatchAction::Removed - }; - for purl in &leg.reverted { - hosted_reverted_events.push( - PatchEvent::new(hosted_action, purl.clone()).with_reason( - "hosted_reverted", - "hosted lockfile redirect unwound on remove", - ), - ); - } + Err(err) => { + let (code, msg) = hosted_unwind_error(err, true); + emit_error_envelope(args.common.json, args.common.dry_run, code, msg); + return 1; } + }; + if args.preserve_state && !leg.reverted.is_empty() && loud { + eprintln!( + "Note: hosted wiring has no preservable local state; its lockfile pins \ + now resolve upstream." + ); + } + // `run_hosted_leg` printed one line per restored purl. + printed_progress |= loud && !leg.reverted.is_empty(); + let hosted_action = if args.common.dry_run { + PatchAction::Verified + } else { + PatchAction::Removed + }; + for purl in &leg.reverted { + hosted_reverted_events.push( + PatchEvent::new(hosted_action, purl.clone()).with_reason( + "hosted_reverted", + "hosted lockfile pin restored to the upstream registry on remove", + ), + ); } } } @@ -840,7 +814,7 @@ pub async fn run(args: RemoveArgs) -> i32 { // the blob sweep below can still preview against the post-removal // reference set. `--preserve-state` deliberately touches neither the // manifest nor the blobs. An emptied manifest stays on disk as - // `{"patches": {}}` — it carries the setup block and the + // `{"patches": {}}` — it carries any legacy setup block and the // empty-vs-missing exit codes of `list`/`apply`/`repair`. let mut updated_manifest = manifest.clone(); let removed = if args.preserve_state { @@ -986,8 +960,9 @@ pub async fn run(args: RemoveArgs) -> i32 { ); } } - // Diff/package archives use the same manifest-uuid keep rule - // (parity with repair and scan --prune). + // Diff archives use the same manifest-uuid keep rule; legacy + // package archives are swept whole (parity with repair and scan + // --prune). for (dir, result) in [("diffs", sweep.diffs), ("packages", sweep.packages)] { if let Some(detail) = sweep_failure(dir, &result) { if loud { @@ -1178,18 +1153,28 @@ async fn revert_vendored_matches( keep_artifact: args.preserve_state, }; let mut leg = RemoveVendorLeg::default(); - for key in keys { - let result = revert_vendor_entry(&args.common.cwd, key, state, opts).await; - for w in &result.warnings { + // Stops at the first hard failure: remove aborts there, leaving the + // remaining matches (and the manifest) untouched. + let reverted = VendoredBackend::new(&args.common, None) + .revert(keys, state, opts, true) + .await; + for RevertedEntry { + key, + warnings, + step, + .. + } in reverted + { + for w in &warnings { if loud { - eprintln!("Warning ({}): {}", w.code, w.detail); + eprintln!("Warning: {}", w.detail); } leg.skipped.push( PatchEvent::new(PatchAction::Skipped, key.clone()) .with_reason(w.code, w.detail.clone()), ); } - match result.step { + match step { VendorRevertStep::Missing => {} VendorRevertStep::Failed(why) => { track_patch_remove_failed( @@ -1298,86 +1283,52 @@ async fn revert_vendored_matches( Ok(leg) } -/// Why a hosted unwind stopped. Each caller renders its own message (the +/// Why a hosted unwind stopped: a pin the upstream restore refused (or a +/// write failure). Each caller renders its own message (the /// manifest-backed path adds that the manifest was not touched). -enum HostedUnwindError { - /// The ledger could not be persisted after the reverts flushed. - Persist(String), - /// Scoped targets whose ecosystem has no per-purl hosted revert. - Unsupported(Vec), - /// A per-purl revert (or the whole-ledger replay) refused. - Failed { what: String, why: String }, +struct HostedUnwindError { + why: String, } -/// Unwind the hosted redirect records in `hosted_matches` and persist the -/// ledger — FIRST, failure or not: the per-purl reverts flush lockfile -/// writes as they go, so an early error return without persisting would -/// strand already-reverted purls' records in the on-disk ledger (lockfiles -/// and ledger desynced; `list`/VEX attest dead wiring). When the matches -/// cover EVERY record the whole-ledger replay serves the ecosystems without -/// a per-purl revert. Shared by the manifest-backed and hosted-only remove -/// paths. +/// Restore the hosted pins in `hosted_matches` to their upstream registry +/// entries. Nothing is written unless every pin resolved (the restore is +/// all-or-nothing per pin, and a refused pin fails the remove). Shared by +/// the manifest-backed and hosted-only remove paths. async fn unwind_hosted( common: &GlobalArgs, - hosted_matches: &[String], - state: &mut RedirectState, + hosted_matches: &[HostedPin], ) -> Result { - let replay_eligible = state.records.keys().all(|p| hosted_matches.contains(p)); - let before = (state.edits.len(), state.records.len()); - let leg = run_hosted_leg(common, hosted_matches, state, replay_eligible).await; + let leg = run_hosted_leg(common, hosted_matches).await; // Printed as soon as the leg returns, so a human run that then fails // still says what it did to the files. print_hosted_leg_warnings(common, &leg.warnings); - if !common.dry_run && (state.edits.len(), state.records.len()) != before { - if let Err(e) = persist_redirect_state(&common.cwd, state).await { - return Err(HostedUnwindError::Persist(e.to_string())); - } - } - if !leg.unsupported.is_empty() { - return Err(HostedUnwindError::Unsupported(leg.unsupported)); + if let Some((_, why)) = leg.failed.first().cloned() { + return Err(HostedUnwindError { why }); } - if let Some((what, why)) = leg.failed.first().cloned() { - return Err(HostedUnwindError::Failed { what, why }); + if let Some(warning) = super::rollback::retire_legacy_redirect_ledger(common).await { + print_hosted_leg_warnings(common, std::slice::from_ref(&warning)); } Ok(leg) } /// Error code + message for a stopped hosted unwind. fn hosted_unwind_error(err: HostedUnwindError, manifest_backed: bool) -> (&'static str, String) { - let note = if manifest_backed { - " The manifest was not modified." - } else { - "" - }; - match err { - HostedUnwindError::Persist(e) => ( - "hosted_revert_failed", - format!("failed to persist the hosted redirect ledger: {e}"), - ), - HostedUnwindError::Unsupported(purls) => ( - "hosted_revert_unsupported", - format!( - "no per-purl hosted-redirect revert exists for: {}. Run an unscoped \ - `socket-patch rollback` to unwind ALL hosted redirects, or re-run \ - `scan --mode hosted` to normalize.{note}", - purls.join(", ") - ), - ), - HostedUnwindError::Failed { what, why } => ( - "hosted_revert_failed", - if manifest_backed { - format!("could not unwind hosted redirect for {what}: {why}.{note}") - } else { - format!("could not unwind hosted redirect for {what}: {why}") - }, - ), - } + // `why` already names the pin (the restore's refusal) or the write + // that failed, with its remedy. + let HostedUnwindError { why } = err; + ( + "hosted_revert_failed", + if manifest_backed { + format!("{why}. The manifest was not modified.") + } else { + why + }, + ) } -/// Remove path for identifiers that match ONLY hosted redirect records -/// (no manifest entry, no vendor-ledger entry): confirm, unwind each -/// record's lockfile wiring, drop it from the redirect ledger, and report -/// `Removed`/`hosted_reverted` events. Like the ledger-only vendored path, +/// Remove path for identifiers that match ONLY hosted lockfile pins (no +/// manifest entry, no vendor-ledger entry): confirm, restore each pin's +/// upstream registry entry, and report `Removed`/`hosted_reverted` events. Like the ledger-only vendored path, /// the unwind IS the removal, so events go through `env.record` and bump /// `summary.removed`. `--skip-rollback` is refused (with no manifest /// entry to delete, removing a hosted patch can only mean unwinding its @@ -1385,8 +1336,7 @@ fn hosted_unwind_error(err: HostedUnwindError, manifest_backed: bool) -> (&'stat /// preservable local state. async fn remove_hosted_only( args: &RemoveArgs, - hosted_matches: Vec, - mut redirect_state: RedirectState, + hosted_matches: Vec, api_token: Option<&str>, org_slug: Option<&str>, ) -> i32 { @@ -1397,8 +1347,8 @@ async fn remove_hosted_only( args.common.dry_run, "hosted_state_retained", format!( - "{} matches only hosted redirect records; removing one means unwinding \ - its lockfile redirect, which --skip-rollback prevents", + "{} matches only hosted lockfile pins; removing one means restoring its \ + upstream registry entry, which --skip-rollback prevents", args.identifier ), ); @@ -1409,9 +1359,9 @@ async fn remove_hosted_only( eprintln!( "The following {} {} unwound and removed:", if hosted_matches.len() == 1 { - "hosted redirect" + "hosted patch" } else { - "hosted redirects" + "hosted patches" }, if args.common.dry_run { "would be" @@ -1419,15 +1369,15 @@ async fn remove_hosted_only( "will be" } ); - for purl in &hosted_matches { - eprintln!(" - {purl}"); + for pin in &hosted_matches { + eprintln!(" - {}", pin.purl); } eprintln!(); } // `--dry-run` previews without mutating — nothing to confirm. let prompt = format!( "Remove {} and unwind {} lockfile wiring?", - plural(hosted_matches.len(), "hosted redirect", "hosted redirects"), + plural(hosted_matches.len(), "hosted patch", "hosted patches"), if hosted_matches.len() == 1 { "its" } else { @@ -1436,29 +1386,15 @@ async fn remove_hosted_only( ); if !args.common.dry_run && !crate::ui::confirm(&prompt, true, &args.common) { if loud { - println!("Removal cancelled."); + println!("{}", crate::ui::CANCELLED); } return 0; } - let leg = match unwind_hosted(&args.common, &hosted_matches, &mut redirect_state).await { + let leg = match unwind_hosted(&args.common, &hosted_matches).await { Ok(leg) => leg, Err(err) => { - match &err { - HostedUnwindError::Unsupported(_) => { - track_patch_remove_failed( - "hosted redirect revert unsupported", - api_token, - org_slug, - ) - .await; - } - HostedUnwindError::Failed { .. } => { - track_patch_remove_failed("hosted redirect revert failed", api_token, org_slug) - .await; - } - HostedUnwindError::Persist(_) => {} - } + track_patch_remove_failed("hosted redirect revert failed", api_token, org_slug).await; let (code, msg) = hosted_unwind_error(err, false); emit_error_envelope(args.common.json, args.common.dry_run, code, msg); return 1; @@ -1481,7 +1417,7 @@ async fn remove_hosted_only( for purl in &leg.reverted { env.record(PatchEvent::new(action, purl.clone()).with_reason( "hosted_reverted", - "hosted lockfile redirect unwound on remove", + "hosted lockfile pin restored to the upstream registry on remove", )); } if args.common.json { @@ -1567,7 +1503,7 @@ async fn remove_ledger_only( }; if !args.common.dry_run && !crate::ui::confirm(&prompt, true, &args.common) { if loud { - println!("Removal cancelled."); + println!("{}", crate::ui::CANCELLED); } return 0; } @@ -1848,12 +1784,12 @@ mod tests { ); assert_eq!( remove_prompt(1, false, false, 0, 1), - "Remove 1 patch, roll back its files, and unwind 1 hosted redirect?" + "Remove 1 patch, roll back its files, and unwind 1 hosted patch?" ); assert_eq!( remove_prompt(1, false, false, 2, 1), "Remove 1 patch, roll back its files, revert 2 vendored artifacts, and unwind 1 \ - hosted redirect?" + hosted patch?" ); assert_eq!( remove_prompt(1, true, false, 1, 0), diff --git a/crates/socket-patch-cli/src/commands/repair.rs b/crates/socket-patch-cli/src/commands/repair.rs index 58d0699be..69cdecb82 100644 --- a/crates/socket-patch-cli/src/commands/repair.rs +++ b/crates/socket-patch-cli/src/commands/repair.rs @@ -14,6 +14,7 @@ use std::path::Path; use std::time::Duration; use crate::args::{apply_env_toggles, parse_bool_flag, GlobalArgs}; +use crate::commands::fetch_stage::files_diffs_cannot_cover; use crate::commands::lock_cli::{acquire_or_emit, error_envelope}; use crate::commands::rollback::{sweep_failure, sweep_unused_artifacts}; use crate::json_envelope::{Command, Envelope, PatchAction, PatchEvent, Status}; @@ -28,10 +29,9 @@ pub struct RepairArgs { // // `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_DOWNLOAD_ONLY=1` (or - // an exported-but-empty `SOCKET_DOWNLOAD_ONLY=`) aborted every `repair` - // invocation. This flag is also outside `GLOBAL_ARG_ENV_VARS`, so - // `main`'s empty-var scrub never rescues it. + // `true`/`false` from the env binding, so `SOCKET_DOWNLOAD_ONLY=1` would + // abort every `repair` invocation. (`main`'s empty-var scrub covers an + // exported-but-empty value via `LOCAL_ARG_ENV_VARS`.) #[arg( long = "download-only", env = "SOCKET_DOWNLOAD_ONLY", @@ -65,18 +65,15 @@ pub async fn run(args: RepairArgs) -> i32 { let mut vendor_references: Option> = None; if tokio::fs::metadata(&manifest_path).await.is_err() { - // Hosted (redirect) mode leaves no local artifacts to repair: the - // lockfiles point at patch.socket.dev URLs, not `.socket/vendor/...`, - // and there is no manifest or vendor ledger. A project whose only - // trace is `redirect-state.json` is therefore a no-op for repair — - // exit success with an informational skip rather than the - // `manifest_not_found` error a bare directory would get. Only cheap - // existence probes (and the read-only lockfile scan) run before the - // lock, so a project with nothing to repair never grows `.socket/`. - let redirect_state = args - .common - .cwd - .join(socket_patch_core::patch::redirect::REDIRECT_STATE_REL); + // Hosted mode leaves no local artifacts to repair: the lockfiles + // point at patch.socket.dev URLs, not `.socket/vendor/...`, and + // there is no manifest or vendor ledger. A project whose only trace + // is its hosted lockfile pins (or a pre-v5 `redirect-state.json`) + // is therefore a no-op for repair — exit success with an + // informational skip rather than the `manifest_not_found` error a + // bare directory would get. Only cheap existence probes (and the + // read-only lockfile scans) run before the lock, so a project with + // nothing to repair never grows `.socket/`. let state_file = args .common .cwd @@ -84,12 +81,20 @@ pub async fn run(args: RepairArgs) -> i32 { let mut has_vendor_traces = tokio::fs::metadata(&state_file).await.is_ok(); if !has_vendor_traces { let refs = - crate::commands::repair_vendor::scan_vendor_references(&args.common.cwd).await; + crate::commands::vendored_backend::repair::scan_vendor_references(&args.common.cwd).await; has_vendor_traces = !refs.is_empty(); vendor_references = Some(refs); } if !has_vendor_traces { - if tokio::fs::metadata(&redirect_state).await.is_ok() { + let legacy_ledger = args + .common + .cwd + .join(socket_patch_core::patch::redirect::REDIRECT_STATE_REL); + let hosted = tokio::fs::metadata(&legacy_ledger).await.is_ok() + || !crate::commands::hosted_inventory(&args.common, &args.common.cwd) + .await + .is_empty(); + if hosted { let msg = HOSTED_ONLY_REASON; if args.common.json { let mut env = Envelope::new(Command::Repair); @@ -146,7 +151,7 @@ pub async fn run(args: RepairArgs) -> i32 { // scanned this ledger-less project. let vendor_references = match vendor_references { Some(refs) => refs, - None => crate::commands::repair_vendor::scan_vendor_references(&args.common.cwd).await, + None => crate::commands::vendored_backend::repair::scan_vendor_references(&args.common.cwd).await, }; // The API client is built lazily: `repair_inner` constructs it only on @@ -259,9 +264,9 @@ fn format_found_missing(n: usize, noun: ArtifactNoun) -> String { /// Why a hosted-only project has nothing to repair (the JSON skip /// reason; the human line adds the period). -const HOSTED_ONLY_REASON: &str = "Hosted redirects need no local repair; re-run \ - `scan --mode hosted` to refresh the lockfile redirects (it also re-checks for stale \ - pre-redirect installs)"; +const HOSTED_ONLY_REASON: &str = "Hosted patches need no local repair; re-run \ + `scan --mode hosted` to refresh the lockfile (it also re-checks for stale \ + pre-hosted installs)"; /// Step 1's line when no patch artifact is missing: why there is nothing /// to download (no manifest, as in a vendored-only project, or an empty @@ -337,6 +342,98 @@ fn format_final_line( } } +/// The `.socket/` source directories a download pass writes into. +struct SourcePaths<'a> { + blobs: &'a Path, + diffs: &'a Path, +} + +/// What one download pass did: how many artifacts were missing, and how +/// many of them it downloaded or failed to. +#[derive(Default)] +struct DownloadPass { + missing: usize, + downloaded: usize, + failed: usize, +} + +/// Step 1's pass over `missing` (non-empty), the `mode` artifacts `m` +/// references: the `--offline` warning, the `--dry-run` preview, or the +/// download and its result lines. +async fn download_pass( + args: &RepairArgs, + client: &mut Option, + m: &socket_patch_core::manifest::schema::PatchManifest, + missing: &[String], + mode: DownloadMode, + paths: &SourcePaths<'_>, +) -> DownloadPass { + let quiet = args.common.json || args.common.silent; + let noun = mode.noun(); + let mut pass = DownloadPass { + missing: missing.len(), + ..DownloadPass::default() + }; + if args.common.offline { + if !quiet { + eprintln!("{}", format_offline_warning(missing, noun)); + } + return pass; + } + if !quiet { + println!("{}", format_found_missing(missing.len(), noun)); + } + if args.common.dry_run { + if !quiet { + println!(); + println!("Would download:"); + for line in format_id_list(missing, noun, DRY_RUN_LIST_CAP) { + println!("{line}"); + } + } + return pass; + } + let mut status = crate::ui::StatusLine::stderr(args.common.json, args.common.silent); + status.set(format!("Downloading {}...", noun.count(missing.len()))); + if client.is_none() { + *client = Some( + get_api_client_with_overrides(args.common.api_client_overrides()) + .await + .0, + ); + } + let client = client.as_ref().expect("client built just above"); + let sources = PatchSources { + blobs_path: paths.blobs, + diffs_path: Some(paths.diffs), + mem_blobs: None, + }; + let fetch_result = fetch_missing_sources(m, &sources, mode, client, None).await; + status.finish(); + pass.downloaded = fetch_result.downloaded; + pass.failed = fetch_result.failed; + if !quiet { + for line in format_fetch_successes(&fetch_result, noun) { + println!("{line}"); + } + } + // Failures are error output: stderr, and not muted by `--silent` + // (`--json` runs carry them in the envelope). + if !args.common.json { + for (i, line) in format_fetch_failures(&fetch_result, noun) + .iter() + .enumerate() + { + if i == 0 { + eprintln!("Error: {line}"); + } else { + eprintln!("{line}"); + } + } + } + pass +} + /// Whether an API token will be found, mirroring the client's chain: the /// `--api-token` flag (clap also maps SOCKET_API_TOKEN into it), then — /// unless `SOCKET_NO_API_TOKEN` vetoes ambient tokens — the env var and the @@ -374,7 +471,6 @@ async fn repair_inner( let socket_dir = crate::args::socket_dir_of(manifest_path, &args.common.cwd); let blobs_path = socket_dir.join("blobs"); let diffs_path = socket_dir.join("diffs"); - let packages_path = socket_dir.join("packages"); let download_mode = DownloadMode::parse(&args.common.download_mode).map_err(|e| e.to_string())?; @@ -401,7 +497,7 @@ async fn repair_inner( // Step 1: Check for and download missing artifacts in the requested // mode. Counts below refer to whatever kind of artifact was requested - // (file blobs, diff archives, or package archives). + // (file blobs or diff archives). // // VENDORED-in-sync manifest entries are excluded: vendor flows keep // patch content in memory and the committed artifact IS the patch, so @@ -416,9 +512,10 @@ async fn repair_inner( let ledger = socket_patch_core::vendor::load_state(&args.common.cwd).await; let no_entries = std::collections::HashMap::new(); let vendor_entries = ledger.as_ref().map(|s| &s.entries).unwrap_or(&no_entries); - // Lockfile vendor references count as vendored even before the ledger - // is reconstructed, so a no-ledger repair doesn't download sources for - // entries the vendored phase is about to own. + // Lockfile vendor references count as vendored even with no ledger + // entry: the committed artifact is the patch, so a no-ledger repair + // must not litter `.socket/` with sources for it (the vendored phase + // reports the missing ledger instead). let referenced_uuids: std::collections::HashSet = vendor_references .iter() .map(|(_, uuid, _)| uuid.clone()) @@ -450,100 +547,67 @@ async fn repair_inner( .into_iter() .collect(), }; - let missing_count = missing_artifacts.len(); let noun = download_mode.noun(); + let paths = SourcePaths { + blobs: &blobs_path, + diffs: &diffs_path, + }; // Whether stdout already carries a line, so the blank separators // between sections never open the output (the offline warning goes // to stderr). - let mut stdout_started = true; - - if missing_artifacts.is_empty() { - if !quiet { - println!("{}", format_nothing_missing(manifest.as_ref(), noun)); + let mut stdout_started = !args.common.offline || missing_artifacts.is_empty(); + let primary = match scoped_manifest.as_ref() { + Some(m) if !missing_artifacts.is_empty() => { + download_pass(args, client, m, &missing_artifacts, download_mode, &paths).await } - } else if args.common.offline { - if !quiet { - eprintln!("{}", format_offline_warning(&missing_artifacts, noun)); - } - stdout_started = false; - } else { - if !quiet { - println!("{}", format_found_missing(missing_artifacts.len(), noun)); - } - - if args.common.dry_run { + _ => { if !quiet { - println!(); - println!("Would download:"); - for line in format_id_list(&missing_artifacts, noun, DRY_RUN_LIST_CAP) { - println!("{line}"); - } + println!("{}", format_nothing_missing(manifest.as_ref(), noun)); } - } else { - let mut status = crate::ui::StatusLine::stderr(args.common.json, args.common.silent); - status.set(format!( - "Downloading {}...", - noun.count(missing_artifacts.len()) - )); - if client.is_none() { - *client = Some( - get_api_client_with_overrides(args.common.api_client_overrides()) - .await - .0, - ); - } - let client = client.as_ref().expect("client built just above"); - let sources = PatchSources { - blobs_path: &blobs_path, - packages_path: Some(&packages_path), - diffs_path: Some(&diffs_path), - mem_blobs: None, - }; - // Step 1 only runs with a manifest (missing_artifacts is - // empty otherwise), so the expect is unreachable. - let m = scoped_manifest - .as_ref() - .expect("step 1 requires a manifest"); - let fetch_result = - fetch_missing_sources(m, &sources, download_mode, client, None).await; - status.finish(); - downloaded_count = fetch_result.downloaded; - download_failed_count = fetch_result.failed; - if !quiet { - for line in format_fetch_successes(&fetch_result, noun) { - println!("{line}"); - } - } - // Failures are error output: stderr, and not muted by - // `--silent` (`--json` runs carry them in the envelope). - if !args.common.json { - for (i, line) in format_fetch_failures(&fetch_result, noun) - .iter() - .enumerate() - { - if i == 0 { - eprintln!("Error: {line}"); - } else { - eprintln!("{line}"); - } - } + DownloadPass::default() + } + }; + // A diff archive has no delta for a file the patch creates, so in diff + // mode that file's blob is downloaded too: without it, a later + // `apply --offline` cannot apply the patch. + let created = match (&scoped_manifest, download_mode) { + (Some(m), DownloadMode::Diff) => { + let created = files_diffs_cannot_cover(m); + let missing: Vec = get_missing_blobs(&created, &blobs_path) + .await + .into_iter() + .collect(); + if missing.is_empty() { + DownloadPass::default() + } else { + download_pass(args, client, &created, &missing, DownloadMode::File, &paths).await } } - } - - // Step 1.5: vendored artifacts — health-check the ledger (and any - // lockfile vendor references with no ledger coverage) and rebuild - // missing/corrupt artifacts. Runs under `--download-only` too: + _ => DownloadPass::default(), + }; + let missing_count = primary.missing; + downloaded_count += primary.downloaded; + download_failed_count += primary.failed; + + // Step 1.5: vendored artifacts — health-check the ledger and re-vendor + // missing/corrupt artifacts through the vendored backend (the patch + // service first, like `vendor`); lockfile vendor references with no + // ledger entry are reported. Runs under `--download-only` too: // restoring artifacts IS repair's download half. The reference scan // and ledger load above are handed over, not repeated. - let vendor_rebuilt = crate::commands::repair_vendor::repair_vendored_artifacts_with_references( + let vendor_rebuilt = crate::commands::vendored_backend::VendoredBackend::new( &args.common, - manifest.as_ref(), - &socket_dir, + None, + ) + .repair( + crate::commands::vendored_backend::repair::RepairRequest { + manifest: manifest.as_ref(), + socket_dir: &socket_dir, + references: &vendor_references, + ledger, + client: client.as_ref(), + }, &mut env, - &vendor_references, - ledger, - client.as_ref(), ) .await; if !quiet && vendor_rebuilt > 0 { @@ -609,13 +673,24 @@ async fn repair_inner( // so a piped stdout never ends in a stray blank line when the // line itself goes to stderr. let other_failure = matches!(env.status, Status::PartialFailure | Status::Error); - let line = format_final_line( - download_failed_count, - other_failure, - noun, - args.common.dry_run, - ); - if download_failed_count > 0 || other_failure { + let failed = download_failed_count + created.failed; + let line = if download_failed_count > 0 && created.failed > 0 { + format!( + "Repair finished with errors: {} and {} were not downloaded.", + noun.count(download_failed_count), + BLOB.count(created.failed) + ) + } else if download_failed_count > 0 { + format_final_line( + download_failed_count, + other_failure, + noun, + args.common.dry_run, + ) + } else { + format_final_line(created.failed, other_failure, BLOB, args.common.dry_run) + }; + if failed > 0 || other_failure { if stdout_started { eprintln!(); } @@ -656,6 +731,28 @@ async fn repair_inner( )); env.mark_partial_failure(); } + if created.downloaded > 0 + || (!args.common.offline && args.common.dry_run && created.missing > 0) + { + let (action, count) = if args.common.dry_run { + (PatchAction::Verified, created.missing) + } else { + (PatchAction::Downloaded, created.downloaded) + }; + env.record( + PatchEvent::artifact(action).with_details(serde_json::json!({ + "count": count, + "mode": DownloadMode::File.as_tag(), + })), + ); + } + if created.failed > 0 { + env.record(PatchEvent::artifact(PatchAction::Failed).with_error( + "download_failed", + format!("{} failed to download", BLOB.count(created.failed)), + )); + env.mark_partial_failure(); + } if blobs_cleaned > 0 { let cleanup_action = if args.common.dry_run { PatchAction::Verified @@ -672,7 +769,7 @@ async fn repair_inner( Ok(( env, RepairCounts { - downloaded: downloaded_count, + downloaded: downloaded_count + created.downloaded, cleaned: blobs_cleaned, bytes_freed, }, @@ -710,8 +807,8 @@ mod tests { ); assert_eq!( HOSTED_ONLY_REASON, - "Hosted redirects need no local repair; re-run `scan --mode hosted` to refresh \ - the lockfile redirects (it also re-checks for stale pre-redirect installs)" + "Hosted patches need no local repair; re-run `scan --mode hosted` to refresh \ + the lockfile (it also re-checks for stale pre-hosted installs)" ); } @@ -785,9 +882,8 @@ mod tests { /// Regression for the offline + dry-run leak: with `--offline` set, the /// download phase is skipped entirely, so even in dry-run mode a missing - /// artifact must NOT produce a "would-download" (verified) event. Before - /// the fix the event was recorded unconditionally on `dry_run && - /// missing > 0`, contradicting the human-readable path (which only warns). + /// artifact must NOT produce a "would-download" (verified) event, + /// matching the human-readable path (which only warns). #[tokio::test] async fn offline_dry_run_does_not_record_download_event() { let tmp = tempfile::tempdir().unwrap(); @@ -894,18 +990,22 @@ mod tests { ); } - /// Cleanup must sweep orphaned diff *and* package archives in addition to - /// blobs, and the reclaimed counts/bytes from all three directories must - /// aggregate into a single `RepairCounts`. Guards against a regression - /// where a cleanup pass uses the wrong directory or drops its tallies. + /// Cleanup must sweep orphaned diff archives and every legacy + /// `.socket/packages/` archive (nothing reads them, so even one named + /// after a manifest UUID goes) in addition to blobs, and the reclaimed + /// counts/bytes from all three directories must aggregate into a single + /// `RepairCounts`. Guards against a regression where a cleanup pass uses + /// the wrong directory or drops its tallies. #[tokio::test] async fn cleanup_sweeps_diff_and_package_archives() { let tmp = tempfile::tempdir().unwrap(); let socket = make_socket(tmp.path()); - // Referenced archives (named after the manifest UUID) must survive. + // A referenced diff archive (named after the manifest UUID) must + // survive; a legacy package archive under the same name must not. write_archive(&socket, "diffs", REFERENCED_UUID, b"kept-diff"); - write_archive(&socket, "packages", REFERENCED_UUID, b"kept-package"); + let legacy_pkg = b"legacy package"; // 14 bytes + write_archive(&socket, "packages", REFERENCED_UUID, legacy_pkg); // Orphan archives (unknown UUIDs) must be swept. let orphan_diff = b"orphan diff archive bytes"; // 25 bytes @@ -929,16 +1029,17 @@ mod tests { .await .expect("repair_inner"); - // Two orphans removed (one diff, one package); the referenced ones stay. - assert_eq!(counts.cleaned, 2, "both orphan archives should be swept"); + // Both orphans and the legacy package archive go; the referenced + // diff archive stays. + assert_eq!(counts.cleaned, 3, "orphans and legacy archives should be swept"); assert_eq!( counts.bytes_freed, - (orphan_diff.len() + orphan_pkg.len()) as u64, + (orphan_diff.len() + orphan_pkg.len() + legacy_pkg.len()) as u64, "bytes_freed must aggregate diff + package reclaim" ); // Cleanup is reported as a SINGLE batched `removed` artifact event whose // `details.count` carries the tally — so the event-count summary is 1 - // (`Summary::bump` increments once per event), and the 2-artifact count + // (`Summary::bump` increments once per event), and the 3-artifact count // is asserted via `counts.cleaned` above and the event details here. assert_eq!(env.summary.removed, 1, "one batched removal event"); let removed = env @@ -952,15 +1053,15 @@ mod tests { .as_ref() .and_then(|d| d.get("count")) .and_then(serde_json::Value::as_u64), - Some(2), - "the batched removal event must report 2 swept artifacts" + Some(3), + "the batched removal event must report 3 swept artifacts" ); assert!(socket .join("diffs") .join(format!("{REFERENCED_UUID}.tar.gz")) .exists()); - assert!(socket + assert!(!socket .join("packages") .join(format!("{REFERENCED_UUID}.tar.gz")) .exists()); diff --git a/crates/socket-patch-cli/src/commands/repair_vendor.rs b/crates/socket-patch-cli/src/commands/repair_vendor.rs deleted file mode 100644 index 1123c5be4..000000000 --- a/crates/socket-patch-cli/src/commands/repair_vendor.rs +++ /dev/null @@ -1,2661 +0,0 @@ -//! `repair`'s vendored-artifact phase: rebuild committed vendor artifacts -//! that are referenced (ledger entry and/or rewired lockfile) but missing -//! or corrupt on disk. -//! -//! Detection is the core health check ([`check_vendored_artifact`]: per-file -//! afterHashes + the whole-file ledger sha256 for file-shaped artifacts). -//! Rebuilds re-dispatch the normal vendor backends — their wired hot paths -//! rebuild the ARTIFACT only and never touch lockfiles or re-record ledger -//! originals — fed by the same pristine-source ladder as `vendor` (installed -//! copy → lockfile-verified registry fetch → ledger-recovered pre-vendor -//! fragment), with patch content staged in memory. -//! -//! Lockfile references with NO ledger coverage (`.socket/vendor` deleted -//! wholesale, state.json included) are RECONSTRUCTED: the uuid is recovered -//! from the lockfile path itself (the contract's uuid-in-path rule), the -//! record from the manifest (or the patch API, yielding a detached entry), -//! and a fresh ledger entry is re-synthesized so sweep/GC/revert know the -//! artifact again — stamped with the lockfile FLAVOR the reference was -//! found in (npm family and pypi), so a later `vendor --revert` routes to the -//! backend whose unwired-revert guard probes the right lockfile. WIRING reconstruction is -//! per-ecosystem: gem recognizes -//! its own Gemfile/lock wiring and rebuilds full revert-capable records -//! ([`socket_patch_core::vendor::gem::reconstruct_gem_wiring`]); the other -//! ecosystems' pre-vendor originals are registry integrity material no -//! offline source can reproduce, so their entries keep empty wiring and the -//! gap is surfaced loudly (`vendor_wiring_unknown`, riding the envelope's -//! run-level `warnings[]` — the entry itself repaired fine, so it must not -//! ride `events[]` as a `skipped` consumers count) — a gem `--revert` of -//! such an entry refuses instead of stranding the pair edit. Existing gem -//! entries with EMPTY wiring (persisted by pre-reconstruction repairs) are -//! backfilled the same way during the ledger-driven pass while healthy. -//! -//! Dir-shaped rebuilds are always LOCAL (the pristine ladder + the recorded -//! patch), while `vendor` may have used the patch service's prebuilt -//! artifact (a converter-generated stub gemspec the local build cannot -//! reproduce): a rebuild whose patched members verify but whose tree -//! differs from the recorded fileInventory refreshes the inventory from -//! the verified rebuild (`vendor_inventory_refreshed`) instead of failing -//! deterministically on every repair. -//! -//! Reconstruction never fingerprints the LIVE artifact into the restored -//! ledger (trust-on-first-use: a tampered unpatched file would become the -//! canonical tree later repairs enforce and VEX attests). A surviving -//! artifact is only restored as-is when an independent anchor vouches for -//! its exact bytes (the rewired npm-family lockfile integrity); otherwise -//! its fingerprint is derived from a member-verified local rebuild, and -//! when no trustworthy pristine source exists the entry is restored -//! fingerprint-less with `vendor_inventory_unverified` — the legacy -//! member-only state — never from the unverifiable live tree. - -use std::collections::{HashMap, HashSet}; -use std::path::{Path, PathBuf}; - -use socket_patch_core::api::client::{get_api_client_with_overrides, ApiClient}; -use socket_patch_core::constants::SOCKET_DIR; -use socket_patch_core::crawlers::CrawlerOptions; -use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; -use socket_patch_core::patch::copy_tree::remove_tree; -use socket_patch_core::utils::fs::read_regular_to_string; -use socket_patch_core::utils::purl::{ - normalize_purl, percent_decode_purl_component, strip_purl_qualifiers, -}; -use socket_patch_core::vendor::state::{VendorArtifact, WiringRecord}; -use socket_patch_core::vendor::{ - self, artifact_is_file_shaped, check_vendored_artifact, compute_dir_inventory, file_sha256_hex, - lock_inventory, parse_vendor_path, registry_fetch, ArtifactHealth, VendorEntry, VendorOutcome, - VendorState, VendorWarning, -}; -use socket_patch_core::vex::time::now_rfc3339; - -use crate::args::GlobalArgs; -use crate::commands::fetch_stage::{stage_vendor_sources_in_memory, MemStageOutcome}; -use crate::commands::vendor::{ - dispatch_vendor_one, ecosystem_in_scope, fetch_pristine_package, persist_vendor_entry, - record_warning, PristineFetch, -}; -use crate::ecosystem_dispatch::{find_packages_for_rollback, partition_purls}; -use crate::json_envelope::{Envelope, PatchAction, PatchEvent, RunWarning}; -use crate::ui::plural; - -/// One broken vendored unit queued for rebuild. -struct Candidate { - purl: String, - entry: VendorEntry, - record: PatchRecord, - detached: bool, - /// True when the ledger entry was re-synthesized from a lockfile - /// reference (it must be persisted after a successful rebuild). - reconstructed: bool, - reason: &'static str, - /// True for a healthy-by-members RECONSTRUCTED entry with no - /// independent integrity anchor (dir-shaped trees; file artifacts no - /// npm-family lock records an integrity for): the live bytes must never - /// be fingerprinted into the restored ledger (trust-on-first-use), so - /// the fingerprint is derived from a member-verified local rebuild — - /// and every pre-rebuild failure falls back to a fingerprint-less - /// restore plus a `vendor_inventory_unverified` warning instead of a - /// hard failure (the artifact itself still verifies member-wise). - soft: bool, -} - -/// Files the vendor backends rewire — the search space for -/// `.socket/vendor///` references when the ledger is gone. -/// The Python locks the root LISTS (`pylock*.toml`, `*.py.lock` + script) -/// and the requirements `-r` include tree are appended at scan time. -const WIRING_FILES: &[&str] = &[ - "vlt-lock.json", - "package-lock.json", - "npm-shrinkwrap.json", - "pnpm-lock.yaml", - "yarn.lock", - "bun.lock", - "package.json", - "Cargo.toml", - "Cargo.lock", - // Pre-v5 vendored cargo wiring (migrated into Cargo.toml on re-run). - ".cargo/config.toml", - ".cargo/config", - "go.mod", - "composer.json", - "composer.lock", - "Gemfile", - "Gemfile.lock", - "uv.lock", - "pyproject.toml", - "poetry.lock", - "pdm.lock", - "Pipfile.lock", - "requirements.txt", -]; - -/// Scan the wiring-bearing files for vendored-artifact references, -/// returning deduped `(ecosystem, uuid, artifact relpath)` triples. Pure -/// text scan plus native binary Bun resolution records and the canonical -/// path parser — the same recovery rule the CLI contract documents. -pub(crate) async fn scan_vendor_references(project_root: &Path) -> Vec<(String, String, String)> { - let mut seen: HashSet<(String, String)> = HashSet::new(); - let mut out = Vec::new(); - if !project_root.join("bun.lock").exists() { - if let Ok(paths) = - socket_patch_core::vendor::bun_lock::binary_vendor_paths(project_root).await - { - for path in paths { - if let Some(parts) = parse_vendor_path(&path) { - if seen.insert((parts.eco.to_string(), parts.uuid.clone())) { - let rel = - format!(".socket/vendor/{}/{}/{}", parts.eco, parts.uuid, parts.leaf); - out.push((parts.eco, parts.uuid, rel)); - } - } - } - } - } - - let mut files: Vec = WIRING_FILES - .iter() - .map(|file| (*file).to_string()) - .collect(); - files.extend(vendor::vlt_lock::vlt_importer_package_jsons(project_root).await); - if let Ok(paths) = socket_patch_core::utils::python_lock::python_lock_paths(project_root) { - for path in paths { - if let Some(script) = - socket_patch_core::utils::python_lock::script_of_lock(&path).map(str::to_string) - { - files.push(script); - } - files.push(path); - } - } - // The requirements planner writes a vendored pin where the original pin - // was — possibly inside a `-r` include — so the root requirements.txt - // alone would miss it (and the orphan sweep, which reuses this scan, - // would delete the include-referenced wheel). An unreadable include - // tree degrades to the root file, matching the per-file tolerance - // below. - if let Ok(includes) = socket_patch_core::vendor::requirements_include_names(project_root).await - { - files.extend(includes); - } - files.sort(); - files.dedup(); - for file in files { - // FIFO-safe: a pipe under a wiring-file name must be skipped, not - // waited on forever in open(2). - let Ok(text) = read_regular_to_string(&project_root.join(file)).await else { - continue; - }; - let mut rest = text.as_str(); - while let Some(idx) = rest.find(".socket") { - let slice = &rest[idx..]; - // `:` ends a reference too: pnpm snapshot keys are - // `name@file::` and yaml mappings suffix the path with a - // colon — npm names/versions never contain one. - let end = slice - .find([ - '"', '\'', '`', ' ', '\t', '\n', '\r', ',', ')', ']', '}', ';', ':', - ]) - .unwrap_or(slice.len()); - let candidate = slice[..end].replace('\\', "/"); - if let Some(parts) = parse_vendor_path(&candidate) { - if seen.insert((parts.eco.to_string(), parts.uuid.clone())) { - out.push(( - parts.eco.to_string(), - parts.uuid.clone(), - candidate.trim_start_matches("./").to_string(), - )); - } - } - rest = &rest[idx + ".socket".len()..]; - } - } - out.sort(); - out -} - -fn synth_entry(eco: &str, uuid: &str, artifact_path: &str, base_purl: &str) -> VendorEntry { - VendorEntry { - ecosystem: eco.to_string(), - base_purl: base_purl.to_string(), - uuid: uuid.to_string(), - artifact: VendorArtifact { - path: artifact_path.to_string(), - sha256: String::new(), - size: None, - platform_locked: None, - file_inventory: None, - }, - wiring: Vec::new(), - lock: None, - took_over_go_patches: false, - detached: false, - record: None, - flavor: None, - uv: None, - pnpm: None, - poetry: None, - pdm: None, - pipenv: None, - } -} - -/// The npm lockfile FLAVOR whose lock carries the -/// `.socket/vendor/npm//` reference, for stamping onto a -/// re-synthesized ledger entry. The strings are `VendorEntry::flavor`'s -/// stable vocabulary (guarded by npm_flavor's `flavor_strings_are_stable` -/// test). Stamping matters: `revert_npm_any` routes by flavor, and each -/// backend's unwired-revert guard probes ITS OWN lockfile — a -/// pnpm-reconstructed entry left at flavor-None would be guarded against -/// package-lock.json instead of pnpm-lock.yaml. Locks are checked in the -/// vendor router's own precedence order (vlt > bun > pnpm > yarn > npm) for the -/// pathological multi-lock case; content sniffs mirror -/// `detect_npm_lock_flavor` (crate-private to core, so re-derived here). -/// `None` when genuinely unknowable — no recognizable lock carries the -/// reference, or the referencing lock's grammar is unrecognized — which -/// routes to the package-lock backend, whose guard also fails closed on -/// unwired entries. -async fn detect_reference_flavor(project_root: &Path, eco: &str, uuid: &str) -> Option { - if eco == "pypi" { - let needle = format!(".socket/vendor/pypi/{uuid}/"); - let mut files = - socket_patch_core::utils::python_lock::python_lock_paths(project_root).ok()?; - // uv.lock outranks the standalone locks (the vendor backend's own - // precedence): a pylock EXPORTED from the wired project lock must not - // relabel the entry `python-lock`. Alphabetical order would. - files.sort_by_key(|file| file != "uv.lock"); - for file in files { - if read_regular_to_string(&project_root.join(&file)) - .await - .ok() - .is_some_and(|text| text.contains(&needle)) - { - return Some( - if file == "uv.lock" { - "uv" - } else { - "python-lock" - } - .to_string(), - ); - } - } - return None; - } - if eco != "npm" { - return None; - } - let needle = format!(".socket/vendor/npm/{uuid}/"); - let read = |name: &'static str| async move { - read_regular_to_string(&project_root.join(name)).await.ok() - }; - if let Some(text) = read("vlt-lock.json").await { - if text.contains(&needle) { - return vendor::vlt_lock::vlt_lock_sniff_ok(&text).then(|| "vlt".to_string()); - } - } - if read("bun.lock").await.is_some_and(|t| t.contains(&needle)) { - return Some("bun".to_string()); - } - if !project_root.join("bun.lock").exists() - && socket_patch_core::vendor::bun_lock::binary_vendor_paths(project_root) - .await - .is_ok_and(|paths| paths.iter().any(|p| p.contains(&needle))) - { - return Some("bun".into()); - } - if let Some(text) = read("pnpm-lock.yaml").await { - if text.contains(&needle) { - // Same version allowlist as core's `sniff_lock_grammar`. - return match text - .lines() - .find_map(|l| l.strip_prefix("lockfileVersion:")) - .map(|v| v.trim().trim_matches(['\'', '"'])) - { - Some("9.0") => Some("pnpm".to_string()), - Some("5.4") | Some("6.0") => Some("pnpm-legacy".to_string()), - _ => None, - }; - } - } - if let Some(text) = read("yarn.lock").await { - if text.contains(&needle) { - // Same head sniff as core's `sniff_yarn_lock` (BOM skipped, - // CRLF-tolerant); berry wins. - let head: Vec<&str> = text - .strip_prefix('\u{feff}') - .unwrap_or(&text) - .lines() - .take(30) - .collect(); - return if head.iter().any(|l| l.starts_with("__metadata:")) { - Some("yarn-berry".to_string()) - } else if head.iter().any(|l| l.trim() == "# yarn lockfile v1") { - Some("yarn-classic".to_string()) - } else { - None - }; - } - } - for name in ["npm-shrinkwrap.json", "package-lock.json"] { - if read(name).await.is_some_and(|t| t.contains(&needle)) { - return Some("package-lock".to_string()); - } - } - None -} - -/// What wiring a re-synthesized ledger entry could recover. -enum WiringReconstruction { - /// The backend recognized its own wiring in the live project files: - /// full revert-capable records, plus any degradation notes to surface. - Wired(Vec, Vec), - /// No wiring recoverable — unsupported ecosystem, or files vendor's - /// grammar does not recognize. The entry keeps empty wiring and the - /// gap is surfaced loudly. - Unknown(String), -} - -/// Per-ecosystem wiring reconstruction for a no-ledger repair. gem is the -/// one ecosystem whose wiring is fully self-describing (the pair edit's -/// originals are derivable from its own emitted forms); the npm family and -/// the rest record pre-vendor REGISTRY integrity fragments that no offline -/// source can reproduce — never guessed at. -async fn reconstruct_entry_wiring( - project_root: &Path, - entry: &VendorEntry, -) -> WiringReconstruction { - match entry.ecosystem.as_str() { - "gem" => match vendor::gem::reconstruct_gem_wiring(project_root, entry).await { - Ok((wiring, notes)) => WiringReconstruction::Wired(wiring, notes), - Err(detail) => WiringReconstruction::Unknown(detail), - }, - _ => WiringReconstruction::Unknown( - "this ecosystem's pre-vendor lock fragments are not offline-recoverable".to_string(), - ), - } -} - -/// Record one artifact that cannot be repaired. An error, so the line -/// prints even under `--silent` (`json` mutes it: the envelope carries it). -fn fail(env: &mut Envelope, json: bool, purl: &str, code: &str, detail: String) { - if !json { - eprintln!("{}", format_repair_failure(purl, &detail)); - } - env.record(PatchEvent::new(PatchAction::Failed, purl.to_string()).with_error(code, detail)); - env.mark_partial_failure(); -} - -/// Report every candidate whose patch content this run could not obtain: -/// a soft one is restored without a fingerprint (counted as rebuilt), any -/// other fails with its own reason code. Shared by the two staging arms — -/// "nothing could be staged" (the whole pass ends here) and "these purls -/// could not, while others staged fine" (the pass continues without them). -/// `unrebuildable` names candidates that already failed earlier and must -/// not be reported twice. -fn report_no_local_source( - env: &mut Envelope, - common: &GlobalArgs, - candidates: &[Candidate], - unrebuildable: &HashSet, - rebuilt: &mut usize, -) { - for c in candidates { - if unrebuildable.contains(&c.purl) { - continue; - } - if c.soft { - soft_restore_without_fingerprint( - env, - common, - &c.purl, - &c.entry, - "its patch content has no local source to rebuild from", - ); - *rebuilt += 1; - continue; - } - fail( - env, - common.json, - &c.purl, - c.reason, - format!( - "the vendored artifact at {} is broken and its patch content has \ - no local source ({})", - c.entry.artifact.path, - if common.offline { - "--offline prevents fetching it" - } else { - "download failed" - } - ), - ); - } -} - -/// `Error: Cannot repair vendored artifact for : `. -fn format_repair_failure(purl: &str, detail: &str) -> String { - format!( - "Error: Cannot repair vendored artifact for {}: {detail}", - normalize_purl(purl) - ) -} - -/// The `repair --dry-run` preview of vendored rebuilds: a heading, then -/// ` - (: )` per artifact. `items` are -/// `(purl, reason code, artifact path)`. -fn format_rebuild_preview(items: &[(String, &str, &str)]) -> Vec { - let mut lines = vec![format!( - "Would rebuild {}:", - plural(items.len(), "vendored artifact", "vendored artifacts") - )]; - lines.extend(items.iter().map(|(purl, reason, path)| { - format!(" - {purl} ({}: {path})", rebuild_reason_label(reason)) - })); - lines -} - -/// Plain words for a rebuild candidate's reason code. -fn rebuild_reason_label(code: &str) -> &str { - match code { - "vendor_artifact_missing" => "missing", - "vendor_artifact_corrupt" => "corrupt", - "vendor_inventory_unverified" => "unverified", - other => other, - } -} - -/// A soft (healthy-by-members, unanchored) reconstruction whose trustworthy -/// rebuild cannot proceed: the entry stays restored WITHOUT a whole-file -/// fingerprint — the legacy member-only state pass 1 keeps warning about -/// (`vendor_inventory_missing` for gems) — and the gap is surfaced, instead -/// of either failing the repair or canonizing the unverifiable live tree. -/// The npm-family lockfiles and the vlt importers' package.json files as -/// they are now, for the unverified-source rebuild's put-back. Read through -/// the FIFO-safe opener: a FIFO or device at one of these paths is left out -/// of the snapshot at once instead of blocking the repair in open(2), like -/// any other file that cannot be read. -async fn snapshot_npm_wiring_files(cwd: &Path) -> Vec<(PathBuf, Option>)> { - let mut names: Vec = [ - "vlt-lock.json", - "package-lock.json", - "npm-shrinkwrap.json", - "pnpm-lock.yaml", - "yarn.lock", - "bun.lock", - "bun.lockb", - ] - .iter() - .map(|n| (*n).to_string()) - .collect(); - names.extend(vendor::vlt_lock::vlt_importer_package_jsons(cwd).await); - let mut snap = Vec::new(); - for name in names { - let p = cwd.join(name); - if let Ok(bytes) = socket_patch_core::utils::fs::read_regular_to_bytes(&p).await { - snap.push((p, Some(bytes))); - } - } - snap -} - -/// The entry itself was already persisted by the pre-rebuild restore. -fn soft_restore_without_fingerprint( - env: &mut Envelope, - common: &GlobalArgs, - purl: &str, - entry: &VendorEntry, - why: &str, -) { - let artifact_path = entry.artifact.path.as_str(); - // A vlt lock rewired to the vendored dir keeps no registry resolution, - // so `vendor` alone has no pristine copy to re-vendor from. - let remedy = if entry.flavor.as_deref() == Some(vendor::vlt_lock::FLAVOR) { - "restore the registry version spec in the package.json files that name the vendored \ - dir, run `vlt install`, then run `socket-patch vendor` to re-vendor and record one" - } else { - "run `socket-patch vendor` to re-vendor and record one" - }; - record_warning( - env, - purl, - &VendorWarning::new( - "vendor_inventory_unverified", - format!( - "the ledger entry was reconstructed but its artifact has no independent \ - integrity anchor and {why}; the entry was restored without a whole-file \ - fingerprint (only the patched members were verified) — {remedy}" - ), - ), - common, - ); - env.record( - PatchEvent::new(PatchAction::Rebuilt, purl.to_string()).with_details(serde_json::json!({ - "path": artifact_path, - "ledgerRestored": true, - "artifactRebuilt": false, - })), - ); -} - -/// `vendor_wiring_unknown` advises about what a FUTURE `vendor --revert` -/// can restore — the entry itself was restored/verified fine, so the -/// advisory rides the envelope's run-level `warnings[]` (the documented -/// carrier for non-fatal advisories) rather than a per-purl `skipped` -/// event, which consumers count as work not done. The purl is baked into -/// `detail` by the callers so attribution survives the run-level move. -fn warn_wiring_unknown(env: &mut Envelope, common: &GlobalArgs, detail: String) { - if !common.silent && !common.json { - eprintln!("Warning (vendor_wiring_unknown): {detail}"); - } - env.warnings.push(RunWarning { - code: "vendor_wiring_unknown".to_string(), - detail, - }); -} - -/// Best-effort removal of a vendored uuid dir after a failed post-verify -/// (never leave unverifiable bytes behind). Prunes the emptied -/// `.socket/vendor//` (and `vendor/`) husks like every other artifact -/// removal, stopping at `.socket/`; a sibling unit or the ledger keeps them. -async fn remove_vendor_dir(cwd: &Path, eco: &str, uuid: &str) { - if let Some(rel) = vendor::path::vendor_uuid_dir_rel(eco, uuid) { - let _ = socket_patch_core::utils::socket_dir::remove_tree_and_prune( - &cwd.join(rel), - &cwd.join(SOCKET_DIR), - ) - .await; - } -} - -/// Move the live uuid dir aside (same parent, `.pre-rebuild`) so the -/// backends' rebuild-on-MISSING trigger fires while the bytes stay -/// recoverable: the dispatch can still refuse or fail — the in-hand -/// installed copy may itself be broken in ways no pre-rebuild rung probes -/// — and a failed dispatch replaced nothing, so the artifact -/// (member-healthy for a soft candidate, corrupt-but-diagnosable for a -/// pass-1 one) must be restorable instead of leaving the wired lockfiles -/// pointing at a bare ENOENT (see the NOTE above the staging step). -/// Returns `(live, kept)` for [`restore_aside_vendor_dir`]; on a rename -/// failure falls back to plain removal (the rebuild trigger must fire) -/// and returns `None`. -async fn set_aside_vendor_dir(cwd: &Path, eco: &str, uuid: &str) -> Option<(PathBuf, PathBuf)> { - let rel = vendor::path::vendor_uuid_dir_rel(eco, uuid)?; - let live = cwd.join(&rel); - let kept = cwd.join(format!("{rel}.pre-rebuild")); - // A crashed earlier run's leftover must not wedge the rename. - let _ = remove_tree(&kept).await; - if tokio::fs::rename(&live, &kept).await.is_ok() { - Some((live, kept)) - } else { - let _ = remove_tree(&live).await; - None - } -} - -/// Put the pre-rebuild bytes back after a dispatch that produced no -/// replacement (clearing any partial husk the failed backend left first). -async fn restore_aside_vendor_dir(live: &Path, kept: &Path) { - let _ = remove_tree(live).await; - let _ = tokio::fs::rename(kept, live).await; -} - -/// Crash recovery for [`set_aside_vendor_dir`]'s transient: a run killed -/// between the move-aside and the backend's replacement leaves -/// `.socket/vendor//.pre-rebuild` as the ONLY copy of bytes the -/// rewired lockfiles still point at, with the live path a bare ENOENT. Put -/// every such leftover back where the wiring expects it before pass 1 -/// classifies the unit (it then re-derives corrupt/soft/healthy from the -/// restored bytes exactly as the crashed run did). A leftover whose live -/// sibling EXISTS is left alone: the live dir may be the completed -/// replacement or a partial husk, and only the health pass can tell — a -/// unit it condemns is set aside again, which clears the leftover. Wet -/// runs only; scope-gated like every other unit; best-effort throughout. -async fn restore_orphaned_pre_rebuild_dirs(common: &GlobalArgs) { - const SUFFIX: &str = ".pre-rebuild"; - let vendor_root = common.cwd.join(".socket/vendor"); - let Ok(mut ecos) = tokio::fs::read_dir(&vendor_root).await else { - return; - }; - while let Ok(Some(eco_dir)) = ecos.next_entry().await { - let eco = eco_dir.file_name().to_string_lossy().into_owned(); - if !ecosystem_in_scope(common, &eco) || !eco_dir.path().is_dir() { - continue; - } - let Ok(mut units) = tokio::fs::read_dir(eco_dir.path()).await else { - continue; - }; - while let Ok(Some(unit)) = units.next_entry().await { - let name = unit.file_name().to_string_lossy().into_owned(); - let Some(uuid) = name.strip_suffix(SUFFIX) else { - continue; - }; - let live = eco_dir.path().join(uuid); - if unit.path().is_dir() && tokio::fs::symlink_metadata(&live).await.is_err() { - let _ = tokio::fs::rename(unit.path(), &live).await; - } - } - } -} - -/// The vendored-artifact phase of `repair`. Runs between the download and -/// cleanup phases (and under `--download-only` — restoring artifacts IS -/// repair's job). `manifest` is `None` when the project has no -/// `.socket/manifest.json` (detached/reconstruction-only repairs). -/// Returns the number of artifacts rebuilt (for the human summary line); -/// failures are carried by `env` (`Failed` events + partial-failure status). -/// -/// `references` is [`scan_vendor_references`]'s `(ecosystem, uuid, -/// artifact relpath)` output for `common.cwd` and `ledger` the caller's -/// `load_state` outcome — both taken by repair.rs under the apply lock -/// this phase runs under (the lockfiles and ledger they describe are the -/// ones the reconstruction below rewires), so neither is re-read here. An -/// unreadable ledger fails this phase loudly (`vendor_state_unreadable`); -/// the caller's own degrade-to-empty policy for its download scoping is -/// its own. `run_client` is the run's API client when the caller already -/// built one (repair.rs's `telemetry_client`): the uuid lookups and the -/// staging fetch reuse it instead of constructing a second (or third) one -/// and re-printing its token advisory; `None` builds lazily on first need. -pub(crate) async fn repair_vendored_artifacts_with_references( - common: &GlobalArgs, - manifest: Option<&PatchManifest>, - socket_dir: &Path, - env: &mut Envelope, - references: &[(String, String, String)], - ledger: std::io::Result, - run_client: Option<&ApiClient>, -) -> usize { - let quiet = common.json || common.silent; - let mut rebuilt = 0usize; - - if !common.dry_run { - restore_orphaned_pre_rebuild_dirs(common).await; - } - - let mut state = match ledger { - Ok(s) => s, - Err(e) => { - // Errors print even under --silent; without this line the - // run exits 1 after a clean-looking repair report. - if !common.json { - eprintln!( - "{}", - crate::commands::vendor::format_state_unreadable(&e.to_string()) - ); - } - env.record( - PatchEvent::artifact(PatchAction::Failed) - .with_error("vendor_state_unreadable", e.to_string()), - ); - env.mark_partial_failure(); - return rebuilt; - } - }; - - // ── Pass 1: ledger-driven health check ─────────────────────────────── - // Shared across both passes so the API client (and its one-time - // token-shape stderr advisory) is constructed at most once per run — - // seeded from the run's client when the caller has one. - let mut api_client: Option = run_client.cloned(); - let mut candidates: Vec = Vec::new(); - let mut ledger_purls: Vec = state.entries.keys().cloned().collect(); - ledger_purls.sort(); - for purl in &ledger_purls { - let entry = state.entries[purl].clone(); - if !ecosystem_in_scope(common, &entry.ecosystem) { - continue; - } - // `detached` is the "no manifest owner" flag. The manifest-driven - // standalone `vendor` embeds the record too, so an embedded record - // does not imply detached: a manifest-owned entry keeps taking the - // manifest's record (a manifest that moved on to a newer patch uuid - // must still surface as vendor_uuid_mismatch below, never repair the - // stale artifact from the embedded copy), and the embedded copy - // stands in only when there is no manifest at all. - let record = match (entry.detached, &entry.record, manifest) { - (true, Some(r), _) => r.clone(), - (_, _, Some(m)) => { - match m - .patches - .get(purl) - .cloned() - .or_else(|| m.patches.values().find(|r| r.uuid == entry.uuid).cloned()) - { - Some(r) => r, - // Dropped from the manifest: the vendor reconcile owns - // reverting it — not repair's call. - None => continue, - } - } - // No manifest at all: the embedded copy, else (a ledger written - // before standalone `vendor` embedded records) recover the record - // from the API below, like a reconstruction. - (_, Some(r), None) => r.clone(), - (_, None, None) => { - match fetch_record_by_uuid(common, &mut api_client, &entry.uuid).await { - Some((_, r)) => r, - None => { - fail( - env, - common.json, - purl, - "vendor_artifact_unrepairable", - format!( - "no manifest record for patch {} and the patch view could not \ - be fetched (offline or API failure)", - entry.uuid - ), - ); - continue; - } - } - } - }; - if record.uuid != entry.uuid { - env.record( - PatchEvent::new(PatchAction::Skipped, purl.clone()).with_reason( - "vendor_uuid_mismatch", - "the manifest's patch uuid moved on; run `socket-patch vendor` (or \ - `scan --vendor`) to re-vendor", - ), - ); - continue; - } - // Pre-v5 cargo wiring in `.cargo/config*`: move it into the root - // Cargo.toml (the v5 location) and record the move in the ledger — - // or restore the manifest entry a pre-v5 multi-version vendor lost — - // and tag an untagged copy + lock entry with the patch uuid. - let entry = if entry.ecosystem == "cargo" { - match vendor::cargo::migrate_legacy_wiring(&entry, &common.cwd, common.dry_run).await { - Ok(Some((migrated, warnings))) => { - for warning in &warnings { - record_warning(env, purl, warning, common); - } - if common.dry_run { - entry - } else if persist_vendor_entry( - common, - env, - &mut state, - purl, - migrated.clone(), - entry.detached, - &record, - ) - .await - { - continue; - } else { - migrated - } - } - Ok(None) => entry, - Err(detail) => { - record_warning( - env, - purl, - &VendorWarning::new( - "cargo_legacy_wiring_kept", - format!( - "the vendored wiring for {} could not be written into \ - Cargo.toml ({detail}); any pre-v5 .cargo/config wiring was \ - left in place", - normalize_purl(purl) - ), - ), - common, - ); - entry - } - } - } else { - entry - }; - let health = check_vendored_artifact(&common.cwd, &entry, &record).await; - if health == ArtifactHealth::Healthy || workspace_copy_issue(&health) { - let mut healed = entry.clone(); - match repair_workspace_copies(&common.cwd, &mut healed, common.dry_run).await { - Ok(true) => { - if common.dry_run { - env.record( - PatchEvent::new(PatchAction::Verified, purl.clone()).with_details( - serde_json::json!({ - "vendorArtifact": true, "wouldRestoreWorkspaceArtifacts": true, - }), - ), - ); - } else if !persist_vendor_entry( - common, - env, - &mut state, - purl, - healed, - entry.detached, - &record, - ) - .await - { - env.record( - PatchEvent::new(PatchAction::Rebuilt, purl.clone()).with_details( - serde_json::json!({ - "path": entry.artifact.path, "workspaceArtifactsRestored": true, - "artifactRebuilt": false, - }), - ), - ); - rebuilt += 1; - } - continue; - } - Ok(false) => {} - Err(detail) => { - fail( - env, - common.json, - purl, - "vendor_artifact_unrepairable", - detail, - ); - continue; - } - } - if workspace_copy_issue(&health) { - continue; - } - } - match health { - ArtifactHealth::Healthy => { - // vlt's `/.gitignore` and `.gitattributes` are not - // part of the artifact: a missing or edited one is simply - // rewritten (DESIGN §4.8). - if entry.ecosystem == "npm" - && entry.flavor.as_deref() == Some(vendor::vlt_lock::FLAVOR) - && !common.dry_run - { - if let Err(e) = - vendor::vlt_lock::restore_vlt_uuid_metadata(&entry, &common.cwd).await - { - fail( - env, - common.json, - purl, - "vendor_artifact_unrepairable", - format!("cannot restore the vendored dir's .gitignore: {e}"), - ); - continue; - } - } - // Dir-shaped artifacts from pre-inventory vendors: the - // health check above could only verify the PATCHED members - // — unpatched-file drift is invisible until a re-vendor - // records the whole-tree inventory. Name the gap — for gem - // only, the one backend that records inventories; the other - // dir-shaped backends (cargo/golang/composer) don't yet, so - // a re-vendor there records nothing and the advice would be - // permanent per-run noise. - if entry.ecosystem == "gem" - && !artifact_is_file_shaped(&entry.artifact.path) - && entry.artifact.file_inventory.is_none() - { - record_warning( - env, - purl, - &VendorWarning::new( - "vendor_inventory_missing", - format!( - "the ledger entry for {} records no file inventory \ - (pre-inventory vendor); only the patched members were \ - verified — re-vendor to make unpatched-file drift \ - detectable", - normalize_purl(purl) - ), - ), - common, - ); - } - // Empty-wiring gem entries (pre-reconstruction repairs - // persisted these): backfill full revert-capable wiring - // from the live pair via the same recognizers the - // no-ledger reconstruction trusts, so `vendor --revert` - // stops refusing with manual cleanup steps. - if entry.ecosystem == "gem" && entry.wiring.is_empty() { - match vendor::gem::reconstruct_gem_wiring(&common.cwd, &entry).await { - Ok((wiring, notes)) => { - if common.dry_run { - env.record( - PatchEvent::new(PatchAction::Verified, purl.clone()) - .with_details(serde_json::json!({ - "vendorArtifact": true, - "wouldRestoreWiring": true, - })), - ); - continue; - } - for w in ¬es { - record_warning(env, purl, w, common); - } - let mut healed = entry.clone(); - healed.wiring = wiring; - let detached = healed.detached; - if persist_vendor_entry( - common, env, &mut state, purl, healed, detached, &record, - ) - .await - { - continue; - } - env.record( - PatchEvent::new(PatchAction::Rebuilt, purl.clone()).with_details( - serde_json::json!({ - "path": entry.artifact.path, - "wiringRestored": true, - "artifactRebuilt": false, - }), - ), - ); - rebuilt += 1; - } - Err(detail) => { - warn_wiring_unknown( - env, - common, - format!( - "the ledger entry for {} records no pre-vendor wiring \ - originals and they cannot be reconstructed from the \ - live files ({detail}); `vendor --revert` cannot \ - restore the project files for this entry", - normalize_purl(purl) - ), - ); - } - } - } - } - ArtifactHealth::StaleUuid => { - env.record( - PatchEvent::new(PatchAction::Skipped, purl.clone()).with_reason( - "vendor_uuid_mismatch", - "a re-vendor is pending for this package; run `socket-patch vendor`", - ), - ); - } - ArtifactHealth::Unverifiable { reason } => { - fail( - env, - common.json, - purl, - "vendor_artifact_unrepairable", - format!("the ledger entry cannot be verified ({reason}); fix state.json"), - ); - } - ArtifactHealth::UnknownFlavor { flavor } => { - record_warning( - env, - purl, - &VendorWarning::new( - "vendor_wiring_unknown_revert_blocked", - format!( - "{} was vendored for the npm flavor `{flavor}`, which this \ - socket-patch release does not understand; left untouched — \ - upgrade socket-patch", - normalize_purl(purl) - ), - ), - common, - ); - } - health @ (ArtifactHealth::Missing | ArtifactHealth::Corrupt { .. }) => { - let reason = if matches!(health, ArtifactHealth::Missing) { - "vendor_artifact_missing" - } else { - "vendor_artifact_corrupt" - }; - let detached = entry.detached; - candidates.push(Candidate { - purl: purl.clone(), - entry, - record, - detached, - reconstructed: false, - reason, - soft: false, - }); - } - } - } - - // ── Pass 2: lockfile references with no ledger coverage ───────────── - let covered: HashSet<(String, String)> = state - .entries - .values() - .map(|e| (e.ecosystem.clone(), e.uuid.clone())) - .collect(); - for (eco, uuid, relpath) in references.iter().cloned() { - if covered.contains(&(eco.clone(), uuid.clone())) || !ecosystem_in_scope(common, &eco) { - continue; - } - // The record: manifest by uuid first, else the patch API (the entry - // is then detached — exactly the manifest-less vendoring shape). - let (purl, record, detached) = - match manifest.and_then(|m| m.patches.iter().find(|(_, r)| r.uuid == uuid)) { - Some((p, r)) => (p.clone(), r.clone(), false), - None => match fetch_record_by_uuid(common, &mut api_client, &uuid).await { - Some((purl, r)) => (purl, r, true), - None => { - fail( - env, - common.json, - &format!("pkg:{eco}/unknown@{uuid}"), - "vendor_artifact_missing", - format!( - "the lockfile references .socket/vendor/{eco}/{uuid}/ but the \ - vendor ledger is gone and the patch view could not be fetched \ - (offline or API failure); restore .socket/vendor/state.json or \ - re-run online" - ), - ); - continue; - } - }, - }; - let mut entry = synth_entry(&eco, &uuid, &relpath, strip_purl_qualifiers(&purl)); - // Stamp the flavor the reference was found in (knowable right here: - // the scan above read specific lockfiles), so `vendor --revert` - // routes to the backend whose unwired-revert guard probes the RIGHT - // lockfile. Genuinely unknowable stays None (guarded fallback). - entry.flavor = detect_reference_flavor(&common.cwd, &eco, &uuid).await; - entry.detached = detached; - if detached { - entry.record = Some(record.clone()); - } - // Wiring reconstruction (fail-closed): gem rebuilds full - // revert-capable records from its own recognizable pair edit; the - // rest keep empty wiring with the gap surfaced loudly — reverting - // such an entry cannot restore the project files. - match reconstruct_entry_wiring(&common.cwd, &entry).await { - WiringReconstruction::Wired(wiring, notes) => { - entry.wiring = wiring; - for w in ¬es { - record_warning(env, &purl, w, common); - } - } - WiringReconstruction::Unknown(detail) => { - warn_wiring_unknown( - env, - common, - format!( - "the ledger entry for {} was reconstructed without pre-vendor \ - wiring originals ({detail}); `vendor --revert` cannot restore \ - the project files for this entry", - normalize_purl(&purl) - ), - ); - } - } - match check_vendored_artifact(&common.cwd, &entry, &record).await { - health if health == ArtifactHealth::Healthy || workspace_copy_issue(&health) => { - // The re-synthesized entry records no sha256/fileInventory, - // so the health check above verified only the patched - // members — whole-file drift (an altered UNPATCHED member) - // is invisible to it. The live bytes must therefore NEVER be - // fingerprinted into the restored ledger: that would be - // trust-on-first-use, canonizing a tampered tree that later - // repairs enforce and VEX attests. Only an INDEPENDENT - // anchor can vouch for the exact bytes — the rewired - // npm-family lockfile integrity, when one records this - // artifact. A "surviving" artifact that no longer matches it - // leaves the package manager broken, so it must be rebuilt, - // never blessed into the reconstructed ledger. - let mut anchored = false; - if let Some(wired) = - lock_inventory::wired_vendor_integrity(&common.cwd, &entry.artifact.path).await - { - let name = npm_coords(&entry.base_purl) - .map(|(n, _)| n) - .unwrap_or_default(); - let intact = match tokio::fs::read(common.cwd.join(&entry.artifact.path)).await - { - Ok(bytes) => { - registry_fetch::artifact_matches_integrity(&bytes, &name, &wired) - .is_ok() - } - Err(_) => false, - }; - if !intact { - candidates.push(Candidate { - purl, - entry, - record, - detached, - reconstructed: true, - reason: "vendor_artifact_corrupt", - soft: false, - }); - continue; - } - anchored = true; - } - if common.dry_run { - let mut details = serde_json::json!({ - "vendorArtifact": true, - "wouldRestoreLedgerEntry": true, - "path": relpath, - }); - if workspace_copy_issue(&health) { - details["wouldRestoreWorkspaceArtifacts"] = serde_json::Value::Bool(true); - } - if !anchored { - // The fingerprint would come from a rebuild, never - // the live tree. - details["wouldRebuild"] = serde_json::Value::Bool(true); - } - env.record( - PatchEvent::new(PatchAction::Verified, purl.clone()).with_details(details), - ); - continue; - } - if anchored { - // The artifact bytes are exactly what the rewired - // lockfile's integrity records; only the ledger was - // lost. Restore the entry (sha/size recomputed from the - // VERIFIED bytes) so GC/sweep/revert know the artifact - // again — without it the next `scan --prune` would sweep - // the uuid dir as an orphan. - fill_artifact_fingerprint(&common.cwd, &mut entry).await; - if let Err(detail) = - repair_workspace_copies(&common.cwd, &mut entry, false).await - { - fail( - env, - common.json, - &purl, - "vendor_artifact_unrepairable", - detail, - ); - continue; - } - let save_failed = persist_vendor_entry( - common, env, &mut state, &purl, entry, detached, &record, - ) - .await; - if save_failed { - continue; - } - env.record( - PatchEvent::new(PatchAction::Rebuilt, purl.clone()).with_details( - serde_json::json!({ - "path": relpath, - "ledgerRestored": true, - "artifactRebuilt": false, - }), - ), - ); - rebuilt += 1; - continue; - } - // No anchor (dir-shaped trees — gem, cargo —, file - // artifacts absent from every npm-family lock): queue a - // SOFT rebuild. The canonical fingerprint is derived from a - // member-verified local rebuild (pristine source + the - // recorded patch, the same dispatch as every other rebuild - // here); when no trustworthy pristine source exists the - // entry is restored WITHOUT a fingerprint — the legacy - // member-only state pass 1 keeps warning about — instead of - // canonizing the live tree. - candidates.push(Candidate { - purl, - entry, - record, - detached, - reconstructed: true, - reason: "vendor_inventory_unverified", - soft: true, - }); - } - ArtifactHealth::Unverifiable { reason } - if reason == "vendor_workspace_artifact_invalid" => - { - fail(env, common.json, &purl, "vendor_artifact_unrepairable", - "workspace tarball paths cannot be validated; fix the binary lock or symbolic links before repairing".into()); - } - _ => { - candidates.push(Candidate { - purl, - entry, - record, - detached, - reconstructed: true, - reason: "vendor_artifact_missing", - soft: false, - }); - } - } - } - - if candidates.is_empty() { - return rebuilt; - } - - // ── Dry run: preview only ──────────────────────────────────────────── - if common.dry_run { - if !quiet { - let items: Vec<(String, &str, &str)> = candidates - .iter() - .map(|c| { - let purl = normalize_purl(&c.purl).into_owned(); - (purl, c.reason, c.entry.artifact.path.as_str()) - }) - .collect(); - println!(); - for line in format_rebuild_preview(&items) { - println!("{line}"); - } - } - for c in &candidates { - env.record( - PatchEvent::new(PatchAction::Verified, c.purl.clone()).with_details( - serde_json::json!({ - "vendorArtifact": true, - "wouldRebuild": true, - "reason": c.reason, - "path": c.entry.artifact.path, - }), - ), - ); - } - return rebuilt; - } - - if !quiet { - println!(); - println!( - "Rebuilding {}...", - plural( - candidates.len(), - "broken vendored artifact", - "broken vendored artifacts" - ) - ); - } - - // ── Soft reconstructions: restore the ledger entry FIRST ───────────── - // Fingerprint-less: the restore must survive even when no trustworthy - // rebuild source turns up below, and the fingerprint slot is only ever - // refilled from a member-verified rebuild — never the live tree. The - // early persist also lets the rebuild's own persist carry the - // reconstructed wiring originals forward by identity. - let mut unrebuildable: HashSet = HashSet::new(); - for c in &candidates { - if c.soft - && persist_vendor_entry( - common, - env, - &mut state, - &c.purl, - c.entry.clone(), - c.detached, - &c.record, - ) - .await - { - // The state write failed (Failed event already recorded): - // nothing below could persist either. - unrebuildable.insert(c.purl.clone()); - } - } - - // NOTE: corrupt artifacts are NOT deleted here. Clearing waits until - // the rebuild loop below, where the patch sources and a pristine - // package source are both in hand (and even there it is a MOVE-ASIDE, - // restored when the dispatch fails) — see the comment there. Destroying - // the corrupt copy before the rebuild-source ladder runs would, on any - // no-source outcome (--offline, node_modules gone, fetch failure), - // convert a corrupt-but-diagnosable integrity-mismatch state into a - // bare ENOENT on the next install (the lock still points at the - // artifact) and erase the forensic evidence of the tamper. - - // ── Patch content (in memory, like all vendor flows) ──────────────── - let records_map: HashMap = candidates - .iter() - .map(|c| (c.purl.clone(), c.record.clone())) - .collect(); - let synth = PatchManifest { - patches: records_map, - setup: None, - }; - // The ledger this pass already holds feeds the staging harvest; repair - // has no download phase, so no seed. - let staged = match stage_vendor_sources_in_memory( - common, - &synth, - socket_dir, - &common.cwd, - Ok(&state.entries), - HashMap::new(), - api_client.as_ref(), - ) - .await - { - MemStageOutcome::Ready(s) => s, - MemStageOutcome::Unavailable => { - report_no_local_source(env, common, &candidates, &unrebuildable, &mut rebuilt); - return rebuilt; - } - }; - // Staging could obtain SOME candidates' content but not others'. The - // ones it could not get the same report the all-unavailable arm above - // gives, and leave the pass; the rest are still rebuilt. - if !staged.unavailable().is_empty() { - let (stuck, rest): (Vec, Vec) = candidates - .into_iter() - .partition(|c| staged.unavailable().iter().any(|(purl, _)| purl == &c.purl)); - report_no_local_source(env, common, &stuck, &unrebuildable, &mut rebuilt); - candidates = rest; - if candidates.is_empty() { - return rebuilt; - } - } - let sources = staged.as_patch_sources(); - - // ── Pristine package sources ───────────────────────────────────────── - let purls: Vec = candidates.iter().map(|c| c.purl.clone()).collect(); - let partitioned = partition_purls(&purls, common.ecosystems.as_deref()); - let crawler_options = CrawlerOptions { - cwd: common.cwd.clone(), - global: common.global, - global_prefix: common.global_prefix.clone(), - }; - // Ledger keys are the manifest spelling — QUALIFIED for release-variant - // ecosystems (gem `?platform=`, pypi `?artifact_id=`, maven - // `?classifier=&ext=`) — while the crawler knows only base purls. A - // base-keyed result map would make the `contains_key(&c.purl)` checks - // below miss every installed - // qualified-key package and fall through to a needless registry fetch - // (or, offline, a spurious unrepairable / fingerprint-less restore). - // The rollback variant fans each base path back out to every qualified - // caller purl — the same fix `vendor_records` carries. - let mut all_packages = find_packages_for_rollback(&partitioned, &crawler_options, quiet).await; - crate::commands::vendor::drop_vendored_installs(&common.cwd, &mut all_packages); - let inventory = lock_inventory::inventory_project(&common.cwd).await; - let client = registry_fetch::build_registry_client(); - let mut holders: Vec = Vec::new(); - // Reconstructed npm candidates fetched UNVERIFIED from the conventional - // registry: their rebuilt tarball MUST match the integrity the rewired - // lockfile records (the trust anchor) before anything is persisted. - let mut must_verify: HashMap = HashMap::new(); - for c in &candidates { - if unrebuildable.contains(&c.purl) { - continue; - } - if all_packages.contains_key(&c.purl) { - // Installed copy: works offline too. But for a RECONSTRUCTED - // entry the copy is an unverified source — the ledger that - // recorded the artifact sha is gone, so the rewired lockfile's - // integrity is the ONLY trust anchor. A copy that drifted since - // vendoring (build-tool artifacts, edited unpatched files) packs - // into a tarball the package manager would reject on its next - // install; register the wired integrity so the rebuilt artifact - // is verified below, exactly like the unverified-registry rung. - if c.reconstructed { - if let Some(wired) = - lock_inventory::wired_vendor_integrity(&common.cwd, &c.entry.artifact.path) - .await - { - must_verify.insert(c.purl.clone(), wired); - } - } - continue; - } - if common.offline { - if c.soft { - soft_restore_without_fingerprint( - env, - common, - &c.purl, - &c.entry, - "the package is not installed and --offline prevents fetching a \ - pristine copy to rebuild from", - ); - rebuilt += 1; - } else { - fail( - env, - common.json, - &c.purl, - c.reason, - format!( - "the vendored artifact at {} is broken, the package is not installed, \ - and --offline prevents fetching a pristine copy", - c.entry.artifact.path - ), - ); - } - unrebuildable.insert(c.purl.clone()); - continue; - } - let pristine = - fetch_pristine_package(&common.cwd, &inventory, &client, &c.purl, Some(&c.entry)).await; - // The `Unverifiable` reason carries the precise, fragment-aware cause - // (e.g. a pdm/poetry/pipenv lock records the wheel hash but no fetchable - // registry URL) — surface it instead of the blanket "no recoverable - // registry fragment", which falsely implies the ledger recorded nothing. - let unverifiable_reason = match &pristine { - PristineFetch::Unverifiable(d) => Some(d.clone()), - _ => None, - }; - match pristine { - // Repair always rebuilds locally, so the pristine tree is read - // either way: materialise it right here, where an extraction - // failure is still the fetch failure it was before the write - // moved off the fetch. - PristineFetch::Fetched(fetched) => { - match fetched.dir().await.map(std::path::Path::to_path_buf) { - Ok(dir) => { - all_packages.insert(c.purl.clone(), dir); - holders.push(fetched); - } - Err(detail) => { - if c.soft { - soft_restore_without_fingerprint( - env, - common, - &c.purl, - &c.entry, - &format!("the pristine fetch failed ({detail})"), - ); - rebuilt += 1; - } else { - fail(env, common.json, &c.purl, "vendor_fetch_failed", detail); - } - unrebuildable.insert(c.purl.clone()); - } - } - } - PristineFetch::NoSource | PristineFetch::Unverifiable(_) => { - // Last rung (npm): the REWIRED lockfile still records the - // integrity of our packed tarball. Fetch the pristine copy - // unverified, rebuild deterministically, and verify the - // REBUILT artifact against that wired integrity below — - // end-to-end fail-closed without ledger or installed copy. - if c.entry.ecosystem == "npm" { - if let Some(wired) = - lock_inventory::wired_vendor_integrity(&common.cwd, &c.entry.artifact.path) - .await - { - if let Some((name, version)) = npm_coords(&c.entry.base_purl) { - match registry_fetch::fetch_npm_unverified(&name, &version, &client) - .await - { - Ok(fetched) => { - match fetched.dir().await.map(std::path::Path::to_path_buf) { - Ok(dir) => { - all_packages.insert(c.purl.clone(), dir); - holders.push(fetched); - must_verify.insert(c.purl.clone(), wired); - } - Err(d) => { - fail( - env, - common.json, - &c.purl, - "vendor_fetch_failed", - d, - ); - unrebuildable.insert(c.purl.clone()); - } - } - continue; - } - Err(registry_fetch::FetchError::Failed(d)) - | Err(registry_fetch::FetchError::Unverifiable(d)) => { - fail(env, common.json, &c.purl, "vendor_fetch_failed", d); - unrebuildable.insert(c.purl.clone()); - continue; - } - } - } - } - } - if c.soft { - soft_restore_without_fingerprint( - env, - common, - &c.purl, - &c.entry, - "no verifiable pristine source exists to rebuild from (the package \ - is not installed, the lockfile is rewired to the vendored artifact, \ - and the reconstructed entry records no recoverable registry \ - fragment)", - ); - rebuilt += 1; - unrebuildable.insert(c.purl.clone()); - continue; - } - let detail = if c.entry.artifact.platform_locked == Some(true) { - "the vendored wheel is platform-locked (compiled); reinstall the \ - package on this platform and re-run repair, or run `socket-patch \ - vendor` to rebuild it" - .to_string() - } else if let Some(reason) = unverifiable_reason { - reason - } else { - "no verifiable pristine source: no installed copy was found, the \ - lockfile is rewired to the (broken) vendored artifact, and the \ - ledger records no recoverable registry fragment" - .to_string() - }; - fail( - env, - common.json, - &c.purl, - "vendor_artifact_unrepairable", - detail, - ); - unrebuildable.insert(c.purl.clone()); - } - PristineFetch::Failed(detail) => { - if c.soft { - soft_restore_without_fingerprint( - env, - common, - &c.purl, - &c.entry, - &format!("the pristine fetch failed ({detail})"), - ); - rebuilt += 1; - } else { - fail(env, common.json, &c.purl, "vendor_fetch_failed", detail); - } - unrebuildable.insert(c.purl.clone()); - } - } - } - - // ── Rebuild via the normal backends ────────────────────────────────── - let vendored_at = now_rfc3339(); - let pipenv_version = tokio::sync::OnceCell::new(); - let installed_sites = socket_patch_core::vendor::pypi::InstalledSiteListings::default(); - for c in candidates { - if unrebuildable.contains(&c.purl) { - continue; - } - let Some(pkg_path) = all_packages.get(&c.purl).cloned() else { - continue; // failed above - }; - // Clear the live uuid dir only NOW — the patch sources and the - // pristine source are both in hand. The backends' wired hot paths - // rebuild on MISSING (one uniform trigger for every ecosystem), - // and the live bytes must never blend into the rebuild: - // - corrupt: the recorded fingerprint already condemned them; - // - soft: the healthy-by-members live tree is exactly what cannot - // be trusted — the fingerprint below derives from the - // member-verified rebuild, never the live bytes. - // Cleared by MOVE-ASIDE, not deletion: an in-hand source does not - // make the dispatch infallible (the installed copy may itself be - // broken in ways no pre-rebuild rung probes), and a dispatch that - // refuses or fails replaced nothing — the bytes go back rather - // than leaving the wired lockfiles pointing at a bare ENOENT and - // destroying the evidence the NOTE above the staging step keeps. - let aside = if c.soft || c.reason == "vendor_artifact_corrupt" { - set_aside_vendor_dir(&common.cwd, &c.entry.ecosystem, &c.entry.uuid).await - } else { - None - }; - // For an unverified-source rebuild the rewired lockfile is the trust - // anchor: snapshot the wiring files so a failed post-verify can put - // them back byte-for-byte. The backend's re-wire may refresh the - // recorded integrity/checksum to the rebuilt tarball's — blessing - // exactly the drifted bytes the verify below is about to reject. - let wiring_snapshot: Option = - if must_verify.contains_key(&c.purl) { - let mut snap = match vendor::bun_lock::snapshot_binary_workspace_artifacts( - &common.cwd, - &c.entry, - ) { - Ok(snap) => snap, - Err(detail) => { - if let Some((live, kept)) = &aside { - restore_aside_vendor_dir(live, kept).await; - } - fail( - env, - common.json, - &c.purl, - "vendor_artifact_unrepairable", - detail, - ); - continue; - } - }; - snap.extend(snapshot_npm_wiring_files(&common.cwd).await); - Some(snap) - } else { - None - }; - let outcome = dispatch_vendor_one( - &c.purl, - pkg_path.as_path().into(), - &common.cwd, - &c.record, - &sources, - &vendored_at, - false, - false, - // Repair rebuilds locally from the recorded patch — no service. - None, - &pipenv_version, - &installed_sites, - ) - .await; - match outcome { - None => { - if let Some((live, kept)) = &aside { - restore_aside_vendor_dir(live, kept).await; - } - fail( - env, - common.json, - &c.purl, - "vendor_artifact_unrepairable", - "no vendor backend for this ecosystem in this build".to_string(), - ); - } - Some(VendorOutcome::Refused { code, detail }) => { - if let Some((live, kept)) = &aside { - restore_aside_vendor_dir(live, kept).await; - } - fail(env, common.json, &c.purl, code, detail); - } - Some(VendorOutcome::Done { - result, - entry, - warnings, - }) => { - if !result.success { - if let Some((live, kept)) = &aside { - restore_aside_vendor_dir(live, kept).await; - } - fail( - env, - common.json, - &c.purl, - "vendor_artifact_rebuild_failed", - result.error.unwrap_or_else(|| "rebuild failed".to_string()), - ); - continue; - } - // The rebuild replaced the artifact: the set-aside copy is - // condemned bytes now (post-verify failures below keep - // their existing nothing-kept contract). - if let Some((_, kept)) = &aside { - if let Some(w) = - vendor::vlt_lock::keep_vlt_links(&c.entry, kept, &common.cwd).await - { - record_warning(env, &c.purl, &w, common); - } - let _ = remove_tree(kept).await; - } - for w in &warnings { - // The Rebuilt event below carries the rebuild signal. - if w.code != "vendor_artifact_rebuilt" { - record_warning(env, &c.purl, w, common); - } - } - // Unverified pristine source: the rebuilt tarball must - // reproduce the integrity the rewired lockfile records. - if let Some(wired) = must_verify.get(&c.purl) { - let abs = common.cwd.join(&c.entry.artifact.path); - let verdict = match tokio::fs::read(&abs).await { - Ok(bytes) => { - let name = npm_coords(&c.entry.base_purl) - .map(|(n, _)| n) - .unwrap_or_default(); - registry_fetch::artifact_matches_integrity(&bytes, &name, wired) - } - Err(e) => Err(format!("cannot read the rebuilt artifact: {e}")), - }; - if let Err(detail) = verdict { - remove_vendor_dir(&common.cwd, &c.entry.ecosystem, &c.entry.uuid).await; - // Put the trust anchor back exactly as it was: the - // backend's re-wire may have refreshed the recorded - // integrity to the rejected rebuild's. - if let Some(snap) = &wiring_snapshot { - for (path, bytes) in snap { - if let Some(bytes) = bytes { - let _ = tokio::fs::write(path, bytes).await; - } else { - let _ = tokio::fs::remove_file(path).await; - } - } - } - fail( - env, - common.json, - &c.purl, - "vendor_artifact_rebuild_failed", - format!( - "the rebuilt artifact does not match the integrity the \ - lockfile records ({detail}); the pristine source may have \ - been tampered with — nothing was kept" - ), - ); - continue; - } - } - // The entry whose recorded fingerprint the post-check must - // match: a backend-returned entry (drift healed / wiring - // re-recorded) wins; a reconstructed entry gets its - // fingerprint computed from the rebuilt bytes. - let from_backend = entry.is_some(); - let mut check_entry = entry.unwrap_or_else(|| c.entry.clone()); - // An artifact-only rebuild hands back a refreshed entry with - // no wiring of its own: re-attach the repaired entry's - // records (a reconstructed entry is not in the ledger yet, - // so the persist below has nothing to carry them from). - if from_backend { - vendor::carry_forward_wiring(&c.entry, &mut check_entry); - } - // The backend's refreshed entry already re-inventoried the - // member-verified rebuild; a changed inventory is the same - // provenance flip the post-verify refresh below reports. - if from_backend - && c.entry.artifact.file_inventory.is_some() - && check_entry.artifact.file_inventory != c.entry.artifact.file_inventory - { - record_warning( - env, - &c.purl, - &VendorWarning::new( - "vendor_inventory_refreshed", - INVENTORY_REFRESHED_DETAIL, - ), - common, - ); - } - if !from_backend && c.reconstructed { - fill_artifact_fingerprint(&common.cwd, &mut check_entry).await; - } - if (from_backend || c.reconstructed) - && persist_vendor_entry( - common, - env, - &mut state, - &c.purl, - check_entry.clone(), - c.detached, - &c.record, - ) - .await - { - continue; - } - // ── Fail-closed post-verify ────────────────────────────── - let mut health = - check_vendored_artifact(&common.cwd, &check_entry, &c.record).await; - // A dir-shaped rebuild whose PATCHED members all verify but - // whose tree differs from the recorded inventory: the entry - // recorded the OTHER build source's tree (the patch - // service's prebuilt artifact carries a converter-generated - // stub gemspec; repair always rebuilds locally). Failing - // here would delete the rebuild, strand the wired pair on a - // dead dir, and deterministically re-fail every later - // repair — so refresh the inventory from the verified - // rebuild instead, loudly. A backend entry whose inventory - // is the repaired entry's own (carried forward — the cargo - // backend records none) is the same case. - if (!from_backend - || check_entry.artifact.file_inventory == c.entry.artifact.file_inventory) - && !c.reconstructed - && matches!(&health, ArtifactHealth::Corrupt { reason } - if reason == "vendor_inventory_mismatch") - { - let abs = common - .cwd - .join(check_entry.artifact.path.replace('\\', "/")); - if let Ok(inv) = artifact_dir_inventory(&check_entry, &abs).await { - check_entry.artifact.file_inventory = Some(inv); - health = - check_vendored_artifact(&common.cwd, &check_entry, &c.record).await; - if health == ArtifactHealth::Healthy { - record_warning( - env, - &c.purl, - &VendorWarning::new( - "vendor_inventory_refreshed", - INVENTORY_REFRESHED_DETAIL, - ), - common, - ); - if persist_vendor_entry( - common, - env, - &mut state, - &c.purl, - check_entry.clone(), - c.detached, - &c.record, - ) - .await - { - continue; - } - } - } - } - match health { - ArtifactHealth::Healthy => { - if !quiet { - println!( - "Rebuilt {} ({})", - normalize_purl(&c.purl), - check_entry.artifact.path - ); - } - env.record( - PatchEvent::new(PatchAction::Rebuilt, c.purl.clone()).with_details( - serde_json::json!({ - "path": check_entry.artifact.path, - "reason": c.reason, - "ledgerRestored": c.reconstructed, - }), - ), - ); - rebuilt += 1; - } - other => { - // The deterministic rebuild did not reproduce the - // recorded artifact (e.g. a tampered ledger sha): - // remove it rather than leave unverifiable bytes. - remove_vendor_dir(&common.cwd, &check_entry.ecosystem, &check_entry.uuid) - .await; - fail( - env, - common.json, - &c.purl, - "vendor_artifact_rebuild_failed", - format!( - "the rebuilt artifact does not match the recorded \ - fingerprint ({other:?}); if state.json was edited, run \ - `socket-patch vendor` to re-vendor from scratch", - ), - ); - } - } - } - } - } - drop(holders); - rebuilt -} - -/// Detail of the `vendor_inventory_refreshed` advisory. -const INVENTORY_REFRESHED_DETAIL: &str = "the rebuilt artifact's patched files verify but its \ - tree differs from the recorded file inventory (the \ - entry was likely vendored from the patch service's \ - prebuilt artifact; repair rebuilds locally); the \ - inventory was refreshed from the verified rebuild — \ - run `socket-patch vendor` to restore the \ - service-built tree"; - -/// Compute and record the artifact fingerprint on a re-synthesized ledger -/// entry: sha256 + size for file-shaped artifacts, the whole-tree file -/// inventory for dir-shaped ones. An uninventoriable dir stays `None` — -/// the entry then behaves as pre-inventory (member-only verification). -async fn fill_artifact_fingerprint(project_root: &Path, entry: &mut VendorEntry) { - let norm = entry.artifact.path.replace('\\', "/"); - let abs = project_root.join(&norm); - if !artifact_is_file_shaped(&norm) { - entry.artifact.file_inventory = artifact_dir_inventory(entry, &abs).await.ok(); - return; - } - if let Some(hex) = file_sha256_hex(&abs).await { - entry.artifact.sha256 = hex; - } - if let Ok(meta) = tokio::fs::metadata(&abs).await { - entry.artifact.size = Some(meta.len()); - } -} - -/// A dir artifact's inventory: an npm dir (vlt's package dir) leaves out -/// its `node_modules/`, which holds vlt's links and is never part of it. -async fn artifact_dir_inventory( - entry: &VendorEntry, - abs: &Path, -) -> Result, String> { - if entry.ecosystem == "npm" { - vendor::compute_package_dir_inventory(abs).await - } else { - compute_dir_inventory(abs).await - } -} - -fn workspace_copy_issue(health: &ArtifactHealth) -> bool { - matches!(health, ArtifactHealth::Corrupt { reason } - if reason == "vendor_workspace_artifact_missing" || reason == "vendor_workspace_artifact_corrupt") -} - -/// Preserve package originals while adopting/rebuilding every member-relative -/// copy from a canonical tarball whose whole-file fingerprint is trusted. -async fn repair_workspace_copies( - root: &Path, - entry: &mut VendorEntry, - dry_run: bool, -) -> Result { - let (wiring, mut changed) = - vendor::bun_lock::repair_binary_workspace_artifacts(root, entry, dry_run).await?; - for record in wiring { - match entry - .wiring - .iter_mut() - .find(|previous| previous.kind == record.kind && previous.file == record.file) - { - Some(previous) if *previous != record => { - *previous = record; - changed = true; - } - Some(_) => {} - None => { - entry.wiring.push(record); - changed = true; - } - } - } - Ok(changed) -} - -/// Fetch one patch view by uuid (proxy-aware) and shape it as a manifest -/// record; `None` offline or on any API failure. `client_cache` holds the -/// one API client the whole vendored-artifact phase shares — construction -/// re-prints the token-shape stderr advisory, so N uuid lookups must not -/// print it N times. Built lazily: a run with nothing to look up never -/// constructs (or warns) at all. -async fn fetch_record_by_uuid( - common: &GlobalArgs, - client_cache: &mut Option, - uuid: &str, -) -> Option<(String, PatchRecord)> { - if common.offline { - return None; - } - if client_cache.is_none() { - *client_cache = Some( - get_api_client_with_overrides(common.api_client_overrides()) - .await - .0, - ); - } - let client = client_cache - .as_ref() - .expect("client_cache was just initialized above"); - let patch = client.fetch_patch(uuid).await.ok()??; - Some(crate::commands::get::record_from_patch_response(&patch)) -} - -/// `pkg:npm/@` → (name, version); the name may be scoped. -/// `base_purl` is stored verbatim percent-encoded (`pkg:npm/%40scope/…`), -/// so each component is decoded like the npm backend's own coordinate -/// parser — the registry fetch and the berry cache-checksum recipe both -/// need the decoded name. -fn npm_coords(base_purl: &str) -> Option<(String, String)> { - let rest = strip_purl_qualifiers(base_purl).strip_prefix("pkg:npm/")?; - let (name_raw, version_raw) = rest.rsplit_once('@')?; - if name_raw.is_empty() || version_raw.is_empty() { - return None; - } - let name = name_raw - .split('/') - .map(percent_decode_purl_component) - .collect::>() - .join("/"); - let version = percent_decode_purl_component(version_raw).into_owned(); - Some((name, version)) -} - -#[cfg(test)] -mod tests { - use super::*; - - /// The unverified-rebuild snapshot reads vlt-lock.json and the other - /// npm-family locks through the FIFO-safe opener: a FIFO at any of them - /// is left out of the snapshot at once instead of blocking the repair - /// in open(2), and the regular files are still captured. - #[cfg(unix)] - #[tokio::test] - async fn wiring_snapshot_skips_fifo_locks_instead_of_wedging() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - let fifos = [root.join("vlt-lock.json"), root.join("package-lock.json")]; - for fifo in &fifos { - let c = std::ffi::CString::new(fifo.to_str().unwrap()).unwrap(); - // SAFETY: plain libc call on a valid C string. - assert_eq!(unsafe { libc::mkfifo(c.as_ptr(), 0o644) }, 0); - } - std::fs::write(root.join("pnpm-lock.yaml"), b"lockfileVersion: '9.0'\n").unwrap(); - - let snap = match tokio::time::timeout( - std::time::Duration::from_secs(5), - snapshot_npm_wiring_files(root), - ) - .await - { - Ok(snap) => snap, - Err(_) => { - use std::os::unix::fs::OpenOptionsExt as _; - for fifo in &fifos { - let _ = std::fs::OpenOptions::new() - .write(true) - .custom_flags(libc::O_NONBLOCK) - .open(fifo); - } - panic!("the wiring snapshot must fail fast on FIFO locks"); - } - }; - let names: Vec = snap - .iter() - .map(|(p, _)| p.file_name().unwrap().to_string_lossy().into_owned()) - .collect(); - assert_eq!(names, ["pnpm-lock.yaml"]); - assert_eq!(snap[0].1.as_deref(), Some(&b"lockfileVersion: '9.0'\n"[..])); - } - - /// Build a local native binary resolution through the public binary - /// rewrite entry point, which shares the codec with vendor's backend. - fn native_binary_vendor_fixture(uuid: &str) -> Vec { - use socket_patch_core::patch::redirect::{ - rewrite_bun_binary, DepOverride, Integrity, RewriteResult, - }; - let bytes = - include_bytes!("../../../socket-patch-core/tests/fixtures/bun-lockb/1.1.45/bun.lockb"); - let mut result = RewriteResult::default(); - rewrite_bun_binary( - bytes, - &[DepOverride { - ecosystem: "npm".into(), - name: "minimist".into(), - namespace: None, - version: "1.2.2".into(), - token: String::new(), - patch_uuid: uuid.into(), - artifact_url: format!("./.socket/vendor/npm/{uuid}/minimist-1.2.2.tgz"), - berry_zip_url: None, - registry_override: None, - integrity: Integrity { - sha512: Some(format!("sha512-{}", "A".repeat(86) + "==")), - ..Default::default() - }, - }], - &mut result, - ); - assert!(result.warnings.is_empty(), "{:?}", result.warnings); - result.binary_files.remove("bun.lockb").unwrap() - } - - #[tokio::test] - async fn binary_bun_repair_recovers_live_references_and_flavor_without_a_ledger() { - let root = tempfile::tempdir().unwrap(); - let uuid = "11111111-1111-4111-8111-111111111111"; - tokio::fs::write( - root.path().join("bun.lockb"), - native_binary_vendor_fixture(uuid), - ) - .await - .unwrap(); - let references = scan_vendor_references(root.path()).await; - assert_eq!( - references, - vec![( - "npm".into(), - uuid.into(), - format!(".socket/vendor/npm/{uuid}/minimist-1.2.2.tgz") - )] - ); - assert_eq!( - detect_reference_flavor(root.path(), "npm", uuid) - .await - .as_deref(), - Some("bun") - ); - assert!(!root.path().join(".socket/vendor/state.json").exists()); - - // Text takes precedence even if the older binary still references - // an artifact. Reconstruction must not revive stale dependencies. - tokio::fs::write(root.path().join("bun.lock"), "{}\n") - .await - .unwrap(); - assert!(scan_vendor_references(root.path()).await.is_empty()); - assert_eq!( - detect_reference_flavor(root.path(), "npm", uuid).await, - None - ); - tokio::fs::remove_file(root.path().join("bun.lock")) - .await - .unwrap(); - tokio::fs::write(root.path().join("bun.lockb"), b"malformed") - .await - .unwrap(); - assert!(scan_vendor_references(root.path()).await.is_empty()); - assert_eq!( - detect_reference_flavor(root.path(), "npm", uuid).await, - None - ); - } - - /// A FIFO under a wiring-file name (here the paired `