diff --git a/.gitattributes b/.gitattributes index 57f48b05..e7f9e267 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,3 +5,8 @@ crates/socket-patch-core/tests/fixtures/redirect/** -text crates/socket-patch-core/tests/fixtures/pdm-native/*.lock -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. +crates/socket-patch-core/tests/fixtures/pnpm-hosted/** -text diff --git a/.github/workflows/bun-compatibility.yml b/.github/workflows/bun-compatibility.yml index bbae07f3..94ee2941 100644 --- a/.github/workflows/bun-compatibility.yml +++ b/.github/workflows/bun-compatibility.yml @@ -34,6 +34,12 @@ on: - 'crates/socket-patch-core/src/utils/process.rs' - 'crates/socket-patch-core/tests/fixtures/redirect/npm/bun/**' - 'crates/socket-patch-cli/src/commands/get.rs' + - 'crates/socket-patch-core/src/vex/**' + - 'crates/socket-patch-cli/src/commands/vex.rs' + - 'crates/socket-patch-cli/src/commands/vex_consumed.rs' + - 'crates/socket-patch-cli/src/commands/vex_sources.rs' + - 'crates/socket-patch-cli/src/commands/apply.rs' + - 'crates/socket-patch-cli/tests/vex_e2e_common/**' - 'crates/socket-patch-cli/src/commands/scan/**' - 'crates/socket-patch-cli/src/commands/rollback.rs' - 'crates/socket-patch-cli/src/commands/vendor.rs' @@ -60,6 +66,12 @@ on: - 'crates/socket-patch-core/src/vendor/**' - 'crates/socket-patch-core/src/patch/redirect/**' - 'crates/socket-patch-cli/src/commands/get.rs' + - 'crates/socket-patch-core/src/vex/**' + - 'crates/socket-patch-cli/src/commands/vex.rs' + - 'crates/socket-patch-cli/src/commands/vex_consumed.rs' + - 'crates/socket-patch-cli/src/commands/vex_sources.rs' + - 'crates/socket-patch-cli/src/commands/apply.rs' + - 'crates/socket-patch-cli/tests/vex_e2e_common/**' - 'crates/socket-patch-cli/src/commands/scan/**' - 'crates/socket-patch-cli/src/commands/rollback.rs' - 'crates/socket-patch-cli/src/commands/vendor.rs' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c15c0c6a..107b5a59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -297,6 +297,12 @@ jobs: # covgap_commands_get masked ~180 binaries that had never run on # Windows at all). Run them all and fail at the end instead. shell: bash + env: + # The real-go hosted/vendored suites (`#![cfg(unix)]`) ride the Go + # installed above for vexctl: fail instead of skip without `go` / + # `zip`, and assert the pinned release. + SOCKET_PATCH_GO_E2E_REQUIRED: '1' + SOCKET_PATCH_GO_E2E_VERSION: '1.24' run: | set -euo pipefail cargo test --workspace --all-features --no-run @@ -304,7 +310,13 @@ jobs: test-release: runs-on: ubuntu-latest - timeout-minutes: 30 + # 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. + timeout-minutes: 40 steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -534,6 +546,7 @@ jobs: gem) EXTRA="--test docker_e2e_vendor_gem" ;; maven) EXTRA="--test docker_e2e_vendor_maven" ;; nuget) EXTRA="--test docker_e2e_vendor_nuget" ;; + pypi) EXTRA="--test docker_e2e_vendor_pypi_pm" ;; esac cargo llvm-cov \ --features docker-e2e \ @@ -667,16 +680,52 @@ jobs: test_filter: --include-ignored - os: ubuntu-latest suite: e2e_nuget - # Host vendor build-proof capstones: fresh-checkout install + - # revert against the REAL composer/bundler toolchains. `#[ignore]`- - # gated (the unpinned `test` job skips them); this job pins the - # toolchain (composer 2, bundler 2.5) below and runs them via - # `--ignored`. ubuntu-latest only — they need the pinned toolchain, - # not per-OS coverage. - - os: ubuntu-latest - suite: e2e_vendor_composer_build - - os: ubuntu-latest - suite: e2e_vendor_gem_build + # 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` + # job skips them); `composer:` / `bundler:` install that exact + # toolchain below, runs them via `--ignored`, and exports the + # suites' `_REQUIRED` + `_VERSION` gates so a leg hard-fails + # instead of skipping on a missing or wrong toolchain. + # ubuntu-latest only — they need the pinned toolchain, not per-OS + # coverage. + # + # composer: 1 (packagist stopped serving composer 1 on 2025-09-01, + # so the fixture resolves from an inline repository), 2.2 LTS (the + # other release with the git-source download fallback the hosted + # redirect must drop) and current 2. + - {os: ubuntu-latest, suite: e2e_vendor_composer_build, composer: '2'} + - {os: ubuntu-latest, suite: e2e_vendor_composer_build, composer: '2.2'} + - {os: ubuntu-latest, suite: e2e_vendor_composer_build, composer: '1'} + - {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`). + - {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'} + - {os: ubuntu-latest, suite: e2e_vendor_gem_build, ruby: '3.1', bundler: '2.1.4'} + - {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 @@ -731,10 +780,17 @@ jobs: # OS, but in parallel off the critical path. They use the runner's # default node/corepack, exactly as they did inside `test` — no # setup-node step, no version change. + # + # `npm_required` turns the npm suites' "npm not installed" soft-skip + # into a hard failure. Not on Windows: `Command::new("npm")` cannot + # resolve `npm.cmd` there, so that leg still skips (a known gap; see + # docs/testing/npm-compatibility.md). - os: ubuntu-latest suite: e2e_redirect_npm_build + npm_required: '1' - os: macos-latest suite: e2e_redirect_npm_build + npm_required: '1' - os: windows-latest suite: e2e_redirect_npm_build - os: ubuntu-latest @@ -823,8 +879,135 @@ jobs: suite: mode_migration_bun bun: '1.3.14' test_filter: --include-ignored + # Manifest-less VEX era legs: the last pre-v2 text-lock writer for + # the hosted/vendored capstones, the v0/v1 writers for the takeover + # suite, and the binary bun.lockb era (1.0 / 1.1 lines) through + # e2e_bun_lockb, which reads the SOCKET_PATCH_BUN_LOCKB_* gates + # exported for it below. + - {os: ubuntu-latest, suite: e2e_redirect_bun_build, bun: '1.3.14', test_filter: --include-ignored} + - {os: ubuntu-latest, suite: e2e_vendor_bun_build, bun: '1.3.14', test_filter: --include-ignored} + - {os: ubuntu-latest, suite: mode_migration_bun, bun: '1.1.45', test_filter: --include-ignored} + - {os: ubuntu-latest, suite: mode_migration_bun, bun: '1.2.23', test_filter: --include-ignored} + - {os: ubuntu-latest, suite: e2e_bun_lockb, bun: '1.0.36', test_filter: --include-ignored} + - {os: ubuntu-latest, suite: e2e_bun_lockb, bun: '1.1.45', test_filter: --include-ignored} + # 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 + # 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 + # re-resolves a transitive override / rejects a repointed + # constraint under --locked; 0.5.5 keeps both). + - {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 + # capstones share ONE test binary (`e2e_vex_build`, a module per + # tool — one optimized link in test-release instead of six), so + # each leg's `test_filter` names its tool's module and keeps + # `--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). + - {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'} + - {os: ubuntu-latest, suite: e2e_vex_build, test_filter: 'poetry:: --ignored', poetry: '2.0.1'} + - {os: ubuntu-latest, suite: e2e_vex_build, test_filter: 'poetry:: --ignored', poetry: '2.4.3'} + - {os: macos-latest, suite: e2e_vex_build, test_filter: 'poetry:: --ignored', poetry: '2.4.3'} + # Real PDM / Hatch capstones (wiremock Socket API that also serves + # the hosted wheel; PyPI for the tool bootstrap + six). + # PDM: lock 2 (1.4), refused 3.1 (1.15) and 4.2 (2.7), 4.3 (2.8), + # the hishel<1 bootstrap window (2.25) and current. + - {os: ubuntu-latest, suite: e2e_vex_build, test_filter: 'pdm:: --ignored', pdm: '1.4.5'} + - {os: ubuntu-latest, suite: e2e_vex_build, test_filter: 'pdm:: --ignored', pdm: '1.15.5'} + - {os: ubuntu-latest, suite: e2e_vex_build, test_filter: 'pdm:: --ignored', pdm: '2.7.4'} + - {os: ubuntu-latest, suite: e2e_vex_build, test_filter: 'pdm:: --ignored', pdm: '2.8.2'} + - {os: ubuntu-latest, suite: e2e_vex_build, test_filter: 'pdm:: --ignored', pdm: '2.25.9'} + - {os: ubuntu-latest, suite: e2e_vex_build, test_filter: 'pdm:: --ignored', pdm: '2.29.2'} + - {os: macos-latest, suite: e2e_vex_build, test_filter: 'pdm:: --ignored', pdm: '2.29.2'} + # Hatch: 1.0 (hatch.toml env vendoring refused, needs >= 1.2), + # 1.2, the virtualenv<21 window (1.9, 1.14) and current. + - {os: ubuntu-latest, suite: e2e_vex_build, test_filter: 'hatch:: --ignored', hatch: '1.0.0'} + - {os: ubuntu-latest, suite: e2e_vex_build, test_filter: 'hatch:: --ignored', hatch: '1.2.1'} + - {os: ubuntu-latest, suite: e2e_vex_build, test_filter: 'hatch:: --ignored', hatch: '1.9.7'} + - {os: ubuntu-latest, suite: e2e_vex_build, test_filter: 'hatch:: --ignored', hatch: '1.14.2'} + - {os: ubuntu-latest, suite: e2e_vex_build, test_filter: 'hatch:: --ignored', hatch: '1.18.1'} + - {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. + - {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'} + - {os: macos-latest, suite: e2e_vex_build, test_filter: 'pip:: --ignored', pip: '22 26'} + # Real-Maven hosted + vendored capstones, one leg per Maven line: + # 3.6 (pre http-blocker), 3.8 (resolver 1.6: no trusted checksums), + # 3.9 (trusted checksums), 4.0 rc. + - {os: ubuntu-latest, suite: e2e_redirect_maven_build, maven: '3.6.3'} + - {os: ubuntu-latest, suite: e2e_redirect_maven_build, maven: '3.8.9'} + - {os: ubuntu-latest, suite: e2e_redirect_maven_build, maven: '3.9.16'} + - {os: ubuntu-latest, suite: e2e_redirect_maven_build, maven: '4.0.0-rc-6'} + - {os: macos-latest, suite: e2e_redirect_maven_build, maven: '3.9.16'} + - {os: ubuntu-latest, suite: e2e_vendor_maven_build, maven: '3.6.3'} + - {os: ubuntu-latest, suite: e2e_vendor_maven_build, maven: '3.8.9'} + - {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'} + # Real .NET SDK capstones: hosted + vendored nuget, one leg per SDK + # major (the suite pins the major through a sandbox global.json). + - {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 + # deno; VEX must attest nothing), one leg per major. + - {os: ubuntu-latest, suite: e2e_vex_build, test_filter: 'deno:: --ignored', deno: '1.46.3'} + - {os: ubuntu-latest, suite: e2e_vex_build, test_filter: 'deno:: --ignored', deno: '2.9.7'} runs-on: ${{ matrix.os }} - timeout-minutes: 25 + # The real-toolchain capstones loop several releases per leg (pip, + # 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: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -850,9 +1033,10 @@ jobs: 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. - # The bun suites run several legs of one suite per OS (one per - # pinned bun release), so the release is part of the key too. - key: ${{ matrix.suite }}-${{ matrix.bun || 'default' }} + # Several suites run one leg per pinned toolchain release (bun, uv, + # poetry, pdm, hatch, pipenv, pip, bundler, composer, maven, dotnet, + # deno), so the release is part of the key too. + key: ${{ matrix.suite }}-${{ 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' }} save-if: ${{ github.ref == 'refs/heads/main' }} - name: Setup Node.js @@ -870,14 +1054,62 @@ jobs: # actions/setup-node). run: npm install -g pnpm@10 + - name: Setup Node.js 24 (named pnpm legs) + if: matrix.suite == 'e2e_redirect_pnpm_build' + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '24.x' + - name: Setup Python - if: matrix.suite == 'e2e_pypi' + if: matrix.suite == 'e2e_pypi' || matrix.uv != '' || matrix.poetry != '' || matrix.pdm != '' || matrix.hatch != '' uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: '3.12.x' + - name: Setup uv (uv legs) + if: matrix.uv != '' + # The exact uv release the leg pins; the suites assert it through + # SOCKET_PATCH_UV_E2E_VERSION. + shell: bash + env: + UV_TEST_VERSION: ${{ matrix.uv }} + run: | + python -m pip install --disable-pip-version-check --upgrade pip "uv==$UV_TEST_VERSION" + uv --version + + - name: Install uv (PDM / Hatch legs) + if: matrix.pdm != '' || matrix.hatch != '' + # The suites bootstrap the pinned PDM / Hatch into their own venv + # with uv; this uv is tooling, not the release under test. + run: python -m pip install uv==0.11.19 + + - name: Install pinned Poetry + if: matrix.poetry != '' + # Poetry 1.0/1.1 need Python 3.8 (their vendored deps predate 3.12); + # 1.2.2 needs the cleo pre-release it was published against. pip 24.0 + # seeds the tool venv (Poetry 1.0 + pip 22.3-23.0 misread the lock-1.0 + # `#sha256=` fragment, as the CLI's advisory says). + shell: bash + env: + POETRY_TEST_VERSION: ${{ matrix.poetry }} + run: | + python -m pip install uv==0.11.19 + case "$POETRY_TEST_VERSION" in 1.0.*|1.1.*) py=3.8.20 ;; *) py=3.12 ;; esac + uv venv "$RUNNER_TEMP/poetry" --python "$py" + extra=""; [ "$POETRY_TEST_VERSION" = "1.2.2" ] && extra="cleo==1.0.0a5" + uv pip install --python "$RUNNER_TEMP/poetry/bin/python" "poetry==$POETRY_TEST_VERSION" pip==24.0 setuptools==69.5.1 $extra + echo "SOCKET_PATCH_POETRY_BIN=$RUNNER_TEMP/poetry/bin/poetry" >> "$GITHUB_ENV" + + - name: Setup uv (Pipenv / pip legs) + if: matrix.pipenv != '' || matrix.pip != '' + uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0 + + - name: Install the Pythons the Pipenv / pip legs run on + if: matrix.pipenv != '' || matrix.pip != '' + run: uv python install 3.8 3.11 3.12 + - name: Setup Ruby - if: matrix.suite == 'e2e_gem' || matrix.suite == 'e2e_vendor_gem_build' + if: matrix.suite == 'e2e_gem' || matrix.bundler != '' uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0 with: # setup-ruby does NOT support `3.2.x` wildcard pinning the @@ -886,22 +1118,71 @@ jobs: # that's currently in the catalog. If the action drops this # patch in the future, bump to whatever's available — see # https://github.com/ruby/setup-ruby for the supported list. - ruby-version: '3.2.10' - # e2e_vendor_gem_build asserts the pair-edit lock grammar against - # the spike-verified bundler 2.5 floor; pin it so the host capstone - # does not ride whatever bundler the runner's Ruby happens to ship. - bundler: '2.5' + # Bundler-era legs pin their own Ruby (`ruby:`): bundler <= 2.2 + # needs Ruby <= 3.3 and 1.17-2.1 need Ruby <= 3.1 (`untaint`). + ruby-version: ${{ matrix.ruby || '3.2.10' }} + # The legs pin their bundler (`bundler:`) so a capstone never rides + # whatever bundler the runner's Ruby ships; bundler 1.x is not + # installable through setup-ruby, see the next step. e2e_gem keeps + # the 2.5 floor. + bundler: ${{ startsWith(matrix.bundler, '1.') && 'none' || matrix.bundler || '2.5' }} bundler-cache: false + - name: Install Bundler 1.x + if: startsWith(matrix.bundler, '1.') + shell: bash + env: + BUNDLER_TEST_VERSION: ${{ matrix.bundler }} + run: | + gem install bundler -v "$BUNDLER_TEST_VERSION" --no-document + echo "BUNDLER_VERSION=$BUNDLER_TEST_VERSION" >> "$GITHUB_ENV" + - name: Setup PHP - if: matrix.suite == 'e2e_vendor_composer_build' - # e2e_vendor_composer_build shells out to a real composer; pin the - # composer 2 major so the composer.lock grammar the pair edit asserts - # stays stable across runners. + if: matrix.composer != '' + # The composer capstones shell out to a real composer; `composer:` + # pins the release line (1, 2.2 LTS, 2) so the composer.lock grammar + # the edits assert stays stable across runners. uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 with: php-version: '8.2' - tools: composer:2 + tools: composer:${{ matrix.composer }} + + - name: Setup Java (Maven legs) + if: matrix.maven != '' + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + with: + distribution: temurin + java-version: '17' + + - name: Install Maven ${{ matrix.maven }} + if: matrix.maven != '' + # 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 }} + 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" + + - name: Setup .NET SDK + if: matrix.dotnet != '' + # Installs next to the runner's preinstalled SDKs; the suite writes + # its own global.json to select the pinned major. + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: ${{ matrix.dotnet }}.0.x + + - name: Setup Deno + if: matrix.deno != '' + uses: denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed # v2.0.5 + with: + deno-version: ${{ matrix.deno }} - name: Setup Bun if: matrix.bun != '' @@ -926,6 +1207,39 @@ jobs: # which the suites treat as unset. SOCKET_PATCH_BUN_E2E_REQUIRED: ${{ matrix.bun != '' && '1' || '' }} SOCKET_PATCH_BUN_E2E_VERSION: ${{ matrix.bun }} + # e2e_bun_lockb only. SOCKET_PATCH_BUN_LOCKB_VERSION is checked with + # `var().is_ok()`, so it must stay EMPTY on every other leg. + SOCKET_PATCH_BUN_LOCKB_REQUIRED: ${{ matrix.suite == 'e2e_bun_lockb' && '1' || '' }} + SOCKET_PATCH_BUN_LOCKB_VERSION: ${{ matrix.suite == 'e2e_bun_lockb' && matrix.bun || '' }} + SOCKET_PATCH_BUN_LOCKB_EXTENDED: ${{ matrix.suite == 'e2e_bun_lockb' && '1' || '' }} + SOCKET_PATCH_BUN_LOCKB_PRODUCTION: ${{ matrix.suite == 'e2e_bun_lockb' && '1' || '' }} + # The same fail-instead-of-skip + pinned-release gates for every + # other real-toolchain capstone. All EMPTY on legs that do not set + # the matching matrix key, which the suites treat as unset. + SOCKET_PATCH_NPM_E2E_REQUIRED: ${{ matrix.npm_required || '' }} + SOCKET_PATCH_CARGO_E2E_REQUIRED: ${{ matrix.suite == 'e2e_safety_cargo_build' && '1' || '' }} + SOCKET_PATCH_UV_E2E_REQUIRED: ${{ matrix.uv != '' && '1' || '' }} + SOCKET_PATCH_UV_E2E_VERSION: ${{ matrix.uv }} + SOCKET_PATCH_POETRY_E2E_REQUIRED: ${{ matrix.poetry != '' && '1' || '' }} + SOCKET_PATCH_POETRY_E2E_VERSION: ${{ matrix.poetry }} + SOCKET_PATCH_PDM_E2E_REQUIRED: ${{ matrix.pdm != '' && '1' || '' }} + SOCKET_PATCH_PDM_E2E_VERSION: ${{ matrix.pdm }} + SOCKET_PATCH_HATCH_E2E_REQUIRED: ${{ matrix.hatch != '' && '1' || '' }} + SOCKET_PATCH_HATCH_E2E_VERSION: ${{ matrix.hatch }} + SOCKET_PATCH_PIPENV_E2E_REQUIRED: ${{ matrix.pipenv != '' && '1' || '' }} + SOCKET_PATCH_PIPENV_E2E_VERSIONS: ${{ matrix.pipenv }} + SOCKET_PATCH_PIP_E2E_REQUIRED: ${{ matrix.pip != '' && '1' || '' }} + SOCKET_PATCH_PIP_E2E_VERSIONS: ${{ matrix.pip }} + SOCKET_PATCH_BUNDLER_E2E_REQUIRED: ${{ matrix.bundler != '' && '1' || '' }} + 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_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' }} # ---------------------------------------------------------------------- @@ -990,8 +1304,140 @@ 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. + run: | + EXTRA="" + case "${{ matrix.ecosystem }}" in + composer) EXTRA="--test docker_e2e_vendor_composer" ;; + nuget) EXTRA="--test docker_e2e_vendor_nuget" ;; + pypi) EXTRA="--test docker_e2e_vendor_pypi_pm" ;; + esac + # shellcheck disable=SC2086 # EXTRA is intentionally word-split + cargo test -p socket-patch-cli --features docker-e2e --test "docker_e2e_${{ matrix.ecosystem }}" $EXTRA + + # ---------------------------------------------------------------------- + # Per-release real-toolchain matrices for the manifest-less VEX work that + # loop several suites per release through a script (yarn) or need a + # toolchain + lock-format axis (cargo). Each leg hard-fails on a skip. + # ---------------------------------------------------------------------- + yarn-classic-matrix: + name: yarn-classic ${{ matrix.release }} + needs: test + runs-on: ubuntu-latest + timeout-minutes: 40 + strategy: + fail-fast: false + matrix: + # 1.0.2 oldest 1.x; 1.6.0 last that installs nothing for a `file:` + # tarball; 1.7.0 first vendored-capable; 1.9.4 last without the + # `integrity` line; 1.10.1 first with it; 1.22.22 current. + release: ['1.0.2', '1.6.0', '1.7.0', '1.9.4', '1.10.1', '1.22.22'] + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Install Rust + run: rustup show + - name: Cache cargo + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + key: yarn-classic-${{ matrix.release }} + save-if: ${{ github.ref == 'refs/heads/main' }} + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '24.x' + - name: Manifest-less VEX over every real yarn classic flow + # The script exports SOCKET_PATCH_YARN_E2E_REQUIRED=1 and + # SOCKET_PATCH_YARN_CLASSIC_E2E_VERSION itself. + env: + COREPACK_ENABLE_DOWNLOAD_PROMPT: '0' + YARN_CLASSIC_RELEASE: ${{ matrix.release }} + run: scripts/yarn-classic-vex-matrix.sh "$YARN_CLASSIC_RELEASE" + + yarn-berry-e2e: + name: yarn-berry ${{ matrix.yarn }} (${{ matrix.os }}) + needs: test + strategy: + 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'] + include: + - {os: macos-latest, yarn: '4.12.0'} + - {os: windows-latest, yarn: '4.12.0'} + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Install Rust + run: rustup show + - name: Cache cargo + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + key: yarn-berry-${{ matrix.yarn }} + save-if: ${{ github.ref == 'refs/heads/main' }} + - name: Enable corepack + run: corepack enable + - name: Yarn berry hosted/vendored + manifest-less VEX + shell: bash + env: + COREPACK_ENABLE_DOWNLOAD_PROMPT: '0' + SOCKET_PATCH_YARN_E2E_REQUIRED: '1' # the script also exports it + YARN_BERRY_RELEASE: ${{ matrix.yarn }} + run: scripts/yarn-berry-vex-matrix.sh "$YARN_BERRY_RELEASE" + + cargo-vex-matrix: + name: cargo ${{ matrix.toolchain }} lock-v${{ matrix.lock || 'own' }} (${{ matrix.os }}) + needs: test + runs-on: ${{ matrix.os }} + timeout-minutes: 40 + 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: macos-latest, toolchain: stable, lock: '1'} + - {os: windows-latest, toolchain: stable, lock: '1'} + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Install Rust + run: rustup show + - name: Install the cargo under test + 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 + shell: bash + env: + SOCKET_PATCH_CARGO_E2E_REQUIRED: '1' + SOCKET_PATCH_CARGO_E2E_TOOLCHAIN: ${{ matrix.toolchain }} + SOCKET_PATCH_CARGO_E2E_LOCK_VERSION: ${{ matrix.lock }} run: | - cargo test -p socket-patch-cli --features docker-e2e --test "docker_e2e_${{ matrix.ecosystem }}" + set -euo pipefail + cargo test -p socket-patch-cli --test e2e_redirect_cargo_build --test e2e_vendor_cargo_build --test mode_migration_cargo + cargo test -p socket-patch-cli --test e2e_safety_cargo_build -- --ignored # ---------------------------------------------------------------------- # Experimental `setup`-flow matrix (NON-BLOCKING). diff --git a/.github/workflows/go-compatibility.yml b/.github/workflows/go-compatibility.yml new file mode 100644 index 00000000..18149fad --- /dev/null +++ b/.github/workflows/go-compatibility.yml @@ -0,0 +1,77 @@ +name: Go patch compatibility + +# Real Go releases (1.18 = first with go.work, 1.21, 1.24 = the CI pin, +# 1.26 = newest) through the hermetic hosted, vendored, go.work and +# go-patches capstones, each ending in the manifest-less VEX step. The +# suites use a file GOPROXY, so no network beyond the toolchain download. + +on: + pull_request: + paths: + - '.github/workflows/go-compatibility.yml' + - 'crates/socket-patch-core/src/vendor/go*.rs' + - 'crates/socket-patch-core/src/vendor/golang.rs' + - 'crates/socket-patch-core/src/patch/redirect/**' + - 'crates/socket-patch-core/src/crawlers/go_crawler.rs' + - 'crates/socket-patch-core/src/vex/**' + - 'crates/socket-patch-cli/src/commands/vex*.rs' + - 'crates/socket-patch-cli/src/commands/vendor*' + - 'crates/socket-patch-cli/tests/e2e_golang_*build.rs' + - 'crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs' + - 'crates/socket-patch-cli/tests/golang_e2e_matrix/**' + - 'crates/socket-patch-cli/tests/e2e_vex_lockfile/golang.rs' + - 'crates/socket-patch-cli/tests/vex_e2e_common/**' + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: go-compat-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + SOCKET_NO_CONFIG: '1' + SOCKET_NO_UPDATE_CHECK: '1' + +jobs: + go: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + go: ['1.18.10', '1.21.13', '1.24.13', '1.26.3'] + include: + # macOS-latest dyld refuses binaries without LC_UUID (Go < 1.24 + # linkers; see ci.yml's vexctl step). + - {os: macos-latest, go: '1.24.13'} + - {os: macos-latest, go: '1.26.3'} + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - run: rustup show + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + key: go-compat + save-if: ${{ github.ref == 'refs/heads/main' }} + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: ${{ matrix.go }} + cache: false + - name: Real-go hosted + vendored flows with manifest-less VEX + env: + SOCKET_PATCH_GO_E2E_VERSION: ${{ matrix.go }} + SOCKET_PATCH_GO_E2E_REQUIRED: '1' + # The hermetic manifest-less VEX cells are the `golang::` module of + # the shared `e2e_vex_lockfile` binary; its filter goes in a second + # command so it does not also filter the build suites' tests. + run: | + cargo test -p socket-patch-cli --no-fail-fast \ + --test e2e_golang_hosted_build --test e2e_vendor_golang_build \ + --test e2e_golang_build --test e2e_golang_workspace_build -- --nocapture + cargo test -p socket-patch-cli --test e2e_vex_lockfile -- golang:: --nocapture diff --git a/.github/workflows/npm-compatibility.yml b/.github/workflows/npm-compatibility.yml new file mode 100644 index 00000000..45ad4759 --- /dev/null +++ b/.github/workflows/npm-compatibility.yml @@ -0,0 +1,95 @@ +name: npm hosted/vendored compatibility + +# Real npm releases (at least one per major, 6 through 12) through the +# hermetic hosted (`e2e_redirect_npm_build`) and vendored +# (`e2e_vendor_npm_build`) capstones, each flow ending in the manifest-less +# VEX checks. Builds the CLI and both test binaries once; every matrix leg +# installs one pinned npm and runs them. See docs/testing/npm-compatibility.md. + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Compile CLI and npm capstones once + run: | + cargo test --locked -p socket-patch-cli --test e2e_redirect_npm_build --test e2e_vendor_npm_build --no-run --message-format=json > target-build.json + python3 - <<'PY' + import json, pathlib, shutil + dest = pathlib.Path('target/npm-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) + name = item.get('target', {}).get('name') + if name in ('e2e_redirect_npm_build', 'e2e_vendor_npm_build') and item.get('executable'): + shutil.copy2(item['executable'], dest / name) + assert all((dest / n).is_file() for n in ('e2e_redirect_npm_build', 'e2e_vendor_npm_build')) + PY + - uses: ./.github/actions/upload-artifact + with: + name: npm-e2e + path: target/npm-e2e/ + if-no-files-found: error + retention-days: 7 + + install-proof: + needs: build + runs-on: ubuntu-latest + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + include: + # node 16's bundled npm 8 writes the v2 lock for the npm 6 x v2 test. + - {npm: '6.14.18', node: '16.20.2'} + - {npm: '7.0.0', node: '16.20.2'} + - {npm: '7.24.2', node: '16.20.2'} + - {npm: '8.19.4', node: '16.20.2'} + - {npm: '9.0.0', node: '18.20.8'} + - {npm: '9.9.4', node: '18.20.8'} + - {npm: '10.9.9', node: '22.22.2'} + - {npm: '11.20.0', node: '24.11.1'} + - {npm: '12.0.0', node: '24.15.0'} + - {npm: '12.1.0', node: '24.15.0'} + steps: + # Same workspace path as the build job, so the socket-patch path + # compiled into the tests (CARGO_BIN_EXE_socket-patch) resolves. + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: npm-e2e* + merge-multiple: true + path: target/debug + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '24.11.1' + - name: Install the pinned npm + env: + NPM_TEST_VERSION: ${{ matrix.npm }} + run: | + npm install --prefix "$RUNNER_TEMP/npm-tool" --no-audit --no-fund "npm@$NPM_TEST_VERSION" + echo "SOCKET_PATCH_NPM_E2E_BIN=$RUNNER_TEMP/npm-tool/node_modules/.bin/npm" >> "$GITHUB_ENV" + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: ${{ matrix.node }} + - name: Hosted + vendored installs, manifest-less VEX + env: + SOCKET_PATCH_NPM_E2E_VERSION: ${{ matrix.npm }} + SOCKET_PATCH_NPM_E2E_REQUIRED: '1' + SOCKET_NO_CONFIG: '1' + SOCKET_NO_UPDATE_CHECK: '1' + run: | + chmod +x target/debug/socket-patch target/debug/e2e_redirect_npm_build target/debug/e2e_vendor_npm_build + target/debug/e2e_redirect_npm_build --include-ignored --test-threads 4 + target/debug/e2e_vendor_npm_build --include-ignored --test-threads 4 diff --git a/.github/workflows/pdm-compatibility.yml b/.github/workflows/pdm-compatibility.yml index ee46908e..dc7ecc14 100644 --- a/.github/workflows/pdm-compatibility.yml +++ b/.github/workflows/pdm-compatibility.yml @@ -20,6 +20,11 @@ on: - 'crates/socket-patch-cli/src/commands/scan/**' - 'crates/socket-patch-cli/src/commands/rollback.rs' - 'crates/socket-patch-core/tests/fixtures/pdm-native/**' + - 'crates/socket-patch-core/src/vex/**' + - 'crates/socket-patch-cli/src/commands/vex*.rs' + - '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/**' push: branches: [main] paths: @@ -83,7 +88,7 @@ jobs: # 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] - 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.25.9', '2.29.2'] + 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' } - { os: macos-latest, pdm: '2.8.2' } @@ -143,3 +148,35 @@ jobs: native-pdm/cases/**/pdm.lock native-pdm/cases/**/*.log retention-days: 14 + + # 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. + capstone: + 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'] + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + 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' }} + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.12' + - run: python -m pip install uv==0.11.19 + - name: Real-PDM hosted + vendored flows with manifest-less VEX + 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 diff --git a/.github/workflows/pipenv-compatibility.yml b/.github/workflows/pipenv-compatibility.yml index 49ba1a20..6d7d9c90 100644 --- a/.github/workflows/pipenv-compatibility.yml +++ b/.github/workflows/pipenv-compatibility.yml @@ -23,6 +23,9 @@ on: - 'crates/socket-patch-cli/src/commands/vex.rs' - 'crates/socket-patch-core/src/patch/redirect/replay.rs' - 'scripts/backtest-pipenv.py' + - 'scripts/tests/test_backtest_harnesses.py' + - 'crates/socket-patch-core/src/vex/**' + - 'crates/socket-patch-cli/src/commands/vex_sources.rs' - '.github/workflows/pipenv-compatibility.yml' push: branches: [main] @@ -70,6 +73,8 @@ jobs: cargo build --locked -p socket-patch-cli mkdir -p "$RUNNER_TEMP/bin" cp target/debug/socket-patch "$RUNNER_TEMP/bin/socket-patch" + - name: Harness unit tests + run: python3 -m unittest scripts/tests/test_backtest_harnesses.py - name: Run the Pipenv matrix env: SOCKET_NO_CONFIG: '1' diff --git a/.github/workflows/pnpm-compatibility.yml b/.github/workflows/pnpm-compatibility.yml index b194dbd1..2aaa8dd9 100644 --- a/.github/workflows/pnpm-compatibility.yml +++ b/.github/workflows/pnpm-compatibility.yml @@ -17,19 +17,22 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - name: Compile CLI and pnpm integration test once + - name: Compile CLI and pnpm integration tests once run: | - cargo test --locked -p socket-patch-cli --test e2e_redirect_pnpm_build --no-run --message-format=json > target-build.json + cargo test --locked -p socket-patch-cli --test e2e_redirect_pnpm_build --test e2e_vendor_pnpm_build --no-run --message-format=json > target-build.json python3 - <<'PY' import json, pathlib, shutil dest = pathlib.Path('target/pnpm-e2e') dest.mkdir(parents=True, exist_ok=True) shutil.copy2('target/debug/socket-patch', dest / 'socket-patch') + names = {'e2e_redirect_pnpm_build': 'pnpm-e2e', 'e2e_vendor_pnpm_build': 'pnpm-vendor-e2e'} for line in pathlib.Path('target-build.json').read_text().splitlines(): item = json.loads(line) - if item.get('target', {}).get('name') == 'e2e_redirect_pnpm_build' and item.get('executable'): - shutil.copy2(item['executable'], dest / 'pnpm-e2e') - assert (dest / 'pnpm-e2e').is_file() + name = item.get('target', {}).get('name') + if name in names and item.get('executable'): + shutil.copy2(item['executable'], dest / names[name]) + for n in names.values(): + assert (dest / n).is_file(), n PY - uses: ./.github/actions/upload-artifact with: @@ -41,7 +44,7 @@ jobs: install-proof: needs: build runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 strategy: fail-fast: false matrix: @@ -100,3 +103,15 @@ jobs: chmod +x bin/socket-patch bin/pnpm-e2e export SOCKET_PATCH_PNPM_E2E_SOCKET_BIN="$PWD/bin/socket-patch" bin/pnpm-e2e pnpm_pinned_matrix --ignored --nocapture + - name: Require vendored lifecycle and manifest-less VEX + # pnpm >= 9: full vendored capstone (vendor + get --mode vendored); + # 7-8: the legacy lifecycle; 1-6: vendoring refused, nothing attested. + env: + SOCKET_PATCH_PNPM_E2E_VERSION: ${{ matrix.pnpm }} + SOCKET_PATCH_PNPM_E2E_REQUIRED: '1' + SOCKET_NO_CONFIG: '1' + SOCKET_NO_UPDATE_CHECK: '1' + run: | + chmod +x bin/socket-patch bin/pnpm-vendor-e2e + export SOCKET_PATCH_PNPM_E2E_SOCKET_BIN="$PWD/bin/socket-patch" + bin/pnpm-vendor-e2e pnpm_pinned_matrix --ignored --nocapture diff --git a/.github/workflows/poetry-compatibility.yml b/.github/workflows/poetry-compatibility.yml new file mode 100644 index 00000000..49367717 --- /dev/null +++ b/.github/workflows/poetry-compatibility.yml @@ -0,0 +1,119 @@ +name: Poetry patch compatibility + +# Native Poetry installer matrix: builds the CLI once per OS, bootstraps each +# pinned Poetry release with uv, and runs `scripts/backtest-poetry.py` — +# hosted, vendored and agent mode against the public production patch, +# verifying the INSTALLED bytes, lock stability, rollback and the +# manifest-less VEX checks. No Socket API token is needed. POSIX only: the +# harness uses `bin/poetry` venv paths. +# +# The hermetic (wiremock) real-Poetry capstone (the `poetry::` module of +# the `e2e_vex_build` test binary) runs in ci.yml's `e2e` matrix; this +# workflow is the production-service, every-release twin. + +on: + pull_request: + paths: + - '.github/actions/upload-artifact/**' + - '.github/workflows/poetry-compatibility.yml' + - 'scripts/backtest-poetry.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' + - 'crates/socket-patch-core/src/vex/discover/pypi_locks.rs' + - 'crates/socket-patch-cli/src/commands/scan/**' + - 'crates/socket-patch-cli/src/commands/vex.rs' + - 'crates/socket-patch-cli/src/commands/vex_sources.rs' + - 'crates/socket-patch-cli/src/commands/rollback.rs' + push: + branches: [main] + paths: + - 'scripts/backtest-poetry.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' + - 'crates/socket-patch-core/src/vex/discover/pypi_locks.rs' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: poetry-compat-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + SOCKET_NO_CONFIG: '1' + SOCKET_NO_UPDATE_CHECK: '1' + +jobs: + build: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + key: poetry-compat + save-if: ${{ github.ref == 'refs/heads/main' }} + - run: cargo build --locked -p socket-patch-cli + - uses: ./.github/actions/upload-artifact + with: + name: poetry-cli-${{ matrix.os }} + path: target/debug/socket-patch + if-no-files-found: error + retention-days: 7 + + native: + needs: build + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + poetry: ['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', '2.4.3'] + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: poetry-cli-${{ matrix.os }}* + merge-multiple: true + path: native-cli + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.12' + # 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 + - name: Exercise the native Poetry installers + shell: bash + env: + POETRY_VERSION: ${{ matrix.poetry }} + run: | + chmod +x native-cli/socket-patch + python3 scripts/backtest-poetry.py \ + --cli native-cli/socket-patch \ + --cli-revision "$GITHUB_SHA" \ + --output native-poetry \ + --versions "$POETRY_VERSION" \ + --modes hosted vendored agent \ + --shapes direct populated crlf \ + --jobs 3 + - uses: ./.github/actions/upload-artifact + if: always() + with: + name: poetry-results-${{ matrix.os }}-${{ matrix.poetry }} + path: | + native-poetry/summary.json + native-poetry/summary.md + native-poetry/captures/**/*.log + retention-days: 14 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6de7b16c..431f9927 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,8 +19,9 @@ into the new version's section — see docs/releasing.md. > **Semver note:** this entry changes `rollback`'s default behavior, narrows > the meaning of its existing `vendored: []` JSON key, makes vendored mode -> manifest-free, and turns a plain non-TTY `scan` report-only — all MAJOR -> per CLI_CONTRACT.md's semver policy — so it ships as the next major +> manifest-free, turns a plain non-TTY `scan` report-only, and makes `vex` +> refuse to attest stale ledger records and corrupt vendor ledgers — all +> MAJOR per CLI_CONTRACT.md's semver policy — so it ships as the next major > release (v5.0). ### Changed (BREAKING) @@ -129,9 +130,165 @@ into the new version's section — see docs/releasing.md. `[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. ### Added +- **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 @@ -452,8 +609,8 @@ into the new version's section — see docs/releasing.md. 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` discloses the same unreadable ledger before its - `manifest_not_found` / `no_patches` exit on a manifest-free project; `list` + `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. @@ -532,6 +689,30 @@ into the new version's section — see docs/releasing.md. "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). +- **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 diff --git a/README.md b/README.md index 3d058c0e..063359d0 100644 --- a/README.md +++ b/README.md @@ -235,8 +235,8 @@ The same patched bytes can reach your build three different ways. The modes diff | 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 — no manifest); the lockfile is rewired to consume them | **None** — the package manager installs the committed bytes | Fully airgapped and hermetic, at the cost of repo size | -| **hosted** — `scan --mode hosted` | No patched bytes in your repo: the lockfile is rewritten so **only** the patched dependencies resolve to Socket-hosted, integrity-pinned packages on `patch.socket.dev`; the edits + patch records are ledgered in `.socket/vendor/redirect-state.json` (commit it — [`vex`](#vex) reads it, and [`rollback`](#rollback) replays its recorded pre-redirect originals to unwind the redirect, see [Undo things](#undo-things)) | Installs must be able to reach `patch.socket.dev` (no CLI, no install hook) | Smallest possible diff (lockfile + ledger); not for airgapped installs | +| **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 @@ -257,6 +257,28 @@ Mode support varies by ecosystem — e.g. Go can't do hosted, Rush monorepos can 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 @@ -350,7 +372,7 @@ 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 +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 @@ -416,8 +438,9 @@ And `setup --remove` reverts the install hooks that `setup` added. > 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, and a leftover ledger would still let [`vex`](#vex) attest the removed -> redirects. +> 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 @@ -650,8 +673,9 @@ socket-patch apply --vex socket.vex.json ### `vex` Generate an [OpenVEX](https://github.com/openvex) 0.2.0 attestation describing the -vulnerabilities that the applied patches have mitigated. See [OpenVEX -attestations](#openvex-attestations) below for the full workflow. +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. **Usage:** ```bash @@ -663,7 +687,7 @@ socket-patch vex [options] |------|---------|-------------| | `-O, --output ` | `SOCKET_VEX_OUTPUT` | Write the VEX document to this path instead of stdout. Required when combined with `--json`. | | `--product ` | `SOCKET_VEX_PRODUCT` | Override the auto-detected top-level product PURL/identifier. | -| `--no-verify` | `SOCKET_VEX_NO_VERIFY` | Skip the on-disk file-hash check and trust the manifest — useful on a build machine that doesn't have the patched files laid out. | +| `--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. | @@ -691,7 +715,8 @@ in one pass). Instead of patching installed packages in place (machine-local sta 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/`) — along with the lockfile edits, and **every fresh checkout +`.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. @@ -1101,30 +1126,38 @@ socket-patch repair --json ## OpenVEX attestations -`socket-patch vex` turns your local manifest into a machine-readable statement of *which +`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. Reads `.socket/manifest.json` and, unless `--no-verify` is passed, re-checks each - patched file's hash on disk so the attestation only covers patches that are actually - applied. [Vendored](#vendor) patches are verified against the **committed artifact** - instead of the installed tree (their impact statement carries a `(vendored)` marker), - and need no `setup` install hook to be attested. Patches vendored by - `scan --mode vendored` attest from the vendor ledger's embedded records, and - [hosted-mode](#three-patch-modes) patches attest from the redirect ledger - (`.socket/vendor/redirect-state.json`, marker `(redirected)` — hash-verified against - the installed tree post-install), so `vex` works even with no manifest file at all. -2. Auto-detects the top-level **product** identifier (override with `--product`), probing +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/@` -3. Emits an OpenVEX 0.2.0 document whose statements mark each mitigated vulnerability as + - `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. @@ -1137,7 +1170,7 @@ Each statement's impact string records *how* the patch is persisted — one mark |---|---|---|---| | `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. When emitted in-run by `scan --mode hosted --vex`, the statement is attested **from the redirect ledger without hash verification** (the bytes are fetched at install time — the JSON `vex` summary carries `verified: false`) | Ensure installs still resolve from `patch.socket.dev` (the lockfile edit is intact), and run `socket-patch vex` **after installing** — it re-reads the ledger and hash-verifies the redirected patches against the installed tree | +| `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 @@ -1167,9 +1200,63 @@ grype --vex socket.vex.json trivy image --vex socket.vex.json ``` -Apply patches first (in any mode) — `vex` errors with `no_patches` when there is nothing -to attest (an empty or missing manifest, no vendored ledger entries, and no hosted -redirect records). +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. + +### 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 +``` + +Behavior worth knowing: + +- **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. + +| 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 | +| cargo | `Cargo.lock`, `Cargo.toml`, `.cargo/config[.toml]` | Root manifest + project config only (no `$CARGO_HOME` / parent configs); 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 full recognition rules are in +[CLI_CONTRACT.md](crates/socket-patch-cli/CLI_CONTRACT.md) ("Manifest-less VEX"). ### Inline VEX on `apply` / `scan` / `vendor` @@ -1195,12 +1282,16 @@ Contract: - The document is **always written to the file** (never stdout), so it never collides with the command's own `--json` output. JSON mode adds a top-level `vex` summary — `{ path, statements, format }` — to the envelope (`apply`) / result (`scan`). -- It's built from the manifest **as it stands after the run** (including any - `--mode agent` writes, with or without `--prune`) and verified against on-disk state - unless `--vex-no-verify` is set. Generated for real applies, `--dry-run`, and read-only - scans alike. +- 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, empty/missing manifest, nothing verified, unwritable path), the command exits + 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. diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 736e04ae..2bb4dc07 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -10,7 +10,7 @@ This document defines the **public surface** of the `socket-patch` binary. Anyth |---|---|---| | `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 | +| `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) | | `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) | @@ -57,6 +57,7 @@ In v3.0 every subcommand accepts the same set of "global" flags via a single sha | `--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 | 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. @@ -94,17 +95,19 @@ Each matching package instance is spliced, including scoped, quoted and nested-p 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. +**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. + `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. -**Takeover reconciliation (npm family, bun 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`. 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. +**Takeover reconciliation (npm family, bun 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`. 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. `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. -**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`. 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. +**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. **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 --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 (legacy manifest-mode entries written by standalone `vendor`) whose patch is gone from the manifest are reverted — entries carrying an embedded `record` (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 --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. `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. @@ -118,7 +121,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc `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), `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 embedded-record 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), `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. `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, or — for golang — the `patch.socket.dev/gopatch/` module path) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither recorded nor attested. 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. @@ -130,8 +133,8 @@ The rewriter reads a fixed set of candidate files from the project root: the npm **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: -* `.socket/vendor/state.json` — the **vendored**-mode ledger (see "Ownership, state, and reversal" below): wiring edits with verbatim pre-vendor originals, artifact fingerprints, and — for every entry written by `scan`/`get --mode vendored` — `detached: true` plus the embedded patch `record` (standalone `vendor` fed by an agent-mode manifest records neither). -* `.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` so a post-install `vex` can attest redirected patches with no manifest entry. The `mode` string is opaque to the loader (pre-rename ledgers carrying `"redirect"` still load; a hosted re-run normalizes them to `"hosted"`). Written identically by this CLI and by the depscan backend's hosted PR flow (`github-patch-pr-hosted.ts`). +* `.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. +* `.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`). **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: @@ -144,6 +147,8 @@ The rewriter reads a fixed set of candidate files from the project root: the npm 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. +`repair` keeps its `gc` visible alias. + **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`. ### Embedded VEX (`apply --vex` / `scan --vex` / `vendor --vex`) @@ -153,12 +158,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, empty/missing manifest, all patches unverified, 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 manifest**, verified against on-disk state (unless `--vex-no-verify`). Generated for real applies, `--dry-run`, and read-only `scan` alike. +* **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"}`). * **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) does **not** trigger VEX generation — there is nothing to attest. +* `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`. * **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 `vendor_state_unreadable` (a corrupt `.socket/vendor/state.json` degraded to "nothing vendored", including under `--vex-no-verify`), `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) and, standalone only, `org_looks_like_path` (`-o`/`--org` given a file-shaped value — `-O` is `--output`). The standalone error envelope carries `warnings[]` too. +* **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). ### VEX provenance markers (contract) @@ -168,11 +173,78 @@ 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-reads the ledger and hash-verifies against the installed tree | +| `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") | `vendored` and `redirected` are disjoint in practice (the modes conflict); if a PURL somehow appears in both sets, `vendored` wins. -`repair` keeps its `gc` visible alias. +**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. + +### 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/`. + +**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. + +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. + +| Ecosystem | Files read | Hosted reference | Vendored reference | Hosted pin (`integrity_required`) | +|---|---|---|---|---| +| npm | `package-lock.json` and `npm-shrinkwrap.json` (both when both exist) | `resolved` on the patch host (`packages` in v2/v3; `dependencies` only in v1; `link` / `inBundle` / `bundled` entries skipped) | `resolved: file:.socket/vendor/npm//-.tgz` | `integrity`, required | +| pnpm | `pnpm-lock.yaml` (every `lockfileVersion`); `shrinkwrap.yaml` only when there is no `pnpm-lock.yaml`; with `rush.json`, `common/config/rush/pnpm-lock.yaml` + `common/config/subspaces/*/pnpm-lock.yaml` | `packages:` `resolution.tarball` on the patch host | `file:.socket/vendor/npm/…` tarball + key | `integrity`, required | +| yarn | `yarn.lock` (classic and berry) | classic `resolved`; berry `resolution: …::__archiveUrl=` | classic `resolved "file:./.socket/vendor/npm/…#"`; berry `file:` entry **plus** a root `package.json` `resolutions` mapping onto the same artifact (without it the entry is orphaned: diagnosed, no ref) | classic `integrity` / `#sha1`, berry `checksum`, required | +| bun | `bun.lock`; `bun.lockb` only when there is no `bun.lock` (bun reads exactly one) | URL tuple / binary remote-tarball resolution; version from the URL leaf | `.socket/vendor/npm//-.tgz` tuple / local-tarball resolution | `sha512-…`, required. A 2-tuple that Bun < 1.3.10 re-saved without its digest is still a reference, but it attests only from an installed tree. | +| cargo | `Cargo.lock`, `Cargo.toml`, `.cargo/config` (else `.cargo/config.toml`) | `Cargo.lock` `source = "sparse+…//index/"`, confirmed by `Cargo.toml`: a crate the root manifest declares must pin `registry = "socket-patch-"`. A reverted pin is diagnosed, no ref. | `[patch.] = { path = ".socket/vendor/cargo//-" }` (config or `Cargo.toml`), live only while the lock holds a sourceless entry for it that is not in `[[patch.unused]]` | `checksum` (v1: `[metadata]`), required | +| golang | `go.mod`, `go.work`, `go.sum`, `go.work.sum` | `replace M v => patch.socket.dev/gopatch/ ` | `replace M v => ./.socket/vendor/golang//M@v` | both go.sum lines, required. A replace that `require` no longer selects (`require M v'`) is inert: diagnosed, no ref. | +| pypi | `uv.lock` (confirmed by `pyproject.toml` `[tool.uv.sources]` when present), PEP 723 `