diff --git a/.gitattributes b/.gitattributes index e7f9e267..5c7b89a0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -10,3 +10,9 @@ crates/socket-patch-core/tests/fixtures/pdm-native/*.lock -text # 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 + +# Captured vlt locks are byte-real; CRLF variants are derived in the tests. +crates/socket-patch-core/tests/fixtures/vlt-locks/** -text + +# The vendored vlt fixtures pin locks real vlt wrote, byte for byte. +crates/socket-patch-core/tests/fixtures/vendor/** -text diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fea73c77..2604a7aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -548,6 +548,7 @@ jobs: 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 llvm-cov \ --features docker-e2e \ --no-report \ @@ -890,6 +891,55 @@ jobs: - {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} + # Real-vlt capstones (DESIGN §8.4): wiremock patch service and a local + # npm registry fed from npmjs, driven by the pinned vlt release + # (`node vlt.js`, installed below from a sha512-checked `npm pack`). + # Every test is `#[ignore]`d and named `vlt_pinned_matrix_*`, so the + # filter must be `--include-ignored vlt_pinned_matrix` (the job + # default `--ignored` selects nothing). The run pipes through + # scripts/check-vlt-legs.py, which fails on `0 passed` or any leg + # line the manifest does not predict. The eras: A0 0.0.0-16, A + # 0.0.0-32, B rc.12/rc.14 (rc.14 legs reach public npm), C rc.32, + # D 1.0.4/1.0.7, E 1.1.1, F 1.2.0. + - {os: ubuntu-latest, suite: e2e_redirect_vlt_build, vlt: '1.2.0', test_filter: --include-ignored vlt_pinned_matrix} + - {os: macos-latest, suite: e2e_redirect_vlt_build, vlt: '1.2.0', test_filter: --include-ignored vlt_pinned_matrix} + - {os: windows-latest, suite: e2e_redirect_vlt_build, vlt: '1.2.0', test_filter: --include-ignored vlt_pinned_matrix} + - {os: ubuntu-latest, suite: e2e_redirect_vlt_build, vlt: '0.0.0-16', test_filter: --include-ignored vlt_pinned_matrix} + - {os: ubuntu-latest, suite: e2e_redirect_vlt_build, vlt: '0.0.0-32', test_filter: --include-ignored vlt_pinned_matrix} + - {os: ubuntu-latest, suite: e2e_redirect_vlt_build, vlt: '1.0.0-rc.14', test_filter: --include-ignored vlt_pinned_matrix} + - {os: ubuntu-latest, suite: e2e_redirect_vlt_build, vlt: '1.0.0-rc.32', test_filter: --include-ignored vlt_pinned_matrix} + - {os: ubuntu-latest, suite: e2e_redirect_vlt_build, vlt: '1.0.4', test_filter: --include-ignored vlt_pinned_matrix} + - {os: ubuntu-latest, suite: e2e_redirect_vlt_build, vlt: '1.1.1', test_filter: --include-ignored vlt_pinned_matrix} + - {os: ubuntu-latest, suite: e2e_vendor_vlt_build, vlt: '1.2.0', test_filter: --include-ignored vlt_pinned_matrix} + - {os: macos-latest, suite: e2e_vendor_vlt_build, vlt: '1.2.0', test_filter: --include-ignored vlt_pinned_matrix} + - {os: windows-latest, suite: e2e_vendor_vlt_build, vlt: '1.2.0', test_filter: --include-ignored vlt_pinned_matrix} + - {os: ubuntu-latest, suite: e2e_vendor_vlt_build, vlt: '0.0.0-32', test_filter: --include-ignored vlt_pinned_matrix} + - {os: ubuntu-latest, suite: e2e_vendor_vlt_build, vlt: '1.0.0-rc.14', test_filter: --include-ignored vlt_pinned_matrix} + - {os: ubuntu-latest, suite: e2e_vendor_vlt_build, vlt: '1.0.0-rc.32', test_filter: --include-ignored vlt_pinned_matrix} + - {os: ubuntu-latest, suite: e2e_vendor_vlt_build, vlt: '1.0.4', test_filter: --include-ignored vlt_pinned_matrix} + - {os: windows-latest, suite: e2e_vendor_vlt_build, vlt: '1.0.0-rc.14', test_filter: --include-ignored vlt_pinned_matrix} + - {os: ubuntu-latest, suite: mode_migration_vlt, vlt: '1.2.0', test_filter: --include-ignored vlt_pinned_matrix} + - {os: macos-latest, suite: mode_migration_vlt, vlt: '1.2.0', test_filter: --include-ignored vlt_pinned_matrix} + - {os: windows-latest, suite: mode_migration_vlt, vlt: '1.2.0', test_filter: --include-ignored vlt_pinned_matrix} + - {os: ubuntu-latest, suite: mode_migration_vlt, vlt: '0.0.0-32', test_filter: --include-ignored vlt_pinned_matrix} + - {os: ubuntu-latest, suite: mode_migration_vlt, vlt: '1.0.0-rc.14', test_filter: --include-ignored vlt_pinned_matrix, vlt_upgrade: '1.2.0'} + - {os: windows-latest, suite: mode_migration_vlt, vlt: '1.0.0-rc.14', test_filter: --include-ignored vlt_pinned_matrix} + # Linux `auto` hardlinks from the global store; every OS gets the + # explicit hardlink linker. + - {os: ubuntu-latest, suite: e2e_safety_vlt, vlt: '1.2.0', test_filter: --include-ignored vlt_pinned_matrix} + - {os: ubuntu-latest, suite: e2e_safety_vlt, vlt: '1.2.0', test_filter: --include-ignored vlt_pinned_matrix, vlt_store_linker: 'hardlink'} + - {os: macos-latest, suite: e2e_safety_vlt, vlt: '1.2.0', test_filter: --include-ignored vlt_pinned_matrix, vlt_store_linker: 'hardlink'} + - {os: windows-latest, suite: e2e_safety_vlt, vlt: '1.2.0', test_filter: --include-ignored vlt_pinned_matrix, vlt_store_linker: 'hardlink'} + # rc.12 gets the definite no-hook advisory; windows rc.14 runs the + # legacy DepIDs on NTFS with pre-junction symlinks. + - {os: ubuntu-latest, suite: e2e_vlt, vlt: '1.2.0', test_filter: --include-ignored vlt_pinned_matrix} + - {os: macos-latest, suite: e2e_vlt, vlt: '1.2.0', test_filter: --include-ignored vlt_pinned_matrix} + - {os: windows-latest, suite: e2e_vlt, vlt: '1.2.0', test_filter: --include-ignored vlt_pinned_matrix} + - {os: ubuntu-latest, suite: e2e_vlt, vlt: '0.0.0-32', test_filter: --include-ignored vlt_pinned_matrix} + - {os: ubuntu-latest, suite: e2e_vlt, vlt: '1.0.0-rc.12', test_filter: --include-ignored vlt_pinned_matrix} + - {os: ubuntu-latest, suite: e2e_vlt, vlt: '1.0.0-rc.32', test_filter: --include-ignored vlt_pinned_matrix} + - {os: ubuntu-latest, suite: e2e_vlt, vlt: '1.0.7', test_filter: --include-ignored vlt_pinned_matrix} + - {os: windows-latest, suite: e2e_vlt, vlt: '1.0.0-rc.14', test_filter: --include-ignored vlt_pinned_matrix} # The named corepack pnpm hosted legs (pnpm 7-11, get-uuid, # zero-touch, --trust-lockfile). `#[ignore]`d and previously run in # no job; the pinned matrix inside the same suite runs in @@ -1035,8 +1085,9 @@ jobs: # same-OS legs wins the cache reserve and the rest fail to save. # Several suites run one leg per pinned toolchain release (bun, uv, # poetry, pdm, hatch, pipenv, pip, bundler, composer, maven, dotnet, - # deno), 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' }} + # deno, vlt), so the release is part of the key too, plus the vlt + # store linker of the two ubuntu e2e_safety_vlt legs. + key: ${{ matrix.suite }}-${{ matrix.vlt || matrix.bun || matrix.uv || matrix.poetry || matrix.pdm || matrix.hatch || matrix.pipenv || matrix.pip || matrix.bundler || matrix.composer || matrix.maven || matrix.dotnet || matrix.deno || 'default' }}${{ matrix.vlt_store_linker && format('-{0}', matrix.vlt_store_linker) || '' }} save-if: ${{ github.ref == 'refs/heads/main' }} - name: Setup Node.js @@ -1210,7 +1261,45 @@ jobs: with: bun-version: ${{ matrix.bun }} + - name: Setup Node.js 24 (vlt legs) + if: matrix.vlt != '' + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '24.21.0' + + - name: Setup vlt + if: matrix.vlt != '' + # The pinned release (and, for the upgrade legs, the second vlt): + # `npm pack`, sha512 against the registry and the committed pin, then + # a prefix install run as `node vlt.js`. The store-linker knob is a + # harness variable because the harness scrubs ambient VLT_*. + shell: bash + env: + VLT_TEST_VERSION: ${{ matrix.vlt }} + VLT_TEST_UPGRADE: ${{ matrix.vlt_upgrade }} + VLT_TEST_STORE_LINKER: ${{ matrix.vlt_store_linker }} + run: | + set -euo pipefail + js=$(scripts/install-vlt.sh "$VLT_TEST_VERSION" "$RUNNER_TEMP/vlt-tool") + { + echo "SOCKET_PATCH_VLT_E2E_JS=$js" + echo "SOCKET_PATCH_VLT_E2E_VERSION=$VLT_TEST_VERSION" + echo "SOCKET_PATCH_VLT_E2E_REQUIRED=1" + echo "LANG=C" + echo "LC_ALL=C" + } >> "$GITHUB_ENV" + if [ -n "$VLT_TEST_STORE_LINKER" ]; then + echo "SOCKET_PATCH_VLT_E2E_STORE_LINKER=$VLT_TEST_STORE_LINKER" >> "$GITHUB_ENV" + fi + if [ -n "$VLT_TEST_UPGRADE" ]; then + up=$(scripts/install-vlt.sh "$VLT_TEST_UPGRADE" "$RUNNER_TEMP/vlt-upgrade") + echo "SOCKET_PATCH_VLT_E2E_UPGRADE_JS=$up" >> "$GITHUB_ENV" + echo "SOCKET_PATCH_VLT_E2E_UPGRADE_VERSION=$VLT_TEST_UPGRADE" >> "$GITHUB_ENV" + fi + node --version + - name: Run e2e tests + if: matrix.vlt == '' # Suites are `#[ignore]`-gated out of the unpinned `test` job by # default, hence `--ignored`; an entry that sets `test_filter` # overrides the selector for itself only. @@ -1257,6 +1346,25 @@ jobs: SOCKET_PATCH_DENO_E2E_VERSION: ${{ matrix.deno }} run: cargo test -p socket-patch-cli --all-features --test ${{ matrix.suite }} -- ${{ matrix.test_filter || '--ignored' }} + - name: Run vlt e2e tests + if: matrix.vlt != '' + # One capstone binary per row, through the leg checker: it fails on + # `0 passed`, a crashed binary, a missing `ran`, an unexpected skip or + # an unknown leg (crates/socket-patch-cli/tests/vlt-leg-manifest.json). + shell: bash + env: + SOCKET_PATCH_VLT_E2E_REQUIRED: ${{ matrix.vlt != '' && '1' || '' }} + VLT_SUITE: ${{ matrix.suite }} + VLT_TEST_FILTER: ${{ matrix.test_filter }} + run: | + set -uo pipefail + status=0 + # shellcheck disable=SC2086 # the filter is several libtest arguments + cargo test -p socket-patch-cli --all-features --test "$VLT_SUITE" -- $VLT_TEST_FILTER 2>&1 | tee vlt-leg.log || status=1 + py=$(command -v python3 || command -v python) + "$py" scripts/check-vlt-legs.py --manifest crates/socket-patch-cli/tests/vlt-leg-manifest.json vlt-leg.log || status=1 + exit "$status" + # ---------------------------------------------------------------------- # Docker-driven real-package e2e suite. # @@ -1694,10 +1802,19 @@ jobs: corepack prepare yarn@1.22.22 --activate corepack prepare yarn@4.6.0 --activate npm install -g bun@1 + # vlt: the same sha512-checked pack-and-install as the e2e rows. + js=$(scripts/install-vlt.sh 1.2.0 "$RUNNER_TEMP/vlt-tool") + { + echo "SOCKET_PATCH_VLT_E2E_JS=$js" + echo "SOCKET_PATCH_VLT_E2E_VERSION=1.2.0" + echo "SOCKET_PATCH_VLT_E2E_REQUIRED=1" + echo "SOCKET_PATCH_HOSTED_E2E_STRICT=1" + } >> "$GITHUB_ENV" node --version npm --version pnpm --version bun --version + node --no-warnings "$js" --version - name: Setup Python + uv if: steps.gate.outputs.run == 'true' @@ -1741,18 +1858,48 @@ jobs: # suites were pulled from the PR matrix (see the `e2e` job). Retry the # whole suite a couple of times before calling it a real failure, so a # transient 503 does not block merges through a required check. + set -o pipefail for attempt in 1 2 3; do echo "::group::hosted-e2e attempt $attempt" + # The step shell runs with -e: keep a failed attempt from ending it. + status=0 cargo test -p socket-patch-cli --test e2e_hosted_production -- \ - --ignored --nocapture --test-threads=4 - status=$? + --ignored --nocapture --test-threads=4 2>&1 | tee hosted-e2e.log || status=$? echo "::endgroup::" if [ "$status" -eq 0 ]; then - exit 0 + # The vlt leg (probe-driven: the clean refusal while the artifact + # is content-encoded, the full install proof once it is not). + python3 scripts/check-vlt-legs.py \ + --manifest crates/socket-patch-cli/tests/vlt-leg-manifest.json hosted-e2e.log + exit $? fi echo "::warning title=hosted-e2e attempt $attempt failed::retrying" sleep $((attempt * 20)) done echo "::error title=hosted-e2e::suite failed on all 3 attempts" exit 1 - if-no-files-found: warn + + - name: Run vendored-mode production e2e (vlt) + if: steps.gate.outputs.run == 'true' + # The vendored vlt install proof against production: the service's + # directory artifact in the D19 layout, then a fresh `vlt ci`. + env: + SOCKET_PATCH_VENDORED_E2E_STRICT: '1' + run: | + set -uo pipefail + for attempt in 1 2 3; do + echo "::group::vendored vlt production attempt $attempt" + status=0 + cargo test -p socket-patch-cli --test e2e_vendored_production -- \ + --include-ignored vlt_pinned_matrix --nocapture 2>&1 | tee vlt-vendored-production.log || status=$? + echo "::endgroup::" + if [ "$status" -eq 0 ]; then + python3 scripts/check-vlt-legs.py \ + --manifest crates/socket-patch-cli/tests/vlt-leg-manifest.json vlt-vendored-production.log + exit $? + fi + echo "::warning title=vendored vlt production attempt $attempt failed::retrying" + sleep $((attempt * 20)) + done + echo "::error title=hosted-e2e::the vendored vlt production proof failed on all 3 attempts" + exit 1 diff --git a/.github/workflows/vlt-compatibility.yml b/.github/workflows/vlt-compatibility.yml new file mode 100644 index 00000000..6fd588bd --- /dev/null +++ b/.github/workflows/vlt-compatibility.yml @@ -0,0 +1,590 @@ +name: vlt patch compatibility + +# Real vlt releases on Linux, macOS and Windows (DESIGN §8.4). ADVISORY: a +# path-filtered workflow cannot be a required check (a skipped run reads as +# pending), so the required gate is ci.yml's `e2e` vlt rows. This workflow +# widens them: +# build the five real-vlt capstones and the CLI, once per OS +# install-proof every capstone on every era of every OS, the Node engine +# floors (plus the collation golden) and the store linkers, +# each run through scripts/check-vlt-legs.py +# native scripts/backtest-vlt.py against the production service +# lock-diff the same cell's vlt-lock.json must be byte-identical on +# Linux, macOS and Windows (these rows also feed depscan) +# canary nightly: vlt@latest through every capstone, plus the +# release and lockfileVersion watchdogs +# downgrade nightly: the latest published socket-patch meets vlt +# ledgers written by this build +# See docs/testing/vlt-compatibility.md. + +on: + pull_request: + paths: + - '.github/actions/upload-artifact/**' + - '.github/workflows/vlt-compatibility.yml' + - 'Cargo.lock' + - 'rust-toolchain.toml' + - 'crates/socket-patch-core/src/vendor/**' + - 'crates/socket-patch-core/src/patch/**' + - 'crates/socket-patch-core/src/crawlers/**' + - 'crates/socket-patch-core/src/vex/**' + - 'crates/socket-patch-core/src/package_json/**' + - 'crates/socket-patch-core/src/utils/fs.rs' + - 'crates/socket-patch-core/src/constants.rs' + - 'crates/socket-patch-core/tests/**' + - 'crates/socket-patch-cli/src/commands/apply.rs' + - 'crates/socket-patch-cli/src/commands/rollback.rs' + - 'crates/socket-patch-cli/src/commands/remove.rs' + - 'crates/socket-patch-cli/src/commands/setup.rs' + - 'crates/socket-patch-cli/src/commands/vendor.rs' + - 'crates/socket-patch-cli/src/commands/repair_vendor.rs' + - 'crates/socket-patch-cli/src/commands/get.rs' + - 'crates/socket-patch-cli/src/commands/vlt_preflight.rs' + - 'crates/socket-patch-cli/src/commands/scan/**' + - 'crates/socket-patch-cli/src/commands/vex*' + - 'crates/socket-patch-cli/tests/common/**' + - 'crates/socket-patch-cli/tests/vlt_e2e_common/**' + - 'crates/socket-patch-cli/tests/vex_e2e_common/**' + - 'crates/socket-patch-cli/tests/*vlt*' + - 'crates/socket-patch-cli/tests/*vlt*/**' + - 'crates/socket-patch-cli/tests/vlt-leg-manifest.json' + - 'docs/testing/vlt-compatibility.md' + - 'scripts/backtest-vlt.py' + - 'scripts/check-vlt-legs.py' + - 'scripts/install-vlt.sh' + - 'scripts/vlt-historical-integrity.json' + - 'scripts/gen-vlt-collation-golden.mjs' + push: + branches: [main] + paths: + - '.github/actions/upload-artifact/**' + - '.github/workflows/vlt-compatibility.yml' + - 'Cargo.lock' + - 'rust-toolchain.toml' + - 'crates/socket-patch-core/src/vendor/**' + - 'crates/socket-patch-core/src/patch/**' + - 'crates/socket-patch-core/src/crawlers/**' + - 'crates/socket-patch-core/src/vex/**' + - 'crates/socket-patch-core/src/package_json/**' + - 'crates/socket-patch-core/src/utils/fs.rs' + - 'crates/socket-patch-core/src/constants.rs' + - 'crates/socket-patch-core/tests/**' + - 'crates/socket-patch-cli/src/commands/apply.rs' + - 'crates/socket-patch-cli/src/commands/rollback.rs' + - 'crates/socket-patch-cli/src/commands/remove.rs' + - 'crates/socket-patch-cli/src/commands/setup.rs' + - 'crates/socket-patch-cli/src/commands/vendor.rs' + - 'crates/socket-patch-cli/src/commands/repair_vendor.rs' + - 'crates/socket-patch-cli/src/commands/get.rs' + - 'crates/socket-patch-cli/src/commands/vlt_preflight.rs' + - 'crates/socket-patch-cli/src/commands/scan/**' + - 'crates/socket-patch-cli/src/commands/vex*' + - 'crates/socket-patch-cli/tests/common/**' + - 'crates/socket-patch-cli/tests/vlt_e2e_common/**' + - 'crates/socket-patch-cli/tests/vex_e2e_common/**' + - 'crates/socket-patch-cli/tests/*vlt*' + - 'crates/socket-patch-cli/tests/*vlt*/**' + - 'crates/socket-patch-cli/tests/vlt-leg-manifest.json' + - 'docs/testing/vlt-compatibility.md' + - 'scripts/backtest-vlt.py' + - 'scripts/check-vlt-legs.py' + - 'scripts/install-vlt.sh' + - 'scripts/vlt-historical-integrity.json' + - 'scripts/gen-vlt-collation-golden.mjs' + schedule: + # Nightly: vlt releases and the production service drift with no PR open. + - cron: '17 4 * * *' + workflow_dispatch: + inputs: + versions: + description: 'Space-separated vlt versions for the native backtest (empty = the per-OS defaults)' + required: false + default: '' + shapes: + description: 'Space-separated backtest shapes (empty = every shape)' + required: false + default: '' + modes: + description: 'Space-separated modes from hosted / vendored / agent (empty = all three)' + required: false + default: '' + nightly: + description: 'Also run the nightly canary and downgrade jobs' + type: boolean + required: false + default: false + +permissions: + contents: read + +# Supersede stale PR runs; main runs are the only rust-cache writers, so they +# are never cancelled mid-save. +concurrency: + group: vlt-compat-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +env: + CARGO_PROFILE_DEV_DEBUG: '0' + CARGO_INCREMENTAL: '0' + LANG: C + LC_ALL: C + +jobs: + matrix-coverage: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Every era, suite and OS is covered + run: python3 -B -m unittest scripts/tests/test_ci_vlt_rows.py -v + + build: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 40 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Cache cargo + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + key: vlt-e2e + save-if: ${{ github.ref == 'refs/heads/main' }} + - name: Compile the CLI and the vlt capstones once + shell: bash + run: | + set -euo pipefail + cargo build --locked -p socket-patch-cli + cargo test --locked -p socket-patch-cli --test e2e_redirect_vlt_build --test e2e_vendor_vlt_build \ + --test mode_migration_vlt --test e2e_safety_vlt --test e2e_vlt --no-run --message-format=json > target-build.json + python3 - <<'PY' + import json, pathlib, shutil, sys + exe = '.exe' if sys.platform == 'win32' else '' + dest = pathlib.Path('target/vlt-e2e') + dest.mkdir(parents=True, exist_ok=True) + shutil.copy2(f'target/debug/socket-patch{exe}', dest / f'socket-patch{exe}') + suites = {'e2e_redirect_vlt_build', 'e2e_vendor_vlt_build', 'mode_migration_vlt', 'e2e_safety_vlt', + 'e2e_vlt'} + for line in pathlib.Path('target-build.json').read_text().splitlines(): + item = json.loads(line) + name = item.get('target', {}).get('name') + if name in suites and item.get('executable') and item.get('profile', {}).get('test'): + shutil.copy2(item['executable'], dest / f'{name}{exe}') + missing = [s for s in suites if not (dest / f'{s}{exe}').is_file()] + assert not missing, missing + PY + - uses: ./.github/actions/upload-artifact + with: + name: vlt-e2e-${{ matrix.os }} + path: target/vlt-e2e/ + if-no-files-found: error + retention-days: 7 + + install-proof: + needs: build + strategy: + fail-fast: false + max-parallel: 8 + matrix: + include: + # Every supported era on every OS (macos adds 0.0.0-16 for A0). + - {os: ubuntu-latest, vlt: '0.0.0-1'} + - {os: ubuntu-latest, vlt: '0.0.0-11'} + - {os: ubuntu-latest, vlt: '0.0.0-16'} + - {os: ubuntu-latest, vlt: '0.0.0-18'} + - {os: ubuntu-latest, vlt: '0.0.0-19'} + - {os: ubuntu-latest, vlt: '0.0.0-24'} + - {os: ubuntu-latest, vlt: '0.0.0-25'} + - {os: ubuntu-latest, vlt: '0.0.0-30'} + - {os: ubuntu-latest, vlt: '0.0.0-32'} + - {os: ubuntu-latest, vlt: '1.0.0-rc.1'} + - {os: ubuntu-latest, vlt: '1.0.0-rc.5'} + - {os: ubuntu-latest, vlt: '1.0.0-rc.6'} + - {os: ubuntu-latest, vlt: '1.0.0-rc.8'} + - {os: ubuntu-latest, vlt: '1.0.0-rc.9'} + - {os: ubuntu-latest, vlt: '1.0.0-rc.12'} + - {os: ubuntu-latest, vlt: '1.0.0-rc.13'} + - {os: ubuntu-latest, vlt: '1.0.0-rc.14'} + - {os: ubuntu-latest, vlt: '1.0.0-rc.15'} + - {os: ubuntu-latest, vlt: '1.0.0-rc.18'} + - {os: ubuntu-latest, vlt: '1.0.0-rc.22'} + - {os: ubuntu-latest, vlt: '1.0.0-rc.29'} + - {os: ubuntu-latest, vlt: '1.0.0-rc.30'} + - {os: ubuntu-latest, vlt: '1.0.0-rc.32'} + - {os: ubuntu-latest, vlt: '1.0.0-rc.33'} + - {os: ubuntu-latest, vlt: '1.0.4'} + - {os: ubuntu-latest, vlt: '1.0.5'} + - {os: ubuntu-latest, vlt: '1.0.7'} + - {os: ubuntu-latest, vlt: '1.0.8'} + - {os: ubuntu-latest, vlt: '1.0.10'} + - {os: ubuntu-latest, vlt: '1.1.1'} + - {os: ubuntu-latest, vlt: '1.2.0'} + - {os: macos-latest, vlt: '0.0.0-16'} + - {os: macos-latest, vlt: '0.0.0-30'} + - {os: macos-latest, vlt: '1.0.0-rc.8'} + - {os: macos-latest, vlt: '1.0.0-rc.13'} + - {os: macos-latest, vlt: '1.0.0-rc.14'} + - {os: macos-latest, vlt: '1.0.0-rc.15'} + - {os: macos-latest, vlt: '1.0.0-rc.22'} + - {os: macos-latest, vlt: '1.0.0-rc.33'} + - {os: macos-latest, vlt: '1.0.8'} + - {os: macos-latest, vlt: '1.1.1'} + - {os: macos-latest, vlt: '1.2.0'} + - {os: windows-latest, vlt: '0.0.0-11'} + - {os: windows-latest, vlt: '0.0.0-19'} + - {os: windows-latest, vlt: '0.0.0-30'} + - {os: windows-latest, vlt: '1.0.0-rc.9'} + - {os: windows-latest, vlt: '1.0.0-rc.13'} + - {os: windows-latest, vlt: '1.0.0-rc.14'} + - {os: windows-latest, vlt: '1.0.0-rc.15'} + - {os: windows-latest, vlt: '1.0.0-rc.18'} + - {os: windows-latest, vlt: '1.0.0-rc.22'} + - {os: windows-latest, vlt: '1.0.0-rc.33'} + - {os: windows-latest, vlt: '1.0.5'} + - {os: windows-latest, vlt: '1.0.8'} + - {os: windows-latest, vlt: '1.0.10'} + - {os: windows-latest, vlt: '1.1.1'} + - {os: windows-latest, vlt: '1.2.0'} + # Node floors (DESIGN §1.1 as measured, scripts/install-vlt.sh): the + # newest release on each, with the collation golden (ICU drift). + - {os: ubuntu-latest, vlt: '1.2.0', node: '22.22.0'} + - {os: ubuntu-latest, vlt: '1.0.0-rc.18', node: '22.13.0'} + - {os: ubuntu-latest, vlt: '0.0.0-30', node: '22.7.0'} + - {os: ubuntu-latest, vlt: '0.0.0-1', node: '22.0.0'} + # Store linkers of the global store (1.2.0), safety suite only. + - {os: ubuntu-latest, vlt: '1.2.0', linker: auto, suites: e2e_safety_vlt} + - {os: ubuntu-latest, vlt: '1.2.0', linker: hardlink, suites: e2e_safety_vlt} + - {os: ubuntu-latest, vlt: '1.2.0', linker: copy, suites: e2e_safety_vlt} + - {os: ubuntu-latest, vlt: '1.2.0', linker: unpack, suites: e2e_safety_vlt} + - {os: macos-latest, vlt: '1.2.0', linker: auto, suites: e2e_safety_vlt} + - {os: macos-latest, vlt: '1.2.0', linker: hardlink, suites: e2e_safety_vlt} + - {os: windows-latest, vlt: '1.2.0', linker: auto, suites: e2e_safety_vlt} + - {os: windows-latest, vlt: '1.2.0', linker: hardlink, suites: e2e_safety_vlt} + - {os: ubuntu-latest, vlt: '1.2.0', linker: hardlink, cache_root: /dev/shm/vlt-e2e-cache, suites: e2e_safety_vlt} + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + steps: + # The capstones resolve fixtures through the build job's checkout path, + # which is the same on every runner of one OS. + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: vlt-e2e-${{ matrix.os }}* + merge-multiple: true + path: target/vlt-e2e + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: ${{ matrix.node || '24.21.0' }} + - name: Install vlt ${{ matrix.vlt }} + shell: bash + env: + VLT_TEST_VERSION: ${{ matrix.vlt }} + NODE_PIN: ${{ matrix.node }} + run: | + set -euo pipefail + js=$(scripts/install-vlt.sh "$VLT_TEST_VERSION" "$RUNNER_TEMP/vlt-tool") + echo "SOCKET_PATCH_VLT_E2E_JS=$js" >> "$GITHUB_ENV" + # A lockfileVersion-0 writer (0.0.0-19 … rc.14) also gets the + # upgrade legs, against 1.2.0 — except on a Node-floor row, whose + # Node is below 1.2.0's floor (the legs then skip no-upgrade-vlt). + if [ -z "$NODE_PIN" ] && VLT_TEST_VERSION=$VLT_TEST_VERSION node -e ' + const m = /^0\.0\.0-(\d+)$|^1\.0\.0-rc\.(\d+)$/.exec(process.env.VLT_TEST_VERSION); + process.exit(m && (m[1] ? Number(m[1]) >= 19 : Number(m[2]) <= 14) ? 0 : 1);'; then + up=$(scripts/install-vlt.sh 1.2.0 "$RUNNER_TEMP/vlt-upgrade") + echo "SOCKET_PATCH_VLT_E2E_UPGRADE_JS=$up" >> "$GITHUB_ENV" + echo "SOCKET_PATCH_VLT_E2E_UPGRADE_VERSION=1.2.0" >> "$GITHUB_ENV" + fi + - name: Collation golden under this Node + if: matrix.node != '' + run: node scripts/gen-vlt-collation-golden.mjs | diff - crates/socket-patch-core/tests/fixtures/vlt/collation-golden.json + - name: Every capstone, through the leg checker + shell: bash + env: + SOCKET_PATCH_VLT_E2E_VERSION: ${{ matrix.vlt }} + SOCKET_PATCH_VLT_E2E_REQUIRED: '1' + SOCKET_PATCH_VLT_E2E_STORE_LINKER: ${{ matrix.linker }} + SOCKET_PATCH_VLT_E2E_CACHE_ROOT: ${{ matrix.cache_root }} + VLT_SUITES: ${{ matrix.suites || 'e2e_redirect_vlt_build e2e_vendor_vlt_build mode_migration_vlt e2e_safety_vlt e2e_vlt' }} + run: | + set -uo pipefail + exe='' + if [ "$RUNNER_OS" = Windows ]; then exe=.exe; fi + chmod +x target/vlt-e2e/* || true + mkdir -p target/debug logs + cp "target/vlt-e2e/socket-patch$exe" "target/debug/socket-patch$exe" + export SOCKET_PATCH_VLT_E2E_SOCKET_BIN="$PWD/target/vlt-e2e/socket-patch$exe" + if [ -z "$SOCKET_PATCH_VLT_E2E_STORE_LINKER" ]; then unset SOCKET_PATCH_VLT_E2E_STORE_LINKER; fi + if [ -z "$SOCKET_PATCH_VLT_E2E_CACHE_ROOT" ]; then + unset SOCKET_PATCH_VLT_E2E_CACHE_ROOT + else + mkdir -p "$SOCKET_PATCH_VLT_E2E_CACHE_ROOT" + fi + py=$(command -v python3 || command -v python) + status=0 + for suite in $VLT_SUITES; do + echo "::group::$suite" + "target/vlt-e2e/$suite$exe" vlt_pinned_matrix --ignored 2>&1 | tee "logs/$suite.log" || status=1 + "$py" scripts/check-vlt-legs.py --binary "$suite" \ + --manifest crates/socket-patch-cli/tests/vlt-leg-manifest.json "logs/$suite.log" || status=1 + echo "::endgroup::" + done + exit "$status" + - uses: ./.github/actions/upload-artifact + if: always() + with: + name: vlt-proof-${{ matrix.os }}-${{ matrix.vlt }}-${{ matrix.node || 'node24' }}-${{ matrix.linker || 'default' }}${{ matrix.cache_root && '-cache-root' || '' }} + path: logs/ + retention-days: 14 + + plan: + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + native: ${{ steps.plan.outputs.native }} + steps: + - id: plan + env: + VERSIONS_OVERRIDE: ${{ github.event.inputs.versions }} + run: | + python3 - <<'PY' >> "$GITHUB_OUTPUT" + import json, os + defaults = { + 'ubuntu-latest': ['0.0.0-16', '0.0.0-32', '1.0.0-rc.14', '1.0.0-rc.32', '1.0.4', '1.0.10', '1.2.0'], + 'macos-latest': ['1.0.0-rc.14', '1.2.0'], + 'windows-latest': ['1.0.0-rc.14', '1.2.0'], + } + override = os.environ.get('VERSIONS_OVERRIDE', '').split() + rows = [{'os': o, 'vlt': v} for o, vs in defaults.items() for v in (override or vs)] + print('native=' + json.dumps({'include': rows})) + PY + + native: + needs: [build, plan] + strategy: + fail-fast: false + # Each job runs its cells against the public patch service. + max-parallel: 6 + matrix: ${{ fromJSON(needs.plan.outputs.native) }} + 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: vlt-e2e-${{ matrix.os }}* + merge-multiple: true + path: native-cli + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '24.21.0' + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.12' + - name: Backtest against production + # Every hosted cell probes the artifact first; it records + # blocked-by-server-encoding only when that probe saw a non-identity + # Content-Encoding and the CLI refused cleanly. Any other failure is an + # error. + shell: bash + env: + VLT_TEST_VERSION: ${{ matrix.vlt }} + CLI_REVISION: ${{ github.event.pull_request.head.sha || github.sha }} + CLI_BUILD_SHA: ${{ github.sha }} + SHAPES_OVERRIDE: ${{ github.event.inputs.shapes }} + MODES_OVERRIDE: ${{ github.event.inputs.modes }} + run: | + set -uo pipefail + chmod +x native-cli/* || true + cli=native-cli/socket-patch + if [ "$RUNNER_OS" = Windows ]; then cli=native-cli/socket-patch.exe; fi + modes=(hosted vendored agent) + if [ -n "$MODES_OVERRIDE" ]; then read -r -a modes <<<"$MODES_OVERRIDE"; fi + shapes=() + if [ -n "$SHAPES_OVERRIDE" ]; then read -r -a shapes <<<"$SHAPES_OVERRIDE"; shapes=(--shapes "${shapes[@]}"); fi + # `${a[@]+…}`: macOS bash 3.2 treats an empty array as unset under -u. + python3 scripts/backtest-vlt.py --cli "$cli" --versions "$VLT_TEST_VERSION" \ + --modes "${modes[@]}" ${shapes[@]+"${shapes[@]}"} --jobs 3 \ + --tools "$RUNNER_TEMP/vlt-tools" --out native-vlt + - uses: ./.github/actions/upload-artifact + if: always() + with: + name: vlt-results-${{ matrix.os }}-${{ matrix.vlt }} + include-hidden-files: true + path: | + native-vlt/summary.json + native-vlt/summary.md + native-vlt/captures/**/result.json + native-vlt/captures/**/cli-output.json + native-vlt/captures/**/before-cli-output.json + native-vlt/captures/**/tree/** + native-vlt/captures/**/logs/** + retention-days: 14 + + lock-diff: + needs: native + if: ${{ !cancelled() }} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: vlt-results-* + path: results + - name: Windows and macOS locks equal Linux + env: + VERSIONS_OVERRIDE: ${{ github.event.inputs.versions }} + SHAPES_OVERRIDE: ${{ github.event.inputs.shapes }} + MODES_OVERRIDE: ${{ github.event.inputs.modes }} + run: | + required=() + modes=(hosted vendored agent) + if [ -n "$MODES_OVERRIDE" ]; then read -r -a modes <<<"$MODES_OVERRIDE"; fi + # The required set only covers cells the native jobs were asked to run. + if [ -z "$VERSIONS_OVERRIDE" ] && { [ -z "$SHAPES_OVERRIDE" ] || [[ " $SHAPES_OVERRIDE " == *" direct "* ]]; }; then + for v in 1.2.0 1.0.0-rc.14; do + for m in "${modes[@]}"; do required+=("$v:$m:direct"); done + done + fi + python3 scripts/backtest-vlt.py --diff-locks results --out lock-diff \ + ${required[@]:+--require-cross-os "${required[@]}"} + - uses: ./.github/actions/upload-artifact + if: always() + with: + name: vlt-lock-diff + path: lock-diff/ + retention-days: 14 + + canary: + needs: build + if: github.event_name == 'schedule' || github.event.inputs.nightly == 'true' + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + 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: vlt-e2e-${{ matrix.os }}* + merge-multiple: true + path: target/vlt-e2e + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '24.21.0' + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.12' + - name: vlt@latest through every capstone + # Only the exact-version pin is relaxed: the capstones still assert + # `--version` against the release this step resolved. + shell: bash + run: | + set -uo pipefail + exe='' + if [ "$RUNNER_OS" = Windows ]; then exe=.exe; fi + chmod +x target/vlt-e2e/* || true + mkdir -p target/debug logs + cp "target/vlt-e2e/socket-patch$exe" "target/debug/socket-patch$exe" + js=$(scripts/install-vlt.sh latest "$RUNNER_TEMP/vlt-tool") || exit 1 + version=$(node --no-warnings "$js" --version) + echo "VLT_LATEST=$version" >> "$GITHUB_ENV" + export SOCKET_PATCH_VLT_E2E_JS="$js" SOCKET_PATCH_VLT_E2E_VERSION="$version" + export SOCKET_PATCH_VLT_E2E_REQUIRED=1 + export SOCKET_PATCH_VLT_E2E_SOCKET_BIN="$PWD/target/vlt-e2e/socket-patch$exe" + py=$(command -v python3 || command -v python) + status=0 + for suite in e2e_redirect_vlt_build e2e_vendor_vlt_build mode_migration_vlt e2e_safety_vlt e2e_vlt; do + echo "::group::$suite" + "target/vlt-e2e/$suite$exe" vlt_pinned_matrix --ignored 2>&1 | tee "logs/$suite.log" || status=1 + "$py" scripts/check-vlt-legs.py --binary "$suite" \ + --manifest crates/socket-patch-cli/tests/vlt-leg-manifest.json "logs/$suite.log" || status=1 + echo "::endgroup::" + done + exit "$status" + - name: Release and lockfileVersion watchdogs + # Fails when npm lists a vlt release that is neither supported nor + # excluded in docs/testing/vlt-compatibility.md, and, once a release + # writes a lockfileVersion other than 0 or 1, unless hosted mode + # refuses that lock (redirect_vlt_lock_unsupported). + if: always() && runner.os == 'Linux' + run: | + chmod +x target/vlt-e2e/* || true + python3 scripts/backtest-vlt.py --cli target/vlt-e2e/socket-patch --canary-checks --allow-unlisted \ + --versions "$VLT_LATEST" --tools "$RUNNER_TEMP/vlt-tools" --out canary + - uses: ./.github/actions/upload-artifact + if: always() + with: + name: vlt-canary-${{ matrix.os }} + path: | + logs/ + canary/canary/report.json + retention-days: 14 + + downgrade: + needs: build + if: github.event_name == 'schedule' || github.event.inputs.nightly == 'true' + # Advisory until the socket-patch release that adds vlt support (with the + # forward-compatible ledger handling) is the latest published one. + continue-on-error: true + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: vlt-e2e-ubuntu-latest* + merge-multiple: true + path: target/vlt-e2e + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '24.21.0' + - name: The published release meets vlt ledgers + run: | + set -uo pipefail + chmod +x target/vlt-e2e/* + npm install --prefix "$RUNNER_TEMP/published" --no-audit --no-fund @socketsecurity/socket-patch@latest + published="$RUNNER_TEMP/published/node_modules/.bin/socket-patch" + "$published" --version + status=0 + python3 scripts/backtest-vlt.py --cli target/vlt-e2e/socket-patch --downgrade-cli "$published" \ + --versions 1.2.0 --tools "$RUNNER_TEMP/vlt-tools" --out downgrade || status=$? + { + cat <<'MD' + ## vlt downgrade + + The latest published socket-patch ran `rollback` on a hosted vlt ledger and + `vendor --revert` on a `flavor: "vlt"` entry written by this build. Each must + leave the project untouched (fail closed) or fully reverted, never half-reverted. + This job is advisory until the release that adds vlt support is published. + + ```json + MD + cat downgrade/downgrade/summary.json 2>/dev/null || echo '[]' + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + exit "$status" + - uses: ./.github/actions/upload-artifact + if: always() + with: + name: vlt-downgrade + path: downgrade/downgrade/ + retention-days: 14 diff --git a/.github/workflows/vlt-serve-watchdog.yml b/.github/workflows/vlt-serve-watchdog.yml new file mode 100644 index 00000000..28f4af5c --- /dev/null +++ b/.github/workflows/vlt-serve-watchdog.yml @@ -0,0 +1,44 @@ +name: vlt serve watchdog + +# vlt hashes the wire body of a registry tarball and asks for +# `accept-encoding: gzip;q=1.0, identity;q=0.5`, so a patch artifact that +# patch.socket.dev (or its CDN) serves content-encoded fails `EINTEGRITY` on +# every vlt release, and hosted mode refuses to pin it +# (`redirect_vlt_artifact_unverifiable`). This job fetches the public free +# minimist artifact exactly that way and fails when the response is +# content-encoded or its sha512 differs from the API's integrity, which pages +# through the repository's workflow-failure notification. +# +# Like installer-drift.yml it checks a deployed service, not the diff. It is +# `continue-on-error` until the serve fix (`Cache-Control: no-transform`) is +# verified in production; removing that line arms it (DESIGN §8.4, the depscan +# rollout's last step). +on: + schedule: + - cron: '23 */6 * * *' + workflow_dispatch: + +permissions: + contents: read + +jobs: + probe: + runs-on: ubuntu-latest + timeout-minutes: 10 + continue-on-error: true + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Fetch the minimist artifact the way vlt does + run: python3 scripts/backtest-vlt.py --serve-probe --out serve-probe + + - name: Upload the probe + if: always() + uses: ./.github/actions/upload-artifact + with: + name: vlt-serve-probe + path: serve-probe/serve-probe.json + retention-days: 14 diff --git a/.gitignore b/.gitignore index f751c7dc..b3a6faf3 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,9 @@ build/Release # Dependency directories node_modules/ jspm_packages/ +# vlt golden inputs carry an install-state sentinel (commit with `git add -f`) +!crates/socket-patch-core/tests/fixtures/redirect/**/node_modules/ +!crates/socket-patch-core/tests/fixtures/redirect/**/node_modules/.vlt-lock.json # Snowpack dependency directory (https://snowpack.dev/) web_modules/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 21874c7e..125b0a0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -263,6 +263,174 @@ into the new version's section — see docs/releasing.md. ### Added +- **`apply` and `rollback` patch vlt installs in place.** A project + installed by vlt (`node_modules/.vlt/` or `node_modules/.vlt-lock.json`) + is detected as vlt ahead of any sibling bun, pnpm, yarn or npm marker, + and `apply` prints `Note: vlt layout detected…` in human mode. `scan`, + `get`, `apply`, `rollback` and `vex` find every package in vlt's store + (`node_modules/.vlt//node_modules/`) in every DepID era, + including transitive-only packages, aliases, git/remote/`file:` entries + and workspace members' link-only trees. `apply` and `rollback` reach + every store copy of a patched `name@version` (vlt's `~peer.`, + hashed-peer and modifier variants, and the legacy `··` / `·npm·` pair), + and every write replaces the file rather than writing through it, so + vlt 1.2's machine-wide store (hardlinked on Linux) stays untouched. The + store-copy failure note is now `store copy failed to patch` / + `failed to roll back` for pnpm and vlt alike. `--update` in a vlt + project suggests `vlt install @socketsecurity/socket-patch@latest`, and + in vlx's cache `vlx -y -- @socketsecurity/socket-patch@latest …`. +- **`rollback`, `remove` and the vendored takeover revert hosted vlt + redirects.** A `redirect_vlt_lock_node` ledger edit (written by the + depscan PR flow, or by `scan --mode hosted` once it rewrites + `vlt-lock.json`) puts the registry integrity and URL back on the node, + keeping whatever vlt re-laid since (a moved comma, a new flag or bins + slot, CRLF re-saved as LF). A node vlt has since re-locked away is + already reverted; any other change refuses with the `vlt-lock.json` + remedy. Peer and modifier variants are claimed per `name@version`. +- **`scan --mode hosted` and `get --mode hosted` redirect vlt projects.** + `vlt-lock.json` default-registry nodes of a patched `name@version` (every + peer and modifier variant, in every DepID era and CRLF lock) keep their + DepID and get the patched sha512 and hosted URL; `vlt.json` is read only. + vlt drives confirmation when its install state is present or no other + npm-family lock is; otherwise both locks are rewritten + (`redirect_vlt_sibling_lockfiles`). Before anything is written, each + artifact is fetched as vlt fetches it: a response vlt would reject + (re-gzipped, wrong sha512, HTTP error, unreachable) withholds the dep + (`redirect_vlt_artifact_unverifiable`) instead of pinning a lock `vlt ci` + cannot install. After the write, stale installed copies of the + Socket-owned nodes (`node_modules/.vlt-lock.json` and their + `node_modules/.vlt/` entries) are removed so the next `vlt install` + extracts the patched packages; `rollback` and `remove` do the same for + the registry bytes. New `--no-vlt-install-cleanup` / + `SOCKET_NO_VLT_INSTALL_CLEANUP` keeps them, and the + `redirect_vlt_reinstall_required` advisory says what to run. A stale + copy of an optional dependency is never removed, because `vlt install` + would not put it back; the advisory says to run `vlt ci` instead + (after upgrading to vlt 1.0.5 or later when every dependency is + optional, since 0.0.0-30 … 1.0.4 would drop the installed copy). A + same-run `--vex` does not attest a vlt package whose installed copy is + stale or unchecked, whose lock a vlt release may ignore, or which also + resolves from a non-default registry. vlt ledgers require the socket-patch + release that adds vlt support. +- **`vendor` wires vlt projects.** A `vlt-lock.json` (lockfileVersion 0 + or 1) routes npm vendoring to the new vlt backend ahead of every other + lockfile. A direct dependency of the root or of a workspace member is + vendored as a patched package directory, + `.socket/vendor/npm//-/node_modules//`, so a + package that `require()`s its own name still resolves; its + `devDependencies` are dropped from the vendored `package.json`, and the + uuid dir's `.gitignore` re-includes the payload against the project's + own ignores while `.gitattributes` keeps EOL conversion off it. The + lock's node becomes a `file` node, its importer edges and the importers' + `package.json` specs move to the `file:` path, and every moved entry is + placed where vlt's own serializer puts it, so `vlt ci`, warm and cold + `vlt install --frozen-lockfile` keep the lock byte-identical and `vlt + install ` keeps the wiring (checked against vlt 1.2.0, 1.0.10, + 1.0.4, 1.0.0-rc.32 and 1.0.0-rc.14). A node whose only extra is one + peer context (a root dependency with resolved peers from vlt 1.0.8, a + workspace member's from rc.15) is vendored without the extra, as vlt + writes `file:` dependencies. Transitive targets + (`vendor_vlt_transitive_unsupported`), several instances of one + `name@version`, modifier variants, foreign registries, peer edges and + dependencies declared in several fields refuse before any write, as do + locks vlt + cannot read and specs that no longer match the lock + (`vendor_vlt_lock_out_of_sync`); a payload git would ignore refuses with + `vendor_artifact_gitignored`, and a package already vendored through + another lockfile flavor with `vendor_flavor_changed`. `vendor --revert` + restores the registry node, edges and specs (keeping flags, trailing + slots and outgoing edge values vlt rewrote since) or keeps everything on + drift. Lock inventory reads `vlt-lock.json` too, Socket-hosted pins + included. Era-A locks (`··` ids, or URL-segment ids equal to a scalar + `registry`) warn `vendor_vlt_legacy_lockfile`. A vendored optional + dependency gets the new `vendor_vlt_reinstall_required` advisory: from + vlt 0.0.0-30 a plain `vlt install` keeps its installed upstream copy, + so it says to run `vlt ci` (or delete `node_modules` and run `vlt + install`); it also names any dependency whose `node_modules` link still + resolves to vlt's store. Reverting a vendored optional dependency (also + in a vendored-to-hosted takeover) gives the same advisory, since from + vlt 0.0.0-30 a plain `vlt install` then keeps the link to the removed + vendored directory. +- **Vendored vlt through every command.** `vendor`, `scan --mode vendored` + and `get --mode vendored` run the complete vlt vendored preflight (lock + version and layout, transitive, peer or foreign-registry targets, + dependencies declared in several fields, out-of-sync specs, a package + already vendored through another lockfile flavor, the installed copy's + `bundleDependencies` or duplicate `devDependencies`, a git rule that + ignores `.socket/`) before any patch is downloaded, anything is written, + or a hosted redirect is reverted for the takeover; the dry-run previews + report the same codes as `would_refuse`. A committed vlt directory + artifact is staged inventory-verified when nothing is installed (a fresh + clone), and vlt's own link to it is never taken as a pristine source. + After a hosted → vendored takeover the store copies vlt installed from + the hosted pin are removed (`redirect_vlt_reinstall_required`), except + optional ones, which the advisory reports as installed copies of the + vendored optional dependencies. `repair` + finds vlt references in `vlt-lock.json` and workspace `package.json` + files, rebuilds vlt directories against the inventory that leaves out + vlt's `node_modules/` links, restores a missing `/.gitignore` or + `.gitattributes`, and stamps reconstructed entries `flavor: "vlt"`. The + human output names the vlt committables and `vlt install`. A Bun lock + beside `vlt-lock.json` no longer triggers the Bun vendored preflight. + The git-ignore check refuses only a rule that ignores the vendored + uuid directory itself (such as `.socket/`), not one like `*.json` that + the directory's own `.gitignore` overrides. A takeover whose vendoring + then fails still removes the hosted store copies against the restored + registry pin. When vlt's link to the committed directory is the only + installed copy, `vendor` says so (`vendor_ledger_entry_missing`, run + `socket-patch repair`, when the vendor ledger lost the entry) instead of + reporting the package as not installed. +- **`vex` reads `vlt-lock.json`.** Manifest-less VEX (and the ledger + liveness gates behind `vex`, `scan`'s takeovers and the + `hosted_wiring_retained` advisory) discovers hosted vlt nodes (a Socket + URL and sha512 on a registry node, every DepID era) and vendored vlt + package directories, and verifies a vendored directory with the vlt + `package.json` exemption, including the out-of-sync check of the + installed link. A lock vlt cannot read (BOM, other `lockfileVersion`) + wires nothing. A hosted npm package is now judged by every store variant + of its installed copies (pnpm and vlt peer, modifier and registry-alias + instances), and a same-version instance on another registry (or a + Socket-shaped one that does not verify) keeps a vlt hosted pin from + attesting before install, as does a lock some vlt release discards (no + `lockfileVersion`, a pre-v1 legacy-id lock without vlt.json `modifiers`, + or a scalar `registry` outside a v1 lock with `registries.npm`), which + also warns `patched_ref_unattributable`. A vendored vlt directory + verified without its vendor ledger checks a devDependencies-stripped + `package.json` against the patched blob in `.socket/blobs`, and is + omitted as `vendor_manifest_unverifiable` when that blob is absent. + `setup.manual` accepts `vlt`. +- **`setup` wires vlt projects.** A `vlt-lock.json`, `vlt.json`, + `node_modules/.vlt-lock.json` or `node_modules/.vlt/` directory in the + project root makes `setup` treat it as vlt, ahead of any pnpm marker. The + hook is npm's `npx @socketsecurity/socket-patch apply --silent --ecosystems + npm`, and a vlt workspace (vlt.json `workspaces`, or vlt <= 0.0.0-12's + `vlt-workspaces.json`) is wired at the root only, because vlt runs the + root hook once per install. The `setup --json` `packageManager` and the + `patch_setup` telemetry `manager` report `vlt`. vlt before 1.0.0-rc.13 + never runs a root `postinstall`: `setup` still wires the project and + warns `vlt_root_scripts_not_run` — definitely when the `vlt` on `PATH` + reports such a version, and as a "may" when `vlt-lock.json` has + `lockfileVersion` 0 or none and no usable `vlt` is found, or the one + found would not write that lock (a v0 lock beside vlt 1.0.0-rc.15 or + later). `setup --remove` also clears the hooks earlier releases wrote + into vlt workspace members. +- **vlt support is proven against real vlt releases.** Every supported vlt + release (0.0.0-1 … 1.2.0, see `docs/testing/vlt-compatibility.md` for the + excluded ones) ran the five real-vlt capstones locally; CI now runs 35 of + those cells on every pull request (ci.yml's `e2e` vlt rows, each checked + by `scripts/check-vlt-legs.py` against the leg manifest), the vlt legs of + the required `hosted-e2e` production job (hosted and vendored), and the + advisory `vlt-compatibility.yml`: every capstone on every era of Linux, + macOS and Windows, the Node engine floors, the store linkers, + `scripts/backtest-vlt.py` against the production service, a cross-OS + `vlt-lock.json` comparison, and nightly `vlt@latest`, release-watchdog and + downgrade jobs. `vlt-serve-watchdog.yml` probes the public patch artifact + every 6 hours the way vlt fetches it. vlt releases are installed from a + sha512-checked `npm pack` (`scripts/install-vlt.sh`, pins in + `scripts/vlt-historical-integrity.json`). Hosted vlt projects stay + refused (`redirect_vlt_artifact_unverifiable`) until patch.socket.dev + stops re-encoding artifacts; vendored and agent mode work against + production today. - **`redirect_yarn_berry_mixed_line_endings` and `vendor_yarn_berry_mixed_line_endings`.** A `yarn.lock` (or, vendored, a root `package.json`) that mixes CRLF and LF line endings — or holds a bare @@ -627,6 +795,38 @@ into the new version's section — see docs/releasing.md. ### Fixed +- **`rollback` fetches a before-blob that only a store peer variant + needs.** The before-blob gate now probes every pnpm and vlt store variant + copy the rollback restores, so an online rollback no longer fails + `Before blob not found` for a still-patched variant beside an + already-original copy. + +- **Re-vendoring under a newer patch never builds from the old patch's + artifact.** With no installed copy, `vendor` staged the committed + artifact of the previous patch as the build source, so a file only the + old patch changed reached the new artifact unnoticed. The committed + artifact is now staged only for the patch that built it; a newer patch + fetches the pristine package per the lockfile (`--offline` skips it). + +- **Ledgers written by a newer socket-patch are never half-reverted.** + A hosted redirect edit kind this release does not understand used to + let `rollback` drop the npm record beside it, leaving that lockfile + redirected with nothing tracking it. Such an edit now holds every + record in the redirect ledger ("the redirect ledger holds a {kind} + edit this socket-patch release does not understand; upgrade + socket-patch"). When it names a purl, that purl's own revert in + `rollback `, `remove` and the hosted-to-vendored takeover + refuses with nothing written, and the takeover's ledger reconcile + leaves the purl for the manual cleanup. When the scope still covers + every hosted record, the whole-ledger replay goes on to unwind the + lockfiles this release understands, but keeps every record and the + unknown edit. + `repair` skips vendored npm entries whose `flavor` it does not know + (`vendor_wiring_unknown_revert_blocked`) instead of rebuilding them + with the wrong layout rules. vlt ledgers (`redirect_vlt_lock_node`, + `flavor: "vlt"`) require the socket-patch release that adds vlt + support. + - **Hosted Go redirects no longer claim patches that did not land.** `scan`/`get --mode hosted` counted a Go module as redirected (recorded it in the redirect ledger, so `vex` attested it) whenever any project diff --git a/Cargo.toml b/Cargo.toml index c1e95483..1a2b392f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ repository = "https://github.com/SocketDev/socket-patch" socket-patch-core = { path = "crates/socket-patch-core", version = "=4.0.0" } clap = { version = "=4.5.60", features = ["derive", "env"] } serde = { version = "=1.0.228", features = ["derive"] } -serde_json = { version = "=1.0.149", features = ["preserve_order"] } +serde_json = { version = "=1.0.149", features = ["preserve_order", "raw_value"] } sha2 = "=0.10.9" sha1 = "=0.10.6" hex = "=0.4.3" diff --git a/README.md b/README.md index 21e18aa8..fe8c26e7 100644 --- a/README.md +++ b/README.md @@ -288,6 +288,25 @@ rewrite them, and does not convert them to text. If both filenames exist, `bun.lock` takes precedence. See [Bun compatibility](docs/testing/bun-compatibility.md) for the tested versions, workspace behavior, and installer integrity limits. +### vlt compatibility + +[vlt](https://www.vlt.sh) projects (`vlt-lock.json`) work in agent and hosted mode on +every vlt release from 0.0.0-1 to 1.2.0 (both DepID grammars and every +`lockfileVersion`), and in vendored mode on locks with `lockfileVersion` 0 or 1 +(0.0.0-19 and later); older locks are refused with `vendor_lockfile_version_unsupported`. +Agent mode patches each copy in `node_modules/.vlt` without writing through vlt 1.2's +shared store. Hosted mode repoints the patched nodes' integrity and URL, first checks +that each artifact is served the way vlt can verify, and removes stale installed copies +so the next `vlt install` fetches the patched packages. Vendored mode commits a patched +package directory for each direct dependency of the root or a workspace member +(transitive dependencies need hosted mode), including one vlt gave a single peer +context; after vendoring an optional dependency run `vlt ci`, because a plain `vlt +install` keeps its installed upstream copy. vlt is detected ahead of every other +npm-family package manager. vlt ledgers require the socket-patch release that adds vlt support. +See [vlt notes](docs/ecosystems.md#npm-vlt-notes) for the caveats (`vlt update`, optional +dependencies, registry configuration) and [vlt compatibility](docs/testing/vlt-compatibility.md) +for the tested releases. + ### Pipenv compatibility Hosted mode rewrites every `Pipfile.lock` category that pins the patched @@ -786,9 +805,12 @@ result. What gets wired, per ecosystem: -- **npm / yarn / pnpm / bun** — writes `postinstall` and `dependencies` scripts into +- **npm / yarn / pnpm / bun / vlt** — writes `postinstall` and `dependencies` scripts into `package.json` so any install — including `npm install ` — re-applies patches - (pnpm: root package only). + (pnpm and vlt: root package only). vlt uses the same `npx` hook, runs it on every + install that changes the tree (never on a no-op install), and aborts the install when + it fails; vlt before 1.0.0-rc.13 never runs a root `postinstall`, which `setup` warns + about (`vlt_root_scripts_not_run`). - **Python (pip / uv / poetry / pdm / hatch)** — Python has no universal post-install hook, so `setup` instead adds a **`socket-patch[hook]`** dependency to your manifest (`pyproject.toml` / `requirements.txt`; for classic Poetry, the equivalent @@ -1246,6 +1268,7 @@ Behavior worth knowing: | pnpm | `pnpm-lock.yaml` (all generations), `shrinkwrap.yaml` (pnpm 1/2), Rush locks | Aliased / nested `resolution` shapes are diagnosed, not attested; `overrides` alone prove nothing | | yarn | `yarn.lock` (classic + berry) | Berry vendored entries also need the root `package.json` `resolutions` mapping; member locks are not read | | bun | `bun.lock`, else `bun.lockb` | A hosted entry that Bun < 1.3.10 re-saved without its sha512 attests only after install | +| vlt | `vlt-lock.json` (`lockfileVersion` absent, 0 or 1) | A BOM-prefixed or other-version lock wires nothing; a same-version instance on another registry keeps a hosted pin from attesting before install; a vendored directory whose `package.json` patch lost its devDependencies needs the patched blob in `.socket/blobs` without the vendor ledger | | cargo | `Cargo.lock`, `Cargo.toml`, `.cargo/config[.toml]` | Root manifest + project config only (no `$CARGO_HOME` / parent configs); vendored `[patch.crates-io]` entries are read from `Cargo.toml` first (v5), the project config for pre-v5 projects, and must agree with the detached lock entry's tagged version `+socket.` (a tag for another uuid — in the lock or in the copy's own `Cargo.toml` — is dead wiring; an untagged detached entry counts only beside an untagged, pre-tag copy); a manifest entry cargo ignores (a same-key project-config item, or a URL-spelled crates.io `[patch]` table) is not attested; a lockless hosted pin needs the redirect ledger's record | | golang | `go.mod`, `go.work`, `go.sum`, `go.work.sum` | A replace that `require` no longer selects is inert; `vendor/modules.txt` is not read | | pypi | `uv.lock`, `*.py.lock`, `pylock*.toml`, `poetry.lock`, `pdm.lock`, `Pipfile.lock`, `requirements.txt` (+ `-r` includes), `pyproject.toml` / `hatch.toml` | A `uv.lock` beside a `pyproject.toml` must agree with its `[tool.uv.sources]`; PDM 3.1 / 4.0–4.2 locks are refused; a Pipenv project needs `--product` (or a git remote) | diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index f6991b70..acc098aa 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -58,6 +58,7 @@ In v3.0 every subcommand accepts the same set of "global" flags via a single sha | `--no-telemetry` | — | `SOCKET_TELEMETRY_DISABLED` | `false` | bool | Disable anonymous usage telemetry | | `--no-trust-lockfile-config` | — | `SOCKET_NO_TRUST_LOCKFILE_CONFIG` | `false` | bool | Opt out of hosted mode's automatic `trustLockfile: true` write to `pnpm-workspace.yaml` (see the pnpm trust-config note under the scan arguments) | | `--no-npm-allow-remote-config` | — | `SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG` | `false` | bool | Opt out of hosted mode's automatic `allow-remote=all` write to the project `.npmrc` (see the npm allow-remote note under the scan arguments). Read by `scan --mode hosted` and `get --mode hosted`; other subcommands accept it silently | +| `--no-vlt-install-cleanup` | — | `SOCKET_NO_VLT_INSTALL_CLEANUP` | `false` | bool | Opt out of hosted mode's warm-tree heal for vlt: stale installed copies (`node_modules/.vlt-lock.json` and the stale `node_modules/.vlt/` entries) are left in place after `vlt-lock.json` is repointed (`scan`/`get --mode hosted`) or restored (`rollback`/`remove`), and the `redirect_vlt_reinstall_required` advisory tells you to run `vlt ci` instead. Stale copies of optional dependencies are always left in place (see `redirect_vlt_reinstall_required`). Other subcommands accept it silently | The `--offline` semantics unified in v3.0. Previously `apply` enforced strict airgap, `repair` skipped network ops, and `rollback` failed when blobs were missing. All three now mean the same thing: never contact the network, fail loudly when a required local source is missing. On `repair`, `--offline` and `--download-only` are mutually exclusive (exit 2). `scan` and `get` need remote data for their core function (patch discovery / patch fetch), so `--offline` refuses them up front — exit 1 with an error naming the offline gate (JSON: `status: "error"`), before any crawl, client build, or network contact. This covers `scan --vendor` too: offline vendored staging is `vendor --offline`'s job. @@ -99,7 +100,9 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc `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`. **golang** takes over the same way: the per-purl revert drops the module's hosted `replace`, removes the socket module's go.sum lines, puts the pruned upstream go.sum lines back in go's sort order, and drops the ledger record, so the vendored `replace` is recorded over pristine go.mod/go.sum (a go.mod whose replace for the module is no longer the recorded one refuses `redirect_revert_failed`). The separate run-level `vendor_supersedes_redirect` warning covers the reconcile-only case — a live lock that already proves vendored won over a stale hosted ledger record (the vendor wiring then holds the hosted-spliced fragment as `original`) — and fires exactly once, on the run that drops the stale records. Which way the live lock points is decided by the same lockfile discovery and ledger-liveness rules `vex` gates attestations on (see "Manifest-less VEX (lockfile discovery)"), for this warning, its `redirect_supersedes_vendored` twin and `hosted_wiring_retained` alike. +**vlt hosted-mode contract**: `scan` / `get --mode hosted` rewrite, in `vlt-lock.json`, every default-registry node of a granted `name@version` (the `''` / `npm` segment or a URL segment equal to the lock's scalar `registry`, both DepID grammars, every peer and modifier variant): slot [2] becomes the granted sha512 and slot [3] the hosted URL (appended to a 3-tuple); the DepID, flags and trailing slots, the line ending and every other byte stay. One `redirect_vlt_lock_node` ledger edit per changed node records the entry text (`"": `, no indent, comma or `\r`). `options` is never edited and `vlt.json` is only read. A lock with another `lockfileVersion` (decided on the raw JSON token), a BOM, a non-object body or a `nodes` section outside vlt's one-node-per-line layout refuses the whole lock (`redirect_vlt_lock_unsupported`). **Confirmation**: vlt drives when its install state (`node_modules/.vlt-lock.json` or `node_modules/.vlt/`) is present or no other npm-family lock is; then only `vlt-lock.json` confirms a uuid. Otherwise every lock is rewritten, `redirect_vlt_sibling_lockfiles` warns, and the other locks' rules confirm, including a dep `vlt-lock.json` merely does not wire (`redirect_vlt_entry_not_found`, `redirect_vlt_entry_vendored`). Whichever lock drives, a dep the vlt rewriter refuses (`redirect_vlt_missing_sha512`, `redirect_vlt_unsupported_lock_key`) is never confirmed by any lock, although a sibling lock may already carry its rewritten URL. **Artifact preflight**: before any takeover or write (dry runs included), each granted artifact with a default-registry instance is fetched once as vlt fetches it and must verify, else the dep is withheld (`redirect_vlt_artifact_unverifiable`, see the tag table). **Heal**: stale installed copies of Socket-owned nodes are removed so the next `vlt install` extracts the patched bytes, and `rollback` / `remove` do the same for the registry bytes (`--no-vlt-install-cleanup` keeps them; optional dependencies' copies are always kept); `redirect_vlt_reinstall_required` says what happened and what to run. The same-run `--vex` never attests a vlt package whose installed copy is stale or unchecked, whose lock a vlt release may ignore (`redirect_vlt_lockfile_version_missing`, `redirect_vlt_old_lockfile_ignored`, `redirect_vlt_scalar_registry_ignored`), or which also resolves from a non-default registry (`redirect_vlt_custom_registry_skipped`). `vlt.json` or vlt install state without `vlt-lock.json` warns `redirect_vlt_no_lockfile` instead of `redirect_npm_no_lockfile`. vlt ledgers require the socket-patch release that adds vlt support. Tested releases: `docs/testing/vlt-compatibility.md`. + +**Takeover reconciliation (npm family, bun and vlt included)**: vendoring over a hosted-redirected purl (`vendor`, `scan --mode vendored`, `get --mode vendored`) first REVERTS that purl's hosted lockfile edits to their pre-redirect registry values through the per-purl redirect revert, drops the purl's record + package edits from `redirect-state.json`, and then vendors — so the vendor ledger records the PRISTINE registry fragment as its wiring `original` and `vendor --revert` lands back on registry state, never on an expiring hosted URL. The run that takes over records a `vendor_takeover_reverted_redirect` advisory event (`skipped` action beside the purl's genuine outcome; the human path prints `Warning (vendor_takeover_reverted_redirect): …`). `--dry-run` PROBES the same revert against an in-memory ledger clone instead of promising it: a clean probe reports `vendor_would_revert_redirect`, and a drifted lock or an undecidable ledger edit surfaces in the preview with the wet run's `redirect_revert_failed` code and detail (for bun, whose hosted rewrite replaces the entry's `name@version` spec, the preview first runs the Bun vendored preflight described below and then stops at the advisory instead of reading the still-hosted lock — a lock the vendored backend would refuse is previewed as the wet run's `failed `, never as `vendor_would_revert_redirect`). A purl whose hosted edits cannot be cleanly reverted fails `redirect_revert_failed` (exit 1 / `partial_failure`, nothing vendored for it, the hosted wiring left in place, the remedy in the detail). **bun** participates like every other npm-family flavor: binary `redirect_bun_lockb_package` snapshots are claimed by their recorded package identity and restore individual binary resolutions; its text `redirect_bun_lock_package` edits are claimed by the recorded line's spec — the registry spec `@`, or a hosted URL whose tarball leaf is `-.tgz` — so a sibling version's or an aliased sibling's edit is neither claimed nor a refusal, and only an edit that mentions the package without being a bun packages-entry line refuses (remedy: an unscoped `socket-patch rollback`, whose whole-ledger replay unwinds bun.lock hosted edits; never hand-edit the ledger). The same claim rule serves scoped `rollback ` / `remove ` of one of several hosted bun records (see "Hosted unwind coverage"). Hosted → vendored and vendored → hosted (`redirect_takeover_reverted_vendored` in `redirect.warnings[]`) both work in place on bun locks the target mode accepts. **Bun vendored preflight before the takeover**: `vendor` — like `scan` / `get --mode vendored`, whose pre-download preflight runs earlier — checks `bun.lock` / `bun.lockb` with the shared Bun vendored preflight BEFORE the per-purl hosted revert, so a hosted-redirected purl on a lock the vendored backend refuses (a pre-version-2 `workspace:` lock → `vendor_bun_workspace_unsupported`; a malformed or unsupported binary lock → `vendor_bun_lockb_invalid`; an unsupported text-lock version → its code) is reported `failed ` with the hosted wiring, the redirect ledger and active Bun lock byte-untouched (exit 1 / `partial_failure`): the package stays hosted-patched instead of being un-hosted and then refused. `vendor --dry-run` previews that same `failed` code (exit-code parity with the wet run, nothing written) instead of promising `vendor_would_revert_redirect`. Pinned by `tests/in_process_vendor_bun_takeover.rs` and, against real Bun, `tests/mode_migration_bun.rs`. **golang** takes over the same way: the per-purl revert drops the module's hosted `replace`, removes the socket module's go.sum lines, puts the pruned upstream go.sum lines back in go's sort order, and drops the ledger record, so the vendored `replace` is recorded over pristine go.mod/go.sum (a go.mod whose replace for the module is no longer the recorded one refuses `redirect_revert_failed`). The separate run-level `vendor_supersedes_redirect` warning covers the reconcile-only case — a live lock that already proves vendored won over a stale hosted ledger record (the vendor wiring then holds the hosted-spliced fragment as `original`) — and fires exactly once, on the run that drops the stale records. Which way the live lock points is decided by the same lockfile discovery and ledger-liveness rules `vex` gates attestations on (see "Manifest-less VEX (lockfile discovery)"), for this warning, its `redirect_supersedes_vendored` twin and `hosted_wiring_retained` alike. `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. @@ -111,9 +114,9 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc `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. -**Lockfile supplement (v3.4)**: `scan` discovery is no longer limited to installed trees. The project's lockfiles (`package-lock.json`/`npm-shrinkwrap.json`, `pnpm-lock.yaml` v9, `yarn.lock` classic + berry, `bun.lock`, `Cargo.lock`, `go.sum`, `composer.lock`, `Gemfile.lock`, `uv.lock`/`poetry.lock`/pinned `requirements.txt`) are inventoried and dependencies with NO installed copy join discovery — counts, the API lookup, the table (flagged ` [NOT INSTALLED]`, plus a stderr note), and the prune "scanned" set (a wiped node_modules no longer prunes lockfile-listed entries). JSON gains a top-level `lockfileOnlyPackages` count and an additive `notInstalled: true` on matching `packages[]` entries. `--apply` partitions lockfile-only patches out BEFORE download (calm `skipped`/`package_not_installed` records — never an error exit, never a manifest write); `--vendor` passes them through to the vendor engine's auto-fetch. Vendored-ledger entries likewise stay discoverable on a fresh clone (the committed artifact is the dependency). Global scans (`--global`) get no supplement. **Rush monorepos** (no root lockfile, `rush.json` present): the npm-lock inventory falls back to the Rush source-of-truth locks — `common/config/rush/pnpm-lock.yaml` plus every `common/config/subspaces/*/pnpm-lock.yaml` (`read_dir`-sorted, repo-relative paths preserved) — so a Rush repo's dependencies still join discovery. **Plug'n'Play layouts are an explicit refusal, not an empty inventory**: a `.pnp.*` loader means the npm packages are structurally unreachable in EVERY mode (under yarn PnP the installed-tree crawl is empty too — no `node_modules/`), so `scan` surfaces an additive top-level `warnings[]` array (`{code, detail}` objects, omitted when empty) carrying `yarn_pnp_unsupported` (same code as apply's refusal; remedy `yarn patch `) or `pnpm_pnp_unsupported` (pnpm's `node-linker=pnp` twin; pnpm remedies), plus a stderr `Warning (): …` line on the human path. Exit code and `status` are deliberately unchanged (exit 0 / `success` — the same posture as hosted refusals, which exit 0 with `redirected: 0`); the warning is the machine-readable signal that nothing was checked. Pinned by `tests/e2e_safety_yarn_pnp.rs`. +**Lockfile supplement (v3.4)**: `scan` discovery is no longer limited to installed trees. The project's lockfiles (`package-lock.json`/`npm-shrinkwrap.json`, `pnpm-lock.yaml` v9, `yarn.lock` classic + berry, `bun.lock`, `vlt-lock.json` (registry nodes, Socket-hosted pins included; vendored `file` nodes are left to the vendor ledger), `Cargo.lock`, `go.sum`, `composer.lock`, `Gemfile.lock`, `uv.lock`/`poetry.lock`/pinned `requirements.txt`) are inventoried and dependencies with NO installed copy join discovery — counts, the API lookup, the table (flagged ` [NOT INSTALLED]`, plus a stderr note), and the prune "scanned" set (a wiped node_modules no longer prunes lockfile-listed entries). JSON gains a top-level `lockfileOnlyPackages` count and an additive `notInstalled: true` on matching `packages[]` entries. `--apply` partitions lockfile-only patches out BEFORE download (calm `skipped`/`package_not_installed` records — never an error exit, never a manifest write); `--vendor` passes them through to the vendor engine's auto-fetch. Vendored-ledger entries likewise stay discoverable on a fresh clone (the committed artifact is the dependency). Global scans (`--global`) get no supplement. **Rush monorepos** (no root lockfile, `rush.json` present): the npm-lock inventory falls back to the Rush source-of-truth locks — `common/config/rush/pnpm-lock.yaml` plus every `common/config/subspaces/*/pnpm-lock.yaml` (`read_dir`-sorted, repo-relative paths preserved) — so a Rush repo's dependencies still join discovery. **Plug'n'Play layouts are an explicit refusal, not an empty inventory**: a `.pnp.*` loader means the npm packages are structurally unreachable in EVERY mode (under yarn PnP the installed-tree crawl is empty too — no `node_modules/`), so `scan` surfaces an additive top-level `warnings[]` array (`{code, detail}` objects, omitted when empty) carrying `yarn_pnp_unsupported` (same code as apply's refusal; remedy `yarn patch `) or `pnpm_pnp_unsupported` (pnpm's `node-linker=pnp` twin; pnpm remedies), plus a stderr `Warning (): …` line on the human path. Exit code and `status` are deliberately unchanged (exit 0 / `success` — the same posture as hosted refusals, which exit 0 with `redirected: 0`); the warning is the machine-readable signal that nothing was checked. Pinned by `tests/e2e_safety_yarn_pnp.rs`. -**Vendor auto-fetch (v3.4)**: `vendor`/`scan --vendor` no longer fail on lockfile-resolved packages with no installed copy. Already-vendored purls stage from their committed artifact (sha256-verified against the vendor ledger; offline-safe). Otherwise the pristine artifact is fetched per the lockfile resolution and verified against the lock's recorded integrity FAIL-CLOSED before any write: npm SRI (or yarn classic's sha1 fragment), yarn berry's cache-zip checksum (rebuilt from the fetched tarball; cacheKey 10c0 only), Cargo.lock sha256 over the .crate, go.sum `h1:` dirhash over the module zip, composer `dist.shasum` (sha1), Gemfile.lock `CHECKSUMS` sha256, uv.lock wheel sha256 (pure `py3-none-any` wheels only). Entries the lock cannot verify are NEVER fetched (`vendor_fetch_unverifiable` warning + the calm `package_not_installed` skip). Registry bases honor `SOCKET_NPM_REGISTRY`, `SOCKET_CRATES_REGISTRY`, `SOCKET_GOPROXY` (else `GOPROXY`, `GONOPROXY` and `GOPRIVATE` the way go reads them — see the env table); npm/yarn/composer/gem/uv lock-recorded URLs are used verbatim. `--offline` refuses the fetch with the calm skip (the detail names the lockfile resolution). The fetch stages into a private tempdir — the project tree is never touched. +**Vendor auto-fetch (v3.4)**: `vendor`/`scan --vendor` no longer fail on lockfile-resolved packages with no installed copy. Already-vendored purls stage from their committed artifact (sha256-verified against the vendor ledger — a vlt directory artifact against its file inventory, which leaves out the links vlt creates inside it; offline-safe) when the ledger entry is at the manifest record's patch uuid; a superseding uuid fetches the pristine package instead, since the older artifact holds the older patch's bytes. Otherwise the pristine artifact is fetched per the lockfile resolution and verified against the lock's recorded integrity FAIL-CLOSED before any write: npm SRI (or yarn classic's sha1 fragment; for vlt the registry node's slot [2]), yarn berry's cache-zip checksum (rebuilt from the fetched tarball; cacheKey 10c0 only), Cargo.lock sha256 over the .crate, go.sum `h1:` dirhash over the module zip, composer `dist.shasum` (sha1), Gemfile.lock `CHECKSUMS` sha256, uv.lock wheel sha256 (pure `py3-none-any` wheels only). Entries the lock cannot verify are NEVER fetched (`vendor_fetch_unverifiable` warning + the calm `package_not_installed` skip). Registry bases honor `SOCKET_NPM_REGISTRY`, `SOCKET_CRATES_REGISTRY`, `SOCKET_GOPROXY` (else `GOPROXY`, `GONOPROXY` and `GOPRIVATE` the way go reads them — see the env table); npm/yarn/composer/gem/uv lock-recorded URLs are used verbatim. `--offline` refuses the fetch with the calm skip (the detail names the lockfile resolution). The fetch stages into a private tempdir — the project tree is never touched. `scan --sync` is sugar for `--apply --prune` — the canonical single-flag bot invocation. `scan --json --sync --yes` discovers, applies, and reconciles state in one pass. @@ -125,7 +128,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc `scan --mode hosted` (== `--redirect`) swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither recorded nor attested. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). Re-runs over already-rewritten output record zero new edits. **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` around its first wet write (the takeover pre-reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/` (and never quarantine: a `--dry-run` or a zero-grant wet run that finds a malformed `redirect-state.json` reports it as the hard error it is — exit 1, the repair-or-move-aside remedy — but moves nothing; only a run holding the lock moves it aside to `redirect-state.json.corrupt`); contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE the redirect ledger is read or written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON keeps the hosted shape — top-level `status: "error"`, `errorCode: "lock_held" | "lock_io"`, a string `error`, and `redirect: {mode: "hosted"}` retained (NOT the vendored `error: {code, message}` object). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: `scan --mode hosted` prints the results table and update detection like the other modes and confirms once — `Redirect N packages to the hosted patch server?` (singular for one), default yes, skipped by `--yes`/`--json`, on `--dry-run` (the engine honors the preview itself; nothing mutates), and when the detail fetch leaves nothing to redirect (that run enters the engine as a no-op — `Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/` — without prompting); without `--yes` on a non-TTY stdin the shared prompt prints `Non-interactive mode detected, proceeding automatically.` to stderr (unless `--silent`) and proceeds — before rewriting anything (parity with the agent/vendored arms and with `get --mode hosted`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine (previously `Redirected 0 packages; rewrote 0 files.`); a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). A malformed redirect ledger on a human hosted run that returns before the engine (empty discovery, nothing downloadable, a detail-fetch failure, a declined confirm) is surfaced there as the read-only `Warning: the redirect ledger … is malformed …` advisory (muted by `--silent`), never moved; the `--json` arm always enters the engine and hard-errors instead. JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang where it used to skip silently, and the ledger's `redirect_nuget_source` edit records `action: "added"` when `nuget.config` was authored from scratch (`rewritten` otherwise). Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips, and the `redirect_yarn_berry_entry` ledger edits record the lock's ON-DISK (CRLF) fragments, which the reverts match byte-exactly. A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover runs these berry gates (mixed line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) BEFORE reverting a vendored berry purl — wet and `--dry-run` alike — so a refused purl keeps its vendored wiring, ledger entry and artifact byte-identical and is skipped with the gate's code (never announced as `redirect_takeover_reverted_vendored` and then left unpatched in both modes). -The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate and `bun.lock` / `bun.lockb`), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and the Gradle build scripts read only to trigger the manual-snippet warning). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (`yarn.lock` entry only — `resolution: ::__archiveUrl=` + `yarnBerry10c0` checksum; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found`, a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). +The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and the Gradle build scripts read only to trigger the manual-snippet warning). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (`yarn.lock` entry only — `resolution: ::__archiveUrl=` + `yarnBerry10c0` checksum; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found`, a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). **Gem stale-install guard (additive warning — the canonical narrative; other mentions point here)**: the gem hosted rewrite is pure Gemfile/lock text, so a gem ALREADY materialized under the project's bundle paths keeps its upstream bytes — the next `bundle install` prints `Using ` and never refetches, on **every** bundler major (live-verified 2026-08-19 on 1.17.3 / 2.7.2 / 4.0.18: bundler 4's CHECKSUMS verify at download time only, and nothing is downloaded; `bundle install --force`/`--redownload` re-install from the stale cached `.gem` instead of re-fetching — bundler 1 silently, bundler 4 with an exit-37 checksum refusal that still leaves the upstream bytes installed; the **verified** remedy is removing the installed dir + cache `.gem` + `specifications` entry, then `bundle install`). After the rewrite, a hosted run therefore probes the installed-gem discovery paths (the same ruby-crawler discovery `apply` uses, honoring `--global`/`--global-prefix` like scan's own discovery) for each confirmed gem redirect and judges the materialization against the patch record's `afterHash` file map. Judgment rules: records are found **by uuid** — this run's fetched records first, then the redirect ledger's persisted ones, so a transiently failed `/patches/view` fetch cannot retire the warning (it re-fires on every re-scan until the stale materialization is gone); a materialization with every file at `afterHash` is already patched and never warns (an agent→hosted migration stays quiet by construction), and when several confirmed variant purls resolve to one installed dir, ANY of them judging it patched keeps it quiet; staleness needs **positive evidence** — at least one record file whose bytes were actually read and hash to neither state's expectation — so missing or unreadable files never produce a warning. Warnings emit `redirect_gem_stale_install` (JSON `redirect.warnings[]` + a code-tagged stderr line) in three flavors: a PROJECT-LOCAL dir gets the verified delete-list remedy (installed dir, cache `.gem`, `specifications` entry — plus the project's committed `vendor/cache/.gem` when present and not proven to be the patched artifact, since bundler installs from `vendor/cache` in preference to fetching); a SHARED gem-env home gets a caveat that the home is shared machine-wide and prefers migrating the project to a local bundle path over deleting shared files; and a committed `vendor/cache` archive whose sha256 differs from the patched artifact's warns standalone even with no installed dir at all (a fresh checkout with a committed stale cache re-materializes the upstream bytes forever). A stale-flagged purl is additionally **excluded from the same run's `--vex` `assume_applied` set** — the envelope must never attest a CVE its own warning says is live; the purl falls back to normal installed-tree verification (a patched install still attests, a stale one is omitted). The probe is read-only (nothing is deleted) and skipped on `--dry-run` — deliberately explicit, since nothing was rewritten but the ledger fallback could otherwise judge an already-redirected project. Exit code and `status` are unchanged (warning-only, the hosted-refusal posture); a same-run `--vex` may still fail on "nothing to attest" per the embedded-VEX contract. @@ -198,6 +201,7 @@ Discovery is read-only, never touches the network, and never fails the run: a ma | 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. | +| vlt | `vlt-lock.json` (lockfileVersion absent, `0` or `1`; a BOM-prefixed, unparseable, non-object or other-version lock is not read: diagnosed, no ref). `vlt.json` (read only for its `modifiers`) and `node_modules/.vlt-lock.json` are never wiring. | a registry node (any segment) whose slot [3] is a `/patch/npm/…` URL on the patch host with the leaf `-.tgz` of its DepID's `name@version`, whose embedded `/` path (when present) is that `name@version`, and slot [1] == name; version from the DepID | a `file` node `.socket/vendor/npm//-/node_modules/` (or a user-installed `-.tgz`) with slot [1] == name; version from the path. A same-`name@version` registry node, or a diagnosed Socket-shaped one, beside it is diagnosed, no ref. | slot [2] `sha512-…`, required (a hosted node without one is no reference). A same-`name@version` node on another registry, or a diagnosed Socket-shaped one, keeps the reference but withholds the lockfile basis: only an installed tree whose every store copy verifies attests. So does a lock some vlt release discards, the conditions of `redirect_vlt_lockfile_version_missing`, `redirect_vlt_old_lockfile_ignored` and `redirect_vlt_scalar_registry_ignored` (which in-run `--vex` withholds too): every hosted reference in it keeps no pin, and one `patched_ref_unattributable` names them. | | 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//-" }` — primarily the root `Cargo.toml` (v5 `vendor`; key-agnostic: `` is `package` when renamed, else the key, so `-socket-` keys count), also the project config (pre-v5 wiring), live only while the lock holds a sourceless entry for it that is not in `[[patch.unused]]`; a manifest entry cargo ignores — the project config redefines its key with another path, or a `[patch."https://github.com/rust-lang/crates.io-index"]` table replaces `[patch.crates-io]` — is diagnosed (`patched_ref_invalid`), no ref | `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 `