diff --git a/.github/actions/upload-artifact/action.yml b/.github/actions/upload-artifact/action.yml new file mode 100644 index 00000000..1a6b0db8 --- /dev/null +++ b/.github/actions/upload-artifact/action.yml @@ -0,0 +1,87 @@ +name: Upload artifact with retries +description: Retry artifact service failures without hiding an exhausted upload failure +inputs: + name: + description: Artifact name, unique to this job within the workflow run + required: true + path: + description: Files or globs to upload + required: true + if-no-files-found: + description: Behavior when no files match (warn, error, or ignore) + default: warn + retention-days: + description: Retention in days (0 uses the repository default) + default: '0' + include-hidden-files: + description: Include hidden files in the artifact + default: 'false' +outputs: + artifact-id: + description: ID of the uploaded artifact + value: ${{ steps.first.outputs.artifact-id || steps.second.outputs.artifact-id || steps.third.outputs.artifact-id }} + artifact-url: + description: URL of the uploaded artifact + value: ${{ steps.first.outputs.artifact-url || steps.second.outputs.artifact-url || steps.third.outputs.artifact-url }} + artifact-digest: + description: SHA-256 digest of the uploaded artifact + value: ${{ steps.first.outputs.artifact-digest || steps.second.outputs.artifact-digest || steps.third.outputs.artifact-digest }} +runs: + using: composite + steps: + # The artifact client does not retry the service's intermittent + # "FinalizeArtifact ... (403) Forbidden: Error from intermediary". + # An unfinished upload reserves its name but is invisible to the list / + # delete APIs, so overwrite cannot recover it. Retry with a fresh name, + # including run_attempt so rerunning a failed job cannot reuse a poisoned + # retry name either. Consumers use a name pattern and merge-multiple to + # accept any successful attempt without duplicating identical files. + - name: Upload (attempt 1) + id: first + if: ${{ !cancelled() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + continue-on-error: true + with: + name: ${{ inputs.name }} + path: ${{ inputs.path }} + if-no-files-found: ${{ inputs.if-no-files-found }} + retention-days: ${{ inputs.retention-days }} + include-hidden-files: ${{ inputs.include-hidden-files }} + overwrite: true + + - name: Wait before retrying + if: ${{ !cancelled() && steps.first.outcome == 'failure' }} + shell: bash + run: sleep 5 + + - name: Upload (attempt 2) + id: second + if: ${{ !cancelled() && steps.first.outcome == 'failure' }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + continue-on-error: true + with: + name: ${{ inputs.name }}-retry-${{ github.run_attempt }}-2 + path: ${{ inputs.path }} + if-no-files-found: ${{ inputs.if-no-files-found }} + retention-days: ${{ inputs.retention-days }} + include-hidden-files: ${{ inputs.include-hidden-files }} + overwrite: true + + - name: Wait before the final attempt + if: ${{ !cancelled() && steps.second.outcome == 'failure' }} + shell: bash + run: sleep 15 + + # Do not continue-on-error here: downstream consumers need the artifact, + # and missing files or persistent service/auth failures must fail the job. + - name: Upload (attempt 3) + id: third + if: ${{ !cancelled() && steps.second.outcome == 'failure' }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: ${{ inputs.name }}-retry-${{ github.run_attempt }}-3 + path: ${{ inputs.path }} + if-no-files-found: ${{ inputs.if-no-files-found }} + retention-days: ${{ inputs.retention-days }} + include-hidden-files: ${{ inputs.include-hidden-files }} + overwrite: true diff --git a/.github/workflows/bun-compatibility.yml b/.github/workflows/bun-compatibility.yml new file mode 100644 index 00000000..ee246458 --- /dev/null +++ b/.github/workflows/bun-compatibility.yml @@ -0,0 +1,329 @@ +name: Bun patch compatibility + +# Native Bun installer matrix: builds the CLI once per OS, downloads each +# pinned Bun release straight from its GitHub release (retried, SHA-256 +# verified against the release's SHASUMS256.txt) and runs +# `scripts/backtest-bun.py` — hosted, vendored and vendored-detached mode +# against the public minimist free patch, verifying the INSTALLED bytes, +# lock stability, digest rejection and rollback on Linux, macOS and Windows. +# No Socket API token is needed. See docs/testing/bun-compatibility.md. +# +# The hermetic (wiremock) real-bun suites run on every PR in ci.yml's `e2e` +# matrix; this workflow is the production-service, many-release twin. + +on: + pull_request: + paths: + - '.github/actions/upload-artifact/**' + - '.github/workflows/bun-compatibility.yml' + - 'scripts/backtest-bun.py' + - 'docs/testing/bun-compatibility.md' + - 'Cargo.lock' + - 'crates/socket-patch-core/src/vendor/**' + - 'crates/socket-patch-core/src/patch/redirect/**' + - 'crates/socket-patch-core/src/vendor/bun_lock_text.rs' + - 'crates/socket-patch-core/src/crawlers/npm_crawler.rs' + - 'crates/socket-patch-core/src/crawlers/pkg_managers.rs' + - 'crates/socket-patch-core/src/constants.rs' + - 'crates/socket-patch-core/src/utils/process.rs' + - 'crates/socket-patch-core/tests/fixtures/redirect/npm/bun/**' + - 'crates/socket-patch-cli/src/commands/get.rs' + - 'crates/socket-patch-cli/src/commands/scan/**' + - 'crates/socket-patch-cli/src/commands/rollback.rs' + - 'crates/socket-patch-cli/src/commands/vendor.rs' + - 'crates/socket-patch-cli/src/commands/repair_vendor.rs' + - 'crates/socket-patch-cli/src/commands/remove.rs' + # Main runs are the only rust-cache writers (save-if below), so a + # path-filtered push trigger is what seeds the cache the PR builds restore + # (rust-cache keys on Cargo.lock, so Cargo.lock belongs here) and re-runs + # the matrix post-merge on the code paths it exercises: the vendored engine + # (`vendor/**` — bun_lock.rs, bun_lock_text.rs's shared version gate, + # npm_flavor.rs, lock_inventory.rs), the hosted rewriter + unwinds, and the + # CLI drivers (`scan/**` — hosted.rs, vendor_flow.rs, mod.rs — plus the + # vendor / repair / remove commands the matrix runs). + push: + branches: [main] + paths: + - '.github/workflows/bun-compatibility.yml' + - 'scripts/backtest-bun.py' + - 'Cargo.lock' + - 'crates/socket-patch-core/src/vendor/**' + - 'crates/socket-patch-core/src/patch/redirect/mod.rs' + - 'crates/socket-patch-core/src/patch/redirect/replay.rs' + - 'crates/socket-patch-core/src/patch/redirect/takeover.rs' + - 'crates/socket-patch-cli/src/commands/get.rs' + - 'crates/socket-patch-cli/src/commands/scan/**' + - 'crates/socket-patch-cli/src/commands/rollback.rs' + - 'crates/socket-patch-cli/src/commands/vendor.rs' + - 'crates/socket-patch-cli/src/commands/repair_vendor.rs' + - 'crates/socket-patch-cli/src/commands/remove.rs' + workflow_dispatch: + inputs: + versions: + description: 'Space-separated Bun versions (empty = the pinned matrix below; every cell runs the override; releases before 1.1.0 are skipped on Windows, which has no build of them)' + required: false + default: '' + shapes: + description: 'Space-separated shapes (empty = every shape; a cell where no requested shape applies to its release reports noCells and passes)' + required: false + default: '' + modes: + description: 'Space-separated modes from hosted / vendored / vendored-detached (empty = all three)' + required: false + default: '' + +permissions: + contents: read + +# Supersede stale PR runs. The `main` guard is load-bearing: main runs are the +# ONLY rust-cache writers (save-if), so they must never be cancelled mid-save. +concurrency: + group: bun-patch-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +env: + CARGO_PROFILE_DEV_DEBUG: '0' + CARGO_INCREMENTAL: '0' + +jobs: + build: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Cache cargo + # save-if keeps writes on main so open PRs do not churn the repo's + # 10 GiB cache budget; rust-cache's automatic key already includes + # the runner OS, so one logical key serves all three builds. + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + key: bun-native + save-if: ${{ github.ref == 'refs/heads/main' }} + + - name: Build CLI + run: cargo build --locked -p socket-patch-cli + + - name: Upload CLI + uses: ./.github/actions/upload-artifact + with: + name: bun-cli-${{ matrix.os }} + path: | + target/debug/socket-patch + target/debug/socket-patch.exe + if-no-files-found: error + retention-days: 7 + + native: + needs: build + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + # Every lock-format and behaviour boundary the CLI has to survive: + # 0.8.1 / 1.0.0 / 1.0.36 / 1.1.0 / 1.1.38 binary bun.lockb only + # 1.1.39 first text lock (lockfileVersion 0, opt-in) + # 1.1.43 first `--lockfile-only` (the lockb migration recipe) + # 1.1.45 last v0 writer + # 1.2.0 / 1.2.23 / 1.3.0 text default, lockfileVersion 1 + # 1.3.9 / 1.3.10 URL/local tarball sha512 enforcement boundary + # (1.3.9 installs a tampered tarball, 1.3.10 refuses) + # 1.3.14 last pre-v2 default + # 1.4.0 / 1.4.2 lockfileVersion 2 + bun: ['0.8.1', '1.0.0', '1.0.36', '1.1.0', '1.1.38', '1.1.39', '1.1.43', '1.1.45', '1.2.0', '1.2.23', '1.3.0', '1.3.9', '1.3.10', '1.3.14', '1.4.0', '1.4.2'] + exclude: + # No Windows binary before Bun 1.1.0 (every later release above + # ships bun-windows-x64.zip). + - {os: windows-latest, bun: '0.8.1'} + - {os: windows-latest, bun: '1.0.0'} + - {os: windows-latest, bun: '1.0.36'} + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Download CLI + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: bun-cli-${{ matrix.os }}* + merge-multiple: true + path: native-cli + + - name: Setup Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.12' + + - name: Download Bun ${{ matrix.bun }} + id: bun + # Pre-populate the exact directory layout the script's install_tool() + # looks up (`tools///bun[.exe]`, SHASUMS256.txt beside + # it) for EVERY release the run needs: the matrix release (or the + # dispatch override) plus Bun 1.1.38, the baseline the `legacy-lockb` + # shape installs with on every other release (LEGACY_BUN in + # scripts/backtest-bun.py) — de-duplicated, and only when that shape + # is in play. install_tool() would fetch a missing release itself, + # retried and SHASUMS-verified (never fail-open), but a GitHub outage + # during that in-job fetch kills the whole cell before any case runs; + # here the backoff loop rides it out, the SHASUMS listing is checked + # BEFORE the zip is fetched (a release with no asset for this OS fails + # in one request instead of five 404 rounds — or, for the pre-1.1.0 + # releases that never shipped a Windows build, is skipped with a + # notice), and the archive is verified against the release's own + # SHASUMS256.txt before extraction (fail closed). The releases that + # were actually staged are exported for the run step. + shell: bash + env: + MATRIX_BUN: ${{ matrix.bun }} + VERSIONS_OVERRIDE: ${{ github.event.inputs.versions }} + SHAPES_OVERRIDE: ${{ github.event.inputs.shapes }} + run: | + set -euo pipefail + case "$RUNNER_OS" in + Linux) system=linux ;; + macOS) system=darwin ;; + Windows) system=windows ;; + *) echo "::error::unsupported runner OS: $RUNNER_OS"; exit 1 ;; + esac + # Same asset choice as install_tool(): aarch64 only for arm64 + # Linux/macOS; Windows is always x64. + arch=x64 + if [ "$RUNNER_ARCH" = "ARM64" ] && [ "$system" != windows ]; then arch=aarch64; fi + asset="bun-${system}-${arch}" + fetch() { + # curl --retry only covers timeouts and 408/429/5xx; the outer + # loop also rides out connection resets and truncated bodies. + for attempt in 1 2 3 4 5; do + if curl -fsSL --retry 5 --retry-delay 5 -o "$2" "$1"; then return 0; fi + rm -f "$2" + if [ "$attempt" = 5 ]; then + echo "::error::download of $1 failed on all 5 attempts" + return 1 + fi + echo "::warning::download of $1 failed (attempt $attempt); retrying" + sleep $((10 * attempt)) + done + } + before_windows_builds() { + # True when $1 predates Bun 1.1.0, the first release with a + # Windows binary (the matrix `exclude` list covers the pinned + # releases; a dispatch override can name any release). + IFS=. read -r major minor _ <<<"$1" + [ "${major:-0}" -lt 1 ] || { [ "${major:-0}" -eq 1 ] && [ "${minor:-0}" -lt 1 ]; } + } + requested="$MATRIX_BUN" + if [ -n "$VERSIONS_OVERRIDE" ]; then requested="$VERSIONS_OVERRIDE"; fi + run_versions="" + for version in $requested; do + if [ "$system" = windows ] && before_windows_builds "$version"; then + echo "::notice::skipping Bun ${version} on Windows: no bun-windows-x64.zip before 1.1.0" + else + run_versions="${run_versions} ${version}" + fi + done + run_versions="${run_versions# }" + # The run step's --versions; empty only when every requested + # release was skipped above (the run step then reports noCells). + echo "versions=${run_versions}" >> "$GITHUB_OUTPUT" + stage="$run_versions" + if [ -n "$run_versions" ] && { [ -z "$SHAPES_OVERRIDE" ] || [[ " $SHAPES_OVERRIDE " == *" legacy-lockb "* ]]; }; then + stage="${stage} 1.1.38" + fi + for version in $(printf '%s\n' $stage | awk 'NF && !seen[$0]++'); do + base="https://github.com/oven-sh/bun/releases/download/bun-v${version}" + dir="native-bun/tools/${version}" + mkdir -p "$dir" + fetch "${base}/SHASUMS256.txt" "${dir}/SHASUMS256.txt" + expected="$(awk -v name="${asset}.zip" '{ sub(/\r$/, "") } $2 == name { print $1 }' "${dir}/SHASUMS256.txt")" + if [ -z "$expected" ]; then + echo "::error::${asset}.zip is not listed in SHASUMS256.txt for bun-v${version}" + exit 1 + fi + fetch "${base}/${asset}.zip" "${dir}/${asset}.zip" + python3 - "${dir}/${asset}.zip" "$expected" "$dir" <<'PY' + import hashlib, pathlib, sys, zipfile + archive, expected, dest = pathlib.Path(sys.argv[1]), sys.argv[2], pathlib.Path(sys.argv[3]) + actual = hashlib.sha256(archive.read_bytes()).hexdigest() + if actual != expected: + sys.exit(f'::error::SHA-256 mismatch for {archive}: expected {expected}, got {actual}') + with zipfile.ZipFile(archive) as zipped: + zipped.extractall(dest) + archive.unlink() + print(f'{archive.name} sha256 {actual} verified') + PY + binary="${dir}/${asset}/bun" + if [ "$system" = windows ]; then binary="${binary}.exe"; fi + chmod +x "$binary" || true + actual="$("$binary" --version)" + if [ "$actual" != "$version" ]; then + echo "::error::expected bun ${version} at ${binary}, got ${actual}" + exit 1 + fi + done + + - name: Install, verify patched bytes, reject corruption, and roll back + shell: bash + env: + # The releases the download step staged: the matrix release or the + # dispatch override, minus any pre-1.1.0 release skipped on Windows. + BUN_VERSIONS: ${{ steps.bun.outputs.versions }} + # Provenance for the captures the depscan SBOM fixtures import: + # `cliRevision` is the branch-resolvable head commit (PR head, or the + # pushed commit on main); `cliBuildSha` is the commit actions/checkout + # actually built (refs/pull/N/merge on PRs) — they diverge once main + # advances past the PR's merge-base, so the script records both. + 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: | + if [ -z "$BUN_VERSIONS" ]; then + # Every requested release was skipped by the download step (a + # pre-1.1.0 override on Windows): nothing to run, not a failure — + # but the artifact must say so rather than go missing. + echo "::notice::no Bun release to run on ${RUNNER_OS}: every requested release predates the first Windows build (1.1.0)" + mkdir -p native-bun + printf '[{"noCells": true, "passed": false, "error": "every requested Bun release was skipped on %s: no Windows build before 1.1.0"}]\n' "$RUNNER_OS" > native-bun/summary.json + exit 0 + fi + chmod +x native-cli/socket-patch* || true + cli="native-cli/socket-patch" + if [ "$RUNNER_OS" = "Windows" ]; then cli="native-cli/socket-patch.exe"; fi + modes="hosted vendored vendored-detached" + if [ -n "$MODES_OVERRIDE" ]; then modes="$MODES_OVERRIDE"; fi + shapes_arg=() + if [ -n "$SHAPES_OVERRIDE" ]; then shapes_arg=(--shapes $SHAPES_OVERRIDE); fi + python3 scripts/backtest-bun.py \ + --cli "$cli" \ + --cli-revision "$CLI_REVISION" \ + --cli-build-sha "$CLI_BUILD_SHA" \ + --output native-bun \ + --tools native-bun/tools \ + --versions $BUN_VERSIONS \ + --modes $modes \ + "${shapes_arg[@]}" \ + --jobs 3 + + - name: Upload results + uses: ./.github/actions/upload-artifact + if: always() + with: + name: bun-results-${{ matrix.os }}-${{ matrix.bun }} + include-hidden-files: true + path: | + native-bun/summary.json + native-bun/captures/**/result.json + native-bun/captures/**/cli-output.json + native-bun/captures/**/tree/** + native-bun/captures/**/*.log + retention-days: 14 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc53ce89..c15c0c6a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,6 +94,9 @@ jobs: ruby -c crates/socket-patch-core/src/setup/gem/templates/plugins.rb.tmpl ruby -c crates/socket-patch-core/src/setup/gem/templates/gemspec.tmpl + - name: Python — test native installer harnesses + run: python3 -B -m unittest discover -s scripts/tests -v + - name: Shell — shellcheck the curl|sh installer # install.sh is the other distribution artifact this job lints; it # had no coverage anywhere before the self-update work touched the @@ -417,7 +420,7 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" - name: Upload host LCOV artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: ./.github/actions/upload-artifact with: name: coverage-host path: coverage-host.lcov @@ -544,7 +547,7 @@ jobs: --output-path coverage-docker-${{ matrix.ecosystem }}.lcov - name: Upload per-ecosystem LCOV artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: ./.github/actions/upload-artifact with: name: coverage-docker-${{ matrix.ecosystem }} path: coverage-docker-${{ matrix.ecosystem }}.lcov @@ -561,6 +564,11 @@ jobs: permissions: contents: read steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Install lcov run: sudo apt-get update && sudo apt-get install -y lcov @@ -568,7 +576,10 @@ jobs: uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: path: coverage-artifacts - pattern: coverage-* + pattern: coverage-{host*,docker-*} + # Retries have distinct artifact names. Their LCOV filenames stay + # stable, so a lost finalization response cannot double the counts. + merge-multiple: true - name: Merge LCOV files # `--add-tracefile` is repeated per input. lcov sums hit counts @@ -602,7 +613,7 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" - name: Upload merged LCOV artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: ./.github/actions/upload-artifact with: name: coverage-lcov path: coverage.lcov @@ -732,6 +743,86 @@ jobs: suite: e2e_redirect_rush_sim - os: windows-latest suite: e2e_redirect_rush_sim + # Hermetic real-bun capstones (wiremock patch service, real `bun + # install`): hosted (`e2e_redirect_bun_build`), vendored + # (`e2e_vendor_bun_build`) and the hosted⇄vendored takeover / + # scoped-rollback suite (`mode_migration_bun`). They are NOT + # `#[ignore]`-gated, so `test_filter: --include-ignored` is + # mandatory — the job default `-- --ignored` would select zero + # tests and pass vacuously (the e2e_composer trap above). The + # runner images ship no bun, so without the `bun:` key below the + # suites soft-skip; `bun:` installs that exact release via + # setup-bun and exports SOCKET_PATCH_BUN_E2E_REQUIRED=1 (+ the + # pinned version), under which the suites hard-fail instead of + # skipping when bun is missing, the wrong version, or the fixture + # install produces no text lock. + # + # Lock-era legs (ubuntu only): bun's text lock has three + # grammars — lockfileVersion 0 (opt-in `--save-text-lockfile`, + # 1.1.39–1.1.45; 2-tuple workspace entries), 1 (default from + # 1.2.0 through 1.3.x) and 2 (1.4.0+). Registry 4-tuples are + # identical across them but the workspace grammar, the lockb + # migration recipe and tarball digest enforcement (URL/local + # tarball sha512 checked only from 1.3.10) all differ, so the + # latest release alone cannot prove the rewrite + fresh install + # round-trip on the locks real projects commit. 1.1.45 = last v0 + # writer, 1.2.23 = v1, 1.3.14 = last pre-v2 default, 1.4.2 = v2. + - os: ubuntu-latest + suite: e2e_redirect_bun_build + bun: '1.4.2' + test_filter: --include-ignored + - os: macos-latest + suite: e2e_redirect_bun_build + bun: '1.4.2' + test_filter: --include-ignored + - os: windows-latest + suite: e2e_redirect_bun_build + bun: '1.4.2' + test_filter: --include-ignored + - os: ubuntu-latest + suite: e2e_redirect_bun_build + bun: '1.1.45' + test_filter: --include-ignored + - os: ubuntu-latest + suite: e2e_redirect_bun_build + bun: '1.2.23' + test_filter: --include-ignored + - os: ubuntu-latest + suite: e2e_vendor_bun_build + bun: '1.4.2' + test_filter: --include-ignored + - os: macos-latest + suite: e2e_vendor_bun_build + bun: '1.4.2' + test_filter: --include-ignored + - os: windows-latest + suite: e2e_vendor_bun_build + bun: '1.4.2' + test_filter: --include-ignored + - os: ubuntu-latest + suite: e2e_vendor_bun_build + bun: '1.1.45' + test_filter: --include-ignored + - os: ubuntu-latest + suite: e2e_vendor_bun_build + bun: '1.2.23' + test_filter: --include-ignored + - os: ubuntu-latest + suite: mode_migration_bun + bun: '1.4.2' + test_filter: --include-ignored + - os: macos-latest + suite: mode_migration_bun + bun: '1.4.2' + test_filter: --include-ignored + - os: windows-latest + suite: mode_migration_bun + bun: '1.4.2' + test_filter: --include-ignored + - os: ubuntu-latest + suite: mode_migration_bun + bun: '1.3.14' + test_filter: --include-ignored runs-on: ${{ matrix.os }} timeout-minutes: 25 steps: @@ -759,7 +850,9 @@ jobs: with: # Matrix suites otherwise collide on one key: only one of the ~9 # same-OS legs wins the cache reserve and the rest fail to save. - key: ${{ matrix.suite }} + # The bun suites run several legs of one suite per OS (one per + # pinned bun release), so the release is part of the key too. + key: ${{ matrix.suite }}-${{ matrix.bun || 'default' }} save-if: ${{ github.ref == 'refs/heads/main' }} - name: Setup Node.js @@ -810,10 +903,29 @@ jobs: php-version: '8.2' tools: composer:2 + - name: Setup Bun + if: matrix.bun != '' + # Installs the exact bun release the leg pins (setup-bun resolves a + # strict semver straight to the `bun-v` GitHub release, so the + # lock-era legs get the historical writer, not `latest`). Works on + # all three runner OSes (windows → bun-windows-x64.zip / bun.exe). + # SHA resolved from `gh api repos/oven-sh/setup-bun/git/ref/tags/v2.2.0`. + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: ${{ matrix.bun }} + - name: Run e2e tests # 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. + env: + # Bun legs only: turn the bun suites' "bun not installed / no text + # lock" soft-skips into hard failures and make them assert the + # pinned release, so a leg can never report green on an + # unexercised toolchain. Both are the EMPTY string on non-bun legs, + # which the suites treat as unset. + SOCKET_PATCH_BUN_E2E_REQUIRED: ${{ matrix.bun != '' && '1' || '' }} + SOCKET_PATCH_BUN_E2E_VERSION: ${{ matrix.bun }} run: cargo test -p socket-patch-cli --all-features --test ${{ matrix.suite }} -- ${{ matrix.test_filter || '--ignored' }} # ---------------------------------------------------------------------- @@ -948,7 +1060,7 @@ jobs: - name: Upload ${{ matrix.ecosystem }} setup-matrix report if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: ./.github/actions/upload-artifact with: name: setup-matrix-${{ matrix.ecosystem }} path: report-${{ matrix.ecosystem }}.json diff --git a/.github/workflows/pdm-compatibility.yml b/.github/workflows/pdm-compatibility.yml index 3732873e..ee46908e 100644 --- a/.github/workflows/pdm-compatibility.yml +++ b/.github/workflows/pdm-compatibility.yml @@ -9,6 +9,7 @@ name: PDM patch compatibility on: pull_request: paths: + - '.github/actions/upload-artifact/**' - '.github/workflows/pdm-compatibility.yml' - 'scripts/backtest-pdm.py' - 'crates/socket-patch-core/src/utils/pdm_lock.rs' @@ -65,7 +66,7 @@ jobs: with: key: pdm-compat - run: cargo build --locked -p socket-patch-cli - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + - uses: ./.github/actions/upload-artifact with: name: pdm-cli-${{ matrix.os }} path: | @@ -95,7 +96,8 @@ jobs: persist-credentials: false - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: - name: pdm-cli-${{ matrix.os }} + pattern: pdm-cli-${{ matrix.os }}* + merge-multiple: true path: native-cli - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: @@ -125,18 +127,19 @@ jobs: --modes hosted vendored agent \ "${shapes_arg[@]}" \ --jobs 3 - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + - uses: ./.github/actions/upload-artifact if: always() with: name: pdm-results-${{ matrix.os }}-${{ matrix.pdm }} path: | native-pdm/summary.json native-pdm/summary.md - native-pdm/*.log + native-pdm/tool-logs/*.log + native-pdm/original/**/*.log native-pdm/original/**/pdm.lock native-pdm/original/**/pyproject.toml - native-pdm/captures/**/result.json - native-pdm/captures/**/cli-output.json - native-pdm/captures/**/pdm.lock - native-pdm/captures/**/*.log + native-pdm/cases/**/result.json + native-pdm/cases/**/cli-output.json + native-pdm/cases/**/pdm.lock + native-pdm/cases/**/*.log retention-days: 14 diff --git a/.github/workflows/pipenv-compatibility.yml b/.github/workflows/pipenv-compatibility.yml index 7345146b..49ba1a20 100644 --- a/.github/workflows/pipenv-compatibility.yml +++ b/.github/workflows/pipenv-compatibility.yml @@ -10,6 +10,7 @@ name: Pipenv compatibility on: pull_request: paths: + - '.github/actions/upload-artifact/**' - 'crates/socket-patch-core/src/patch/redirect/pipenv.rs' - 'crates/socket-patch-core/src/vendor/pypi_pipenv.rs' - 'crates/socket-patch-core/src/vendor/pypi.rs' @@ -89,7 +90,7 @@ jobs: --shapes $PIPENV_SHAPES \ --modes hosted vendored agent agent-oot \ --jobs 4 - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + - uses: ./.github/actions/upload-artifact if: always() with: name: pipenv-compat-${{ matrix.os }}-${{ strategy.job-index }} diff --git a/.github/workflows/pnpm-compatibility.yml b/.github/workflows/pnpm-compatibility.yml index 8d14e489..b194dbd1 100644 --- a/.github/workflows/pnpm-compatibility.yml +++ b/.github/workflows/pnpm-compatibility.yml @@ -31,7 +31,7 @@ jobs: shutil.copy2(item['executable'], dest / 'pnpm-e2e') assert (dest / 'pnpm-e2e').is_file() PY - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + - uses: ./.github/actions/upload-artifact with: name: pnpm-e2e path: target/pnpm-e2e/ @@ -75,7 +75,8 @@ jobs: steps: - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: - name: pnpm-e2e + pattern: pnpm-e2e* + merge-multiple: true path: bin - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index ad24f5d4..60efa363 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -249,7 +249,11 @@ into the new version's section — see docs/releasing.md. pypi, composer, bun, and the non-package rideshare edits (pnpm `trustLockfile` auto-config — pristine scaffold deleted, modified scaffold keeps the file and loses only the owned line). The bun.lockb - migration is unrestorable by design (warning names git history); + migration marker restores the binary lock from the bytes the ledger + captured (`redirect_bun_lockb_restored`; the generated `bun.lock` is kept) + and warns `redirect_bun_lockb_unrestorable` naming git history only when + the ledger holds no bytes and the file is absent, or a different + `bun.lockb` has appeared since; maven and nuget fail closed with `hosted_revert_unsupported` guidance (their structured-metadata edits keep their ledger records; re-run `scan --mode hosted` or restore from VCS). Refused groups keep their @@ -264,6 +268,99 @@ into the new version's section — see docs/releasing.md. ### Fixed +- **Bun refusal safety:** hosted compatibility is checked before removing + an existing vendored patch, including during dry-run. Vendored preflight + exemptions require live local lock tuples; a ledger retained by + `rollback --preserve-state` cannot bypass a refusal or hide it in a preview. + Symlinked `bun.lockb` files are refused before migration so their links + survive, and `vendor --silent` keeps refusal diagnostics on stderr. + +- **Bun projects: every text-lock generation is accepted, vendored refusals + fire before any write, and every mode change unwinds.** `bun.lock` + `lockfileVersion` 0 — the opt-in text lock Bun 1.1.39–1.1.45 write with + `--save-text-lockfile` — joins 1 and 2 in the shared version gate, so hosted + mode redirects it (golden fixture `npm/bun/lock-v0`), vendored mode wires it + and the lockfile inventory discovers it; a newer version is now refused with + "update socket-patch" instead of a re-lock that would reproduce it. Workspace + locks are refused only where Bun cannot consume the rewrite: hosted mode + refuses a version-0 lock holding `workspace:` packages + (`redirect_bun_workspace_unsupported`; delete `bun.lock` and re-lock with + Bun ≥ 1.2, which writes version 1 — accepted; a plain in-place + `bun install` bumps the version only when a workspace depends on another + workspace, otherwise Bun 1.2.0 keeps version 0 and 1.2.23+ fail to + resolve) and vendored mode refuses any pre-version-2 workspace lock before + writing (`vendor_bun_workspace_unsupported` — Bun 1.2–1.3 resolve a + workspace member's local tarball path relative to the member; delete + `bun.lock` and re-lock with Bun ≥ 1.4, since an in-place `bun install` + keeps the existing version, or — for a version-1 lock — use hosted mode; a + version-0 lock is told to re-lock with Bun ≥ 1.2 first, since hosted + refuses it too), while purls already vendored (by the ledger at the + selected uuid, or with every matching lock tuple already pointing into + `.socket/vendor/`, so a superseding patch uuid re-pins in place), re-runs + and `repair` on such a lock keep working; a corrupt + `.socket/vendor/state.json` met by that preflight is reported as + `vendor_state_unreadable` rather than a Bun lock code. `scan --mode vendored`, + `get --mode vendored` (search and uuid paths) and `--detached` runs now + preflight the Bun lock BEFORE any download: a binary-only, unreadable, + unsupported-version or pre-version-2 workspace lock marks the npm patches + `failed` with the vendor refusal code and detail, fetches nothing and + records no patch — the `scan` / `get ` path still writes an unchanged + `.socket/manifest.json` and exits `partial_failure`, `get --mode + vendored` exits 1 with `status: "error"` and writes nothing — where + previously the record landed in the manifest and the vendor step failed + afterwards (and a detached run over an alias install misreported + `package_not_installed`). The refusals stay visible under `--silent` + (code-tagged stderr line), `--dry-run` previews them as the additive + `would_refuse` action (the human `scan` and `get` previews both print the + `[would-refuse]` lines), the `bun.lockb` refusal carries one remedy on every + path (`bun install --save-text-lockfile`, Bun ≥ 1.1.39), and a `scan` on a + `bun.lockb`-only project warns `bun_lockb_unsupported` instead of reporting + a clean empty inventory — the detail names a shadowed sibling + `package-lock.json` / `yarn.lock` / `pnpm-lock.yaml` and the + delete-the-stale-lockb remedy when one exists, and the warning is kept in + hosted mode too (beside the driver's own `redirect_bun_lockb_*` outcome on + the run that migrates) instead of being dropped on every non-empty hosted + run. Hosted → vendored + takeover now works for bun — + `scan`/`get --mode vendored` and `vendor` over a hosted-redirected `bun.lock` + claim and replay that purl's hosted edit instead of refusing + `redirect_revert_failed`, and `vendor --dry-run` probes the takeover instead + of promising it — as do `rollback ` / `remove ` of one of + several hosted bun records; on a lock the vendored backend refuses (a + pre-version-2 workspace lock) `vendor` and its dry run report the refusal + BEFORE the hosted revert, leaving the purl hosted-patched instead of + un-hosting it and then refusing. The hosted `bun.lockb` migration is truthful: + `bun` is resolved on absolute `PATH` entries (Windows `bun.cmd` shims + included, spawned directly — the standard library quotes batch-shim paths + with spaces and metacharacters correctly), a stale `bun.lockb` beside a + live npm / yarn / pnpm lock is left alone (`redirect_bun_lockb_sibling_lock`; + the redirect follows the sibling lock) instead of converting the project to + `bun.lock`, a `bun.lockb` that is not a regular file is refused before + `bun` is spawned, a `bun.lockb` that Bun 1.1.43–1.1.45 keep beside the new text + lock is removed by the CLI so the ledger's `removed` edit is true, the + pre-migration bytes ride the ledger and `rollback` restores `bun.lockb` + (`redirect_bun_lockb_restored`; the generated `bun.lock` is kept), Bun + 1.1.39–1.1.42 — which accept the flags but write nothing — get + `redirect_bun_lockb_manual_migration` instead of a false "unavailable", and + a failed spawn's `redirect_bun_lockb_unsupported` carries bun's output tail. + The hosted rewrite keeps CRLF on the rewritten `bun.lock` line. Real-Bun + coverage now runs in CI: the hermetic hosted and vendored suites on Linux, + macOS and Windows (Bun 1.4.2, plus 1.1.45 and 1.2.23 lock-era legs), and + the production native matrix — 16 releases from 0.8.1 to 1.4.2 in hosted, + vendored and detached-vendored mode — on pull requests and `main` (rows + carry `cliRevision` and `cliBuildSha` provenance), with + the corrected digest boundary (Bun verifies URL/local tarball sha512 from + 1.3.10, not 1.3.14). Bun 1.1.39–1.3.9 also re-save a hosted URL or + vendored local-tarball tuple WITHOUT its `sha512` on any later lock + re-save (`bun add`, `bun install` after a manifest change); that + digest-less 2-tuple is now recognised as the CLI's own wiring — repeat runs + heal the digest, `repair` rebuilds through it, and `rollback`, scoped + `rollback` / `remove`, `vendor --revert` and both mode takeovers unwind it + to the registry line — where previously every re-save on those releases + left `redirect_bun_entry_not_found` beside `redirected: 1`, a + `partial_failure` rollback and `vendor_lock_entry_not_found` / + `vendor_lock_entry_drifted` refusals. See + `docs/testing/bun-compatibility.md` and `scripts/backtest-bun.py`. (#245) - **Rollback after a Pipenv relock no longer refuses forever.** `pipenv lock` (and `update`, and `install ` before 2024) regenerates a redirected or vendored entry to registry shape on every Pipenv major; that is now the diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 6558d95a..7a088f0d 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -94,7 +94,7 @@ 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)**: vendoring over a hosted-redirected purl drops that purl's records + package edits from `redirect-state.json` (the vendor wiring embeds the hosted-spliced fragments as `original`, so `vendor --revert` byte-restores the hosted lock); the `vendor_supersedes_redirect` warning fires exactly once, on the run that reconciles. +**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: its `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 `bun.lockb`-only or unsupported-version lock → its code) is reported `failed ` with the hosted wiring, the redirect ledger and `bun.lock` byte-untouched (exit 1 / `partial_failure`): the package stays hosted-patched instead of being un-hosted and then refused. `vendor --dry-run` previews that same `failed` code (exit-code parity with the wet run, nothing written) instead of promising `vendor_would_revert_redirect`. Pinned by `tests/in_process_vendor_bun_takeover.rs` and, against real Bun, `tests/mode_migration_bun.rs`. The separate run-level `vendor_supersedes_redirect` warning covers the reconcile-only case — a live lock that already proves vendored won over a stale hosted ledger record (the vendor wiring then holds the hosted-spliced fragment as `original`) — and fires exactly once, on the run that drops the stale records. `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 always prompts the user interactively. @@ -114,13 +114,13 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Path-scoped scans (`scan [PATHS]...`, v5.0)**: optional variadic positional path globs scope DISCOVERY at the **purl level** — a package is in scope iff ANY of its crawled installed copies sits under a matching path, and a selected package is then handled with ALL its copies (scoping selects which packages are considered, never which copies). Glob semantics (shared with `rollback`'s path targets, `src/path_scope.rs`): Unix-shell globs with `require_literal_separator` — `*`/`?` never cross a `/`, `**` spans directories; a pattern matching any **ancestor** directory of the copy path also matches, so a bare `scan packages/foo` scopes the whole subtree without `/**`; relative patterns match against the copy path relativized to `--cwd`, absolute patterns against the absolute path (the ONLY way to reach paths outside the project tree, e.g. `--global` stores — a relative pattern never matches outside `--cwd`); leading `./` and trailing `/` are normalized away, matching is purely textual (no filesystem access or symlink resolution), case-sensitive except on Windows (whose filesystems are not); an unparseable or empty pattern is a usage error (exit 2). **The prune universe is never narrowed**: the path filter is applied strictly AFTER the `scanned_purls` capture (and after `--ecosystems`), so `scan PATHS --prune` prunes exactly what an unscoped `scan --prune` would — a scoped scan can never treat an out-of-scope package as uninstalled (the same fail-safe as the `--ecosystems` filter). Lockfile-only and vendor-ledger supplement records have no installed path and are EXCLUDED from a path-scoped scan, surfaced as one run-level `path_scope_excluded_supplements` warning carrying the count. A scope matching nothing is a normal empty scan — exit 0, zero packages, **no GC** (the zero-package early return fires before any GC). `PATHS` with `--mode hosted` or `--mode vendored` is a usage error (exit 2, `resolve_mode_flags`: "path targeting … applies to agent-mode and read-only scans" — their lockfile rewiring is whole-project by construction); `PATHS` with `--apply`/`--sync`/`--prune`/`--global` is fine. Every scan JSON shape (success, zero-package, and error alike) gains an additive always-present `paths` key echoing the patterns verbatim (empty array when unscoped). One-sentence duality rule: **a target that selects nothing is an error on `rollback` (exit 1) and an empty scan on `scan` (exit 0)**. -`scan --vendor` swaps the in-place apply for the vendor pipeline: discover → download (manifest written, as `--apply`) → vendor every patched dependency via the same engine as the `vendor` command (under the same lock). The whole manifest is vendored, so a package vendored at an older patch uuid is **re-vendored automatically** (its old uuid dir is removed — `vendor_stale_artifact_removed`); same-uuid re-runs are `already_vendored` skips. With `--prune`, GC runs **before** the vendor step so stale manifest entries don't fail vendoring with `package_not_installed`. JSON output gains a `download` sub-object (the download phase; no `applied` field — nothing is applied in place) and a `vendor` sub-object (a full vendor Envelope). The download phase writes only `.socket/manifest.json`; patch blobs are held in memory (see "Patch sources stay in memory" under the vendor contract). `--dry-run` previews per-patch `would_vendor` | `would_revendor` (+`oldUuid`) | `already_vendored` without network downloads or disk writes. Interactive mode prompts "Download and vendor N patch(es)?". +`scan --vendor` swaps the in-place apply for the vendor pipeline: discover → download (manifest written, as `--apply`) → vendor every patched dependency via the same engine as the `vendor` command (under the same lock). The whole manifest is vendored, so a package vendored at an older patch uuid is **re-vendored automatically** (its old uuid dir is removed — `vendor_stale_artifact_removed`); same-uuid re-runs are `already_vendored` skips. With `--prune`, GC runs **before** the vendor step so stale manifest entries don't fail vendoring with `package_not_installed`. JSON output gains a `download` sub-object (the download phase; no `applied` field — nothing is applied in place) and a `vendor` sub-object (a full vendor Envelope). The download phase writes only `.socket/manifest.json`; patch blobs are held in memory (see "Patch sources stay in memory" under the vendor contract). `--dry-run` previews per-patch `would_vendor` | `would_revendor` (+`oldUuid`) | `already_vendored` — plus, additive, `would_refuse` (+`errorCode`, `error`) for npm purls the wet run's Bun preflight (see the `get --mode vendored` bullet below) would refuse — without network downloads or disk writes; the preview never flips status or exit (the human path — `scan` and `get` alike, through one shared printer — prints `[would-refuse] (): ` lines behind the `--silent` gate). Interactive mode prompts "Download and vendor N patch(es)?". `scan --vendor --detached` performs the same vendoring **without ever writing `.socket/manifest.json`**: records are fetched into memory (`download.detached: true`), the artifacts are built + wired, and the ledger entry carries `detached: true` plus an embedded copy of the patch record (`record`) as the verification source. Detached patches are invisible to apply and repair (nothing is in the manifest), exempt from `vendor`'s manifest reconcile, and exit via `remove ` (which reverts them), `vendor --revert`, or — as of v5.0 — `rollback`, whose vendored leg reverts detached ledger entries alongside manifest-tracked ones (unscoped and identifier-scoped runs; path-scoped runs reach them only when an installed copy matches). Idempotent re-runs reuse the embedded record and skip the patch-view fetch entirely. `scan --mode hosted` (== `--redirect`) swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL, or — for golang — the `patch.socket.dev/gopatch/` module path) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither recorded nor attested. Re-runs over already-rewritten output record zero new edits. 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). 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. -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`), `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), `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 1 or 2 — bun 1.3/1.4 share one emitted grammar; a binary `bun.lockb` with no text lock is auto-migrated to text via `bun install --save-text-lockfile --frozen-lockfile --lockfile-only` before the read, recorded as a `removed` FileEdit; `redirect_bun_lockb_would_migrate` on `--dry-run`, `redirect_bun_lockb_unsupported` when the migration is unavailable). **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 and `bun.lock`), `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), `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. **`bun.lockb` auto-migration**: a binary `bun.lockb` with no text lock beside it is migrated to text before the read — only when an npm override was granted (with nothing to redirect the user's lockfile format is never touched) and never on `--dry-run` (`redirect_bun_lockb_would_migrate`, nothing spawned). Pre-spawn refusals leave the file untouched: a symlinked `bun.lockb` is rejected with `redirect_symlinked_file_unsupported` (exit 1, including dry-run), since byte restoration cannot recreate the link; a `bun.lockb` that is not a regular file (a FIFO, socket or directory squatting the name) is `redirect_bun_lockb_unsupported` with detail "bun.lockb is not a regular file; refusing to migrate it" and `bun` is never spawned (it would block on the same FIFO); and a `bun.lockb` beside a live sibling lock (`package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock` or `pnpm-lock.yaml`) is left alone with the warning `redirect_bun_lockb_sibling_lock` (also on `--dry-run`, in place of `redirect_bun_lockb_would_migrate`) — the recipe would have converted an npm / yarn / pnpm project into a `bun.lock` project; the redirect follows the sibling lock as before. `scan`'s run-level `bun_lockb_unsupported` layout warning is kept in hosted mode as well (see that code's row): on the run that migrates it rides beside the driver's `redirect_bun_lockb_*` outcome, and when no npm override is granted — the driver never touches the file then — it is the only mention that the binary lock was skipped. The CLI spawns the `bun` resolved on ABSOLUTE `PATH` entries only (a relative entry would run a `bun` planted in the scanned repository; on Windows `PATHEXT` finds the npm-global `bun.cmd` / `.bat` shim, which is spawned directly — the Rust standard library launches batch shims through `cmd.exe` with correct quoting, so a shim under a path with spaces and `(x86)`-style metacharacters works) as `bun install --save-text-lockfile --frozen-lockfile --lockfile-only` (offline, fails closed on drift; bun's chatter never reaches stdout). The recipe works from Bun 1.1.43: 1.1.43–1.1.45 write a version-0 `bun.lock` and KEEP `bun.lockb`, ≥ 1.2 write version 1 and delete it — the CLI removes a surviving `bun.lockb` itself, so the ledger's `redirect_bun_lockb_migrated` / action `removed` FileEdit is always true and Bun ≤ 1.1.38 can never silently install the unpatched bytes from a stale binary lock beside the redirected text lock; the edit's `original` carries the pre-migration bytes as standard base64 (locks up to 8 MiB; larger ones are recorded without bytes), which `rollback` restores (see "Hosted unwind coverage"). Bun 1.1.39–1.1.42 accept the flags, exit 0 and write NO text lock (`--frozen-lockfile` suppresses the save there; Bun ≤ 1.1.38 has no text lockfile at all): `redirect_bun_lockb_manual_migration`, whose detail names the manual step (`bun install --save-text-lockfile`, Bun ≥ 1.1.39) and re-run. A missing or unspawnable `bun`, a non-zero exit (the detail carries bun's output tail) or a surviving `bun.lockb` that cannot be removed (the text lock bun wrote is dropped again) is `redirect_bun_lockb_unsupported`; the binary lock is never parsed. A migration whose rewrite then lands nothing in the new `bun.lock` is undone — `bun.lockb` bytes restored, generated `bun.lock` removed, no ledger record — and reported `redirect_bun_lockb_migration_reverted` (`redirect_bun_lockb_migrated_without_redirect` when that restore itself fails; git history is the restore path); the rewriter's own warning says why nothing landed. The `redirect_npm_no_lockfile` noise is suppressed on a `bun.lockb` project. Every bun code above rides `redirect.warnings[]` with the hosted-refusal posture (exit 0, `redirected: 0`); all of it is additive (MINOR). 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). **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. @@ -134,11 +134,11 @@ The rewriter reads a fixed set of candidate files from the project root: the npm **get --mode and installed narrowing (v3.6).** `get --mode hosted|vendored` consumes the resolved patch(es) through the SAME engines as `scan --mode hosted|vendored`, so for the same selected (purl, uuid) set the on-disk result is identical by construction — this is the per-advisory selector hosted/vendored previously lacked (the old workaround, `get --save-only` then `vendor`, still works but is superseded). Semantics: * **Hosted** (`get GHSA-… --mode hosted`): resolves the advisory, then hands the selected (purl, uuid) pairs to scan's hosted engine — reference grants, cross-mode takeover pre-revert, lockfile rewrite, `redirect-state.json` ledger (merge-never-clobber), gem stale-install probe, warnings, confirmation rules (cargo via `confirmed_cargo_uuids` only) all identical to `scan --mode hosted`. **No manifest write, no blobs** — the ledger is the persistence. JSON: get's legacy envelope gains the same nested `redirect` sub-object as scan's (`{mode:"hosted", redirected, rewrittenFiles, skipped, warnings, dryRun}`); the top-level shape is `{status, found, patches:[], warnings?}` — `downloaded`/`applied` are absent (nothing is downloaded into `.socket/`). Exit codes follow scan's hosted semantics: skipped grants and rewriter warnings never flip the exit; infra errors (reference fetch, corrupt/unwritable ledger, file writes) exit 1. Human prompt: `Redirect N package(s) to the hosted patch server?` (scan hosted has no prompt; get keeps its confirm gate, `--yes`/`--json`/non-TTY auto-accept as usual). -* **Vendored** (`get GHSA-… --mode vendored`): the download phase is scan's vendored posture (writes ONLY `.socket/manifest.json`; blobs held in memory; the nested apply never runs), then scan's vendor step runs — apply lock, **whole-manifest scope including `reconcile_dropped`**: every manifest record is verified/re-vendored and records whose patches left the manifest may have their vendored state reverted, exactly like `scan --mode vendored` (a stderr `[note]` names the count of other affected records; this blast radius is deliberate parity, stated loudly). JSON: get's envelope (with `applied` dropped — structurally zero under save-only) gains the nested `vendor` Envelope exactly like scan's `result["vendor"]`; a vendor-step error folds the partial envelope + `{status:"error", error:{code,message}}` in (the pre-failure reconcile may have already mutated the ledger — its events must reach the consumer). Exit: download failures or vendor `has_errors` → `partial_failure`/1. Human prompt: `Download and vendor N patch(es)?`. Telemetry mirrors scan's vendored arms (`track_outcomes_for_vendor` / `track_patch_vendor_failed`). +* **Vendored** (`get GHSA-… --mode vendored`): the download phase is scan's vendored posture (writes ONLY `.socket/manifest.json`; blobs held in memory; the nested apply never runs), then scan's vendor step runs — apply lock, **whole-manifest scope including `reconcile_dropped`**: every manifest record is verified/re-vendored and records whose patches left the manifest may have their vendored state reverted, exactly like `scan --mode vendored` (a stderr `[note]` names the count of other affected records; this blast radius is deliberate parity, stated loudly). JSON: get's envelope (with `applied` dropped — structurally zero under save-only) gains the nested `vendor` Envelope exactly like scan's `result["vendor"]`; a vendor-step error folds the partial envelope + `{status:"error", error:{code,message}}` in (the pre-failure reconcile may have already mutated the ledger — its events must reach the consumer). Exit: download failures or vendor `has_errors` → `partial_failure`/1. Human prompt: `Download and vendor N patch(es)?`. Telemetry mirrors scan's vendored arms (`track_outcomes_for_vendor` / `track_patch_vendor_failed`). **Bun vendored preflight (additive)** — shared by `get --mode vendored` on both its paths, `scan --mode vendored`, and `--detached` runs: before ANY patch download, and only when the selection holds a `pkg:npm/` purl, the download phase reads `bun.lock`/`bun.lockb` once (`preflight_vendor`) and, when the vendor backend would refuse the project — `bun.lockb` with no `bun.lock` → `vendor_bun_lockb_unsupported`; an unreadable `bun.lock` → `vendor_lockfile_missing`; a `lockfileVersion` other than 0/1/2 or a non-canonical `packages` grammar → `vendor_lockfile_version_unsupported`; `workspace:` packages in a lock below version 2 → `vendor_bun_workspace_unsupported` — every `pkg:npm/` result becomes `{action:"failed", errorCode:, error:}` with NO fetch (the patch view is never requested) and no patch record; other ecosystems' results are untouched. **Search path** (`get --mode vendored`) and `scan --mode vendored`: the records ride `patches[]` / `download.patches[]`, the download phase still writes `.socket/manifest.json` (unchanged — an empty `{"patches": {}}` on a fresh project; a record seeded for another purl survives, re-serialized), the vendor step still runs (no event for the refused purl — unless `.socket/manifest.json` already held its record, in which case the vendor step's own preflight, shared with `vendor`, reports it `failed` with the same code and leaves any hosted wiring untouched), exit `partial_failure`/1. **`--detached`**: the same `download.patches[]` records with `download.downloaded: 0`, and no manifest at all (previously the view was fetched first and, for an alias install, the engine misreported `package_not_installed`). **uuid path** (`get --mode vendored`): the uuid lookup is the only fetch; the run exits 1 BEFORE the record save and the vendor step with exactly `{status:"error", found:1, downloaded:0, skipped:0, failed:1, error:{code, message}, patches:[{purl, uuid, action:"failed", errorCode, error}]}` (the `error` OBJECT is the vendored-mode error shape of the vendor-step fold-in above) and writes nothing — no `.socket/` on a fresh project; human mode prints `Error (): ` on stderr. **Already-vendored exemption**: a purl is exempt from the workspace refusal only when every instance of its `name@version` in `bun.lock` is already a `.socket/vendor/npm/…` local tuple (any uuid; the digest-less 2-tuple counts) — the engine's own criterion — so in-sync re-runs, `repair`, and a superseding patch uuid on a project vendored before it grew a workspace member all flow to the engine (re-pinning an already-local tuple adds no workspace-relative exposure); a wiped ledger alone is not a refusal (the engine path decides). UUID equality in the ledger alone never exempts a purl: `rollback --preserve-state` retains its record after unwiring. Dry-run refusal takes priority over `already_vendored`. **Unreadable vendor ledger**: a `.socket/vendor/state.json` the preflight cannot read or parse is itself the refusal — `vendor_state_unreadable` with the io/parse detail, fail-closed (nothing is exempt) — on the uuid path, the search / `scan` path, `--detached` and the `--dry-run` preview alike; never a Bun lock code. **`--silent`** is "errors only" and never mutes the refusal: the code-tagged `[error] (): ` (per-patch paths) / `Error (): …` (uuid path) line stays on stderr with an empty stdout. **`--dry-run`** previews the refusal as the additive `would_refuse` action (see `--dry-run` below). Agent-mode `get --save-only` is NOT preflighted (record-only intent has no consumption precondition). Pinned by `tests/in_process_vendor_bun.rs` (exact uuid-path envelope, seeded-manifest survival, detached parity, `--silent`, `--dry-run`) and `tests/scan_vendor_e2e.rs`. * **Installed-version narrowing** (all modes, `get`'s search path): a CVE/GHSA fan-out returns one patch record per patched VERSION; get keeps only versions present here and emits calm `skipped` records (`errorCode: "package_not_installed"`) for the rest — never an error exit. Presence = installed on disk (qualified-aware resolver) ∪ already tracked in the manifest (record maintenance keeps working on hosts without an installed copy); hosted/vendored modes additionally count lockfile-resolved deps and vendor-ledger purls (mirroring scan's discovery supplements, including their `--global` gate). **Exempt** (no narrowing): UUID identifiers, exact-versioned PURL identifiers (explicit intent), `--save-only` runs (record-only has no installation precondition — the fresh-clone record→vendor flow keeps working), `--all-releases`, and the package-name path (already installed-derived). When EVERY found patch is filtered out, get exits 0 with the additive status **`not_installed`** (`{status:"not_installed", found:N, downloaded:0, applied:0, patches:[], warnings?}`) — never `no_match`, which remains pinned to the fuzzy package-name path. PnP layouts are surfaced, not misreported: yarn-PnP npm results skip with `errorCode: "yarn_pnp_unsupported"` in every mode; pnpm-PnP skips carry `pnpm_pnp_unsupported` in agent/vendored modes; hosted mode — the refusal's own remedy — keeps ONLY the versions the raw `pnpm-lock.yaml` text actually resolves (boundary-anchored probe over the v5/v6/v9 key spellings, so a large fan-out never requests grants for every version ever patched), labels a JUDGED miss `package_not_installed` exactly like a non-PnP project (the layout blocked nothing — the lock was read and the version isn't resolved), and reserves the layout code for an unreadable lock (no judgment possible). When EVERY narrowed-out result is a PnP refusal, the human terminal names the layout instead of claiming "not installed" and never advises `--all-releases` (which cannot make PnP patchable); the JSON status stays `not_installed` — consumers dispatch on the per-record `errorCode`. Hosted mode also runs the per-release VARIANT filter (`filter_to_installed_releases`) on its search path before requesting grants — agent/vendored runs get it inside the download engines — with the same keep-all-plus-warning fallbacks (surfaced as `(release_narrowing)`-prefixed strings in `warnings[]`). An ecosystem this binary has no crawler for is likewise never judged: its results are KEPT (absence from a crawl that never looked carries no information — the same fail-safe as scan's prune GC). The human `Found patches:` listing deliberately shows ALL found patches (pre-narrowing, main's behavior) with the `[skip]` lines following; machine output (the prompt count, the JSON envelope) uses the kept set. The finer per-release variant narrowing (`filter_to_installed_releases`) is unchanged and still runs inside the download engines. * **Deliberate divergences from scan** (documented, not drift): get keeps its `selection_required` JSON posture for free multi-patch PURLs (scan auto-picks); get has no `--vex` (an ambient `SOCKET_VEX` is ignored by get's modes), no `--detached`, no `--prune`; get does not run scan's pre-confirm vendor baseline annotation; and an all-narrowed-out run exits `not_installed` without entering the vendor step (heal-after-wipe re-vendoring stays `scan --mode vendored`'s job). Plain agent-mode `get` continues to ignore `--dry-run` (pre-existing; hosted/vendored honor it — see below). -`--dry-run` previews what `apply` / `rollback` / `scan --apply` / `repair` / `remove` — and (v3.6) `get --mode hosted|vendored` — would do without mutating disk. `get --mode hosted --dry-run` flows through the hosted engine's dry-run contract (no ledger write, no lockfile writes, `redirect.dryRun: true`); `get --mode vendored --dry-run` emits the same ledger-classification preview as scan's (`would_vendor` / `already_vendored` / `would_revendor`+`oldUuid` under the nested `vendor` key) before any download, and both skip the confirm prompt (nothing to confirm). In JSON mode, the envelope is populated with would-be actions and counts (`remove --dry-run` skips the confirmation prompt — there is nothing to confirm — and flips its would-be `Removed` events to `Verified` previews, so `summary.removed` stays "entries actually deleted"). `repair --dry-run` also skips the final lock-file deletion. `rollback --dry-run` (v5.0) previews every leg — the in-place restore verification, the vendored unwire (`Would revert/unwire vendoring for …`), the hosted unwind (the redirect engines resolve every inverse and drift check exactly like a wet run, flush nothing to disk, and claim the IN-MEMORY ledger clone exactly like a wet run — so the composed preview, per-purl reverts then whole-ledger replay, sees the same intermediate state a wet run would; the ON-DISK ledger is untouched), the manifest removals (simulated in memory), and the blob/archive GC — with no writes and no prompt. +`--dry-run` previews what `apply` / `rollback` / `scan --apply` / `repair` / `remove` — and (v3.6) `get --mode hosted|vendored` — would do without mutating disk. `get --mode hosted --dry-run` flows through the hosted engine's dry-run contract (no ledger write, no lockfile writes, `redirect.dryRun: true`); `get --mode vendored --dry-run` emits the same ledger-classification preview as scan's (`would_vendor` / `already_vendored` / `would_revendor`+`oldUuid` under the nested `vendor` key — plus, additive, `would_refuse` + `errorCode` + `error` for npm purls the wet run's Bun preflight would refuse: an in-sync `already_vendored` entry is exempt, as is a `would_revendor` entry whose `bun.lock` instances are all already local tuples; a purl the lock still resolves from the registry is refused like a fresh one, and the preview stays exit 0 / `status: "success"` with nothing written) before any download, and both skip the confirm prompt (nothing to confirm). In JSON mode, the envelope is populated with would-be actions and counts (`remove --dry-run` skips the confirmation prompt — there is nothing to confirm — and flips its would-be `Removed` events to `Verified` previews, so `summary.removed` stays "entries actually deleted"). `repair --dry-run` also skips the final lock-file deletion. `rollback --dry-run` (v5.0) previews every leg — the in-place restore verification, the vendored unwire (`Would revert/unwire vendoring for …`), the hosted unwind (the redirect engines resolve every inverse and drift check exactly like a wet run, flush nothing to disk, and claim the IN-MEMORY ledger clone exactly like a wet run — so the composed preview, per-purl reverts then whole-ledger replay, sees the same intermediate state a wet run would; the ON-DISK ledger is untouched), the manifest removals (simulated in memory), and the blob/archive GC — with no writes and no prompt. The hidden alias `--no-apply` on `get --save-only` is **part of the contract** — it does not appear in `--help` but is widely used in existing scripts. @@ -571,7 +571,7 @@ to **six flavors**. | npm / yarn berry (node-modules linker) | (same tarball) | root `package.json` `resolutions` + `yarn.lock` entry with `checksum: 10c0/` of the berry cache-zip (reproduced from the tarball offline). **PnP is refused** (`.pnp.*` → different artifact pipeline) | `yarn install --immutable --check-cache`, cold cache. Refused if `__metadata.cacheKey ≠ 10c0` or a non-default `compressionLevel` | | npm / pnpm (lockfileVersion 9) | (same tarball) | root `package.json` `pnpm.overrides` (versioned selector) **+** `pnpm-lock.yaml` surgery (overrides / importer version / packages `resolution.integrity` / snapshots) | `pnpm install --frozen-lockfile --offline`, cold store (integrity-verified; byte-stable on pnpm 9 & 10). Other lockfileVersions: 5.4/6.0 route to the legacy backend below; anything else refused | | npm / pnpm LEGACY (lockfileVersion 5.4 = pnpm 7, 6.0 = pnpm 8; flavor `pnpm-legacy`) | (same tarball) | root `package.json` `pnpm.overrides` **+** legacy lock surgery (overrides / root dep + specifiers / packages rekey to a bare `file:` key with recomputed integrity / in-package dep refs). **No `pnpm-workspace.yaml` is written** (pnpm ≤ 8 reads overrides only from package.json). The lock's SPECIFIER is machine-ABSOLUTE — pnpm ≤ 8 absolutizes `file:` overrides itself — surfaced as `vendor_pnpm_legacy_absolute_specifier`. Legacy WORKSPACE locks (`importers:`) refused | same-path `pnpm install --frozen-lockfile --offline`, cold store (byte-stable on pnpm 7.33.5 / 8.15.9). A checkout at a DIFFERENT path fails the frozen check (path-bound specifier) and must run `pnpm install --offline --no-frozen-lockfile` once (the flag matters on CI, where pnpm defaults frozen on), which installs the vendored tarball and re-resolves only the specifier line | -| npm / bun (`bun.lock`) | (same tarball) | `bun.lock` only: the packages entry's registry 4-tuple → local 3-tuple with recomputed `sha512`. `bun.lockb` (binary) refused with a `--save-text-lockfile` pointer | `bun install --frozen-lockfile`, cold cache (integrity-enforced) | +| npm / bun (`bun.lock`, lockfileVersion 0, 1 or 2 — `vendor_lockfile_version_unsupported` otherwise) | (same tarball) | `bun.lock` only: the packages entry's registry 4-tuple → local 3-tuple with recomputed `sha512`; the entry's `{deps}` meta, the lock's version line and its line endings are preserved. A lock holding `workspace:` packages is refused `vendor_bun_workspace_unsupported` unless lockfileVersion is 2 — Bun 1.2–1.3 resolve a workspace member's local-tarball path relative to the MEMBER (ENOENT on our root-relative path), 1.4 relative to the lockfile, and a committed version-2 lock is the only proof every consumer runs Bun ≥ 1.4 (a deliberate over-approximation: a package declared only by the workspace root would install on version 1 too). The gate fires only on a run that would WRITE a new local tuple, so in-sync re-runs, `already_vendored` skips and `repair` rebuilds on such a lock pass. The detail names the version and the remedy: delete `bun.lock` and re-lock with Bun ≥ 1.4 (an in-place `bun install` keeps the existing lockfileVersion), or `--mode hosted`. `bun.lockb` (binary) refused `vendor_bun_lockb_unsupported` with the `bun install --save-text-lockfile` (Bun ≥ 1.1.39) pointer. `scan`/`get --mode vendored` apply all four refusals BEFORE downloading (see the `get --mode vendored` bullet). Bun 1.1.39–1.3.9 re-save the local tuple WITHOUT its `sha512` on any later lock re-save (`bun add`, `bun install` after a manifest change); the digest-less 2-tuple is recognised as the same wiring — an in-sync re-run stays `already_vendored` and re-pins the digest on disk (no new wiring record) when the committed artifact still holds the bytes the lock was written from — otherwise, as for any stale tuple of ours, the line is re-pinned and the fresh entry carries the new fingerprint — `repair` rebuilds through it, and `vendor --revert` / `rollback` restore the registry line over it (a 2-tuple at ANOTHER uuid is still `vendor_lock_entry_drifted`) | `bun install --frozen-lockfile`, cold cache (the local tarball's sha512 is enforced by Bun ≥ 1.3.10; 1.1.39–1.3.9 install it unverified — the committed artifact is the protection there) | | cargo | crate dir `-/` (no `.cargo-checksum.json`) | `.cargo/config.toml` `[patch.crates-io]` path entry **+** Cargo.lock surgery (the `[[package]]` entry's `source`/`checksum` removed) | `cargo build --locked --offline` on a fresh checkout. Requires cargo ≥ 1.56 (`[patch]` in config files). Note: path deps build **without** `--cap-lints allow` | | golang | module dir `@/` | `go.mod` `replace => ./.socket/vendor/golang//@` | `go build` with `GOPROXY=off` + empty `GOMODCACHE` (directory replaces bypass go.sum entirely; survives `go mod tidy`) | | composer | package dir `/@/` | `composer.lock` only: entry's `dist` → `{type: "path", url, reference: null}`, `source` removed, `transport-options: {symlink: false}` added. `content-hash` unaffected; `composer.json` untouched | `composer install` (from the lock alone, real copy not symlink, works under `--network none`). `composer update ` reverts it | @@ -587,7 +587,9 @@ to **six flavors**. Ecosystems with no vendor backend (jsr) refuse per-purl with `vendor_unsupported_ecosystem`. yarn-berry **PnP** (`.pnp.*`) and bun's binary `bun.lockb` are refused with stable codes pointing at the native -alternative / a text-lockfile migration; a lock-less tool marker (a `[tool.uv]`/`[tool.poetry]`/ +alternative / a text-lockfile migration (`bun install --save-text-lockfile`, Bun ≥ 1.1.39 — one +detail text on the `vendor` router and on the `get`/`scan --mode vendored` pre-download +preflight); a lock-less tool marker (a `[tool.uv]`/`[tool.poetry]`/ `[tool.pdm]` table or a `Pipfile` without its lock) refuses `_no_lockfile` unless a `requirements.txt` fallback exists. PURLs of **compiled-out** ecosystems are invisible to `vendor` exactly as they are to `apply` (the binary cannot parse them). @@ -607,7 +609,7 @@ worse, lets a warm cache silently serve unpatched bytes): | npm / yarn classic | `resolved "…#"` fragment + `integrity` SRI | both recomputed from the packed tarball (sha1 fragment + sha512 SRI); integrity line added when the registry block lacked one — yarn then enforces both | | npm / yarn berry | `checksum: 10c0/` (over berry's cache zip) | recomputed by rebuilding berry's deterministic cache-zip from the tarball and hashing it (byte-identical to yarn's own); refused if the lock's `cacheKey`/`compressionLevel` would change the zip | | npm / pnpm | `packages[].resolution.integrity` (sha512) | recomputed from the tarball; the versioned `pnpm.overrides` selector pins exactly the patched version | -| npm / bun | the packages-entry trailing `sha512-…` | recomputed from the tarball; tamper fails the frozen install | +| npm / bun | the packages-entry trailing `sha512-…` | recomputed from the tarball; tamper fails the frozen install on Bun ≥ 1.3.10 (URL/local tarball tuples are verified from 1.3.10, registry 4-tuples from 1.2.0 — so on 1.1.39–1.3.9 a hosted or vendored rewrite removes digest enforcement for the patched package; see `docs/testing/bun-compatibility.md`) | | gem | `CHECKSUMS` section (bundler ≥ 2.6 opt-in) | the vendored gem's entry rewritten to bundler's own path-gem form (bare `name (ver)`, sha256 token stripped) so re-locks stay byte-stable; original line in the ledger | | pypi / uv | `wheels[].hash`, `sdist.hash`, requires-dist specifiers | single `{filename, hash: sha256-of-our-wheel}`; sdist dropped; dropped specifiers ledgered for revert | | pypi / poetry | `files = [{file, hash}]` (2.x) / `[metadata.files]` entry (1.0/1.1) / `[metadata.hashes]` entry (0.12) | replaced with a single `{file, hash: sha256-of-our-wheel}` (or the bare hash for 0.12) in the generation's own table (Poetry ≥ 1.4 verifies the artifact against one listed hash; older writers are flagged `pypi_poetry_integrity_unverified`; stale registry hashes removed) | @@ -755,8 +757,8 @@ Restore the system but keep the local patch state for a later re-apply: manifest ### Hosted unwind coverage -* **Per-purl reverts** exist for **cargo and the npm family** (`redirect_revert_supported`): staged, fail-closed on drift, and honoring `dry_run` (every inverse and drift check resolves like a wet run; nothing flushes and the ledger is untouched). npm purls on projects with bun-lock edits DEFER to the replay (below) instead of failing, whenever the replay will run. -* **Whole-ledger reverse replay** (`revert_remaining_redirect_edits`, core `patch/redirect/replay.rs`) runs whenever the in-scope hosted record set equals the FULL ledger record set — however the scope was spelled (bare `rollback`, `rollback '**'`, an identifier set covering every record; `remove` reuses the same eligibility rule). It walks every remaining ledger edit in reverse write order through a **per-kind inverse table**, staged and committed **per ecosystem group, all-or-nothing**: one drifted, ambiguous (a fragment appearing more than once), or unhandled edit refuses the whole group byte-untouched while other groups proceed. This covers **gem, golang, pypi, composer, bun**, the yarn/pnpm text kinds (normally claimed by the per-purl npm revert first), and the **non-package rideshare edits** — the pnpm `trustLockfile` auto-config (a pristine created scaffold is deleted; a user-modified one keeps the file and loses only the `trustLockfile: true` line, warned as `redirect_pnpm_trust_scaffold_modified`) — plus a "last one out turns off the lights" pass: when the record map empties but non-package edits remain, they are replayed in the same persist, so the trust edit never strands. The **bun.lockb migration marker is unrestorable by design** (the binary original was never captured): it warns `redirect_bun_lockb_unrestorable` naming git history as the restore path and never blocks its group. +* **Per-purl reverts** exist for **cargo and the npm family** (`redirect_revert_supported`): staged, fail-closed on drift, and honoring `dry_run` (every inverse and drift check resolves like a wet run; nothing flushes and the ledger is untouched). npm purls on projects with bun-lock edits DEFER to the whole-ledger replay (below) whenever it will run — the scope covers every record, and the replay stages the bun group together with the `bun.lockb` migration marker all-or-nothing. A SCOPED unwind (`rollback `, or `remove ` while other hosted records remain) takes the per-purl revert instead: it claims that purl's `redirect_bun_lock_package` edits by the recorded line's spec (`@` registry spec, or a hosted URL whose tarball leaf is `-.tgz`) and replays them like the yarn/pnpm text kinds (whole-line fragments, CRLF-exact); a sibling version's edit is neither claimed nor a refusal; an edit that mentions the package but is not a bun packages-entry line refuses with the unscoped-`rollback` remedy. Pinned by `tests/in_process_vendor_bun_takeover.rs` (`bun_scoped_rollback_of_one_of_two_hosted_records_unwinds_only_that_purl` and the `remove` twin). +* **Whole-ledger reverse replay** (`revert_remaining_redirect_edits`, core `patch/redirect/replay.rs`) runs whenever the in-scope hosted record set equals the FULL ledger record set — however the scope was spelled (bare `rollback`, `rollback '**'`, an identifier set covering every record; `remove` reuses the same eligibility rule). It walks every remaining ledger edit in reverse write order through a **per-kind inverse table**, staged and committed **per ecosystem group, all-or-nothing**: one drifted, ambiguous (a fragment appearing more than once), or unhandled edit refuses the whole group byte-untouched while other groups proceed. This covers **gem, golang, pypi, composer, bun**, the yarn/pnpm text kinds (normally claimed by the per-purl npm revert first), and the **non-package rideshare edits** — the pnpm `trustLockfile` auto-config (a pristine created scaffold is deleted; a user-modified one keeps the file and loses only the `trustLockfile: true` line, warned as `redirect_pnpm_trust_scaffold_modified`) — plus a "last one out turns off the lights" pass: when the record map empties but non-package edits remain, they are replayed in the same persist, so the trust edit never strands. The **bun.lockb migration marker** (`redirect_bun_lockb_migrated`, action `removed`) restores the binary lock: when the edit's `original` carries the pre-migration bytes (standard base64, captured by the hosted run for locks up to 8 MiB) and no `bun.lockb` exists, the replay writes them back and warns `redirect_bun_lockb_restored` — the text `bun.lock` generated during the redirect is left in place (Bun ≥ 1.1.39 reads `bun.lock` when both exist; delete whichever lockfile you do not want). A `bun.lockb` already holding those bytes is a silent no-op; a DIFFERENT `bun.lockb` (the user re-locked with an old Bun) is never clobbered and warns `redirect_bun_lockb_unrestorable`, as does a marker recorded WITHOUT bytes (an oversize lock, or a ledger written by a CLI that predates the capture) when the file is absent — the detail names git history as the restore path; a present `bun.lockb` with no recorded bytes is left alone silently. An undecodable payload degrades like an absent one. The marker never blocks its group. * **maven and nuget fail closed**: their structured-metadata kinds (`redirect_maven_repository` / `redirect_maven_dep_management` / `redirect_maven_config` / `redirect_maven_trusted_checksums`, `redirect_nuget_source` / `redirect_nuget_lock`) have no revert implementation, so any such edit refuses its whole group (the maven `` suffix rewrite alone IS invertible, but it rides the same all-or-nothing group). The refusal keeps their records + edits in the ledger and names the remedy: re-run `scan --mode hosted` to normalize, or restore the lockfiles from version control. Unknown future kinds refuse the same way (forward-compat). * **Scoped runs** (paths / identifiers / `--ecosystems`) that do NOT cover the full record set get per-purl reverts only; in-scope hosted purls of ecosystems without one fail closed — `rollback` reports them in `hosted.unsupported` (exit 1), `remove` as the top-level `hosted_revert_unsupported` error — with the remedy "run an unscoped `socket-patch rollback` to unwind ALL hosted redirects, or re-run `scan --mode hosted`". * **Ledger accounting**: exactly the replayed (or already-at-original) edits are dropped; a record is dropped only when every group its ecosystem writes ended clean, so refused groups keep both edits and records — the intermediate-but-coherent ledger a retry needs. The mutated ledger is persisted (delete-when-empty); a failed persist rides `hosted.failed` / `hosted_revert_failed`. @@ -767,7 +769,7 @@ Restore the system but keep the local patch state for a later re-apply: manifest | Key | Shape | Meaning | |---|---|---| -| `warnings` | `[{code, detail}]` | Run-level warnings, now populated (previously always empty): `reinstall_required`, `hosted_state_not_preservable`, `out_of_scope_copies_restored`, `vendor_state_unreadable`, `redirect_state_unreadable`, `cleanup_failed`, `manifest_write_failed`, `redirect_bun_lockb_unrestorable`, `redirect_pnpm_trust_scaffold_modified`, plus vendored/hosted leg advisories. New codes are additive (MINOR) | +| `warnings` | `[{code, detail}]` | Run-level warnings, now populated (previously always empty): `reinstall_required`, `hosted_state_not_preservable`, `out_of_scope_copies_restored`, `vendor_state_unreadable`, `redirect_state_unreadable`, `cleanup_failed`, `manifest_write_failed`, `redirect_bun_lockb_restored`, `redirect_bun_lockb_unrestorable`, `redirect_pnpm_trust_scaffold_modified`, plus vendored/hosted leg advisories. New codes are additive (MINOR) | | `vendored` | `[purl]` | **Meaning narrowed (MAJOR)**: vendor-owned purls the run did NOT act on — today exactly the corrupt-vendor-ledger skip. Previously this listed every vendor-owned skip | | `vendoredReverted` | `[purl]` | Ledger entries cleanly reverted this run (unwired + artifact deleted + entry dropped; previewed on dry-run) | | `vendoredPreserved` | `[purl]` | `--preserve-state`: unwired with artifact + ledger entry kept | @@ -1045,9 +1047,10 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `hosted_state_not_preservable` | rollback `warnings[]` | rollback `--preserve-state` (v5.0): hosted redirects were unwound and their ledger records dropped anyway — hosted has no preservable local state; re-run `scan --mode hosted` to re-wire. (`remove --preserve-state` prints the same note on stderr.) | | `out_of_scope_copies_restored` | rollback `warnings[]` | path-scoped rollback (v5.0): a selected patch had installed copies outside the given patterns; ALL copies were restored (patches are per-package). Informational — never flips the exit. | | `path_scope_excluded_supplements` | scan `warnings[]` | path-scoped scan (v5.0): lockfile-only / vendor-ledger supplement packages have no installed path and were excluded from the scoped scan; the detail carries the count. | -| `vendor_state_unreadable` / `redirect_state_unreadable` | rollback `warnings[]`; remove top-level error | corrupt-ledger containment (v5.0). Rollback: an unreadable vendor ledger skips the vendored leg + manifest cleanup + GC; an unreadable redirect ledger skips the hosted leg (quarantine/restore remedy in the detail); either drives `partial_failure` exit 1 while the agent leg still restores files. Remove: `vendor_state_unreadable` is a hard top-level error before any mutation (an unreadable redirect ledger only warns — the identifier may match other stores). | +| `vendor_state_unreadable` / `redirect_state_unreadable` | rollback `warnings[]`; remove top-level error | corrupt-ledger containment (v5.0). Rollback: an unreadable vendor ledger skips the vendored leg + manifest cleanup + GC; an unreadable redirect ledger skips the hosted leg (quarantine/restore remedy in the detail); either drives `partial_failure` exit 1 while the agent leg still restores files. Remove: `vendor_state_unreadable` is a hard top-level error before any mutation (an unreadable redirect ledger only warns — the identifier may match other stores). Also the Bun vendored preflight's refusal code: `get` / `scan --mode vendored`, `--detached` runs, `vendor`'s pre-takeover check and the `--dry-run` `would_refuse` preview report an unreadable `.socket/vendor/state.json` as itself (`errorCode` in `patches[]` / `download.patches[]`, or `get `'s top-level `error.code`), fail-closed — nothing is exempt — instead of a Bun lock code. | | `manifest_write_failed` | rollback `warnings[]` | rollback (v5.0): the post-rollback manifest update could not be written; no entries were removed (`manifest.removedEntries: []`) and the run exits `partial_failure` 1. | -| `redirect_bun_lockb_unrestorable` | rollback/remove `warnings[]` | hosted replay (v5.0): the ledger records a bun.lockb→bun.lock migration whose binary original was never captured; restore bun.lockb from git history if the binary format is required. Never blocks its group. | +| `redirect_bun_lockb_restored` | rollback/remove `warnings[]` | hosted replay: the ledger's `redirect_bun_lockb_migrated` edit carried the pre-migration bytes and `bun.lockb` was absent, so the binary lock was written back; the text `bun.lock` generated during the redirect is kept (Bun ≥ 1.1.39 reads `bun.lock` when both exist — delete whichever lockfile you do not want). Never blocks its group. | +| `redirect_bun_lockb_unrestorable` | rollback/remove `warnings[]` | hosted replay (v5.0): the bun.lockb→bun.lock migration marker cannot restore `bun.lockb` — it was recorded without bytes (a lock above 8 MiB, or a ledger written before the bytes were captured) and the file is absent, or a DIFFERENT `bun.lockb` has appeared since (left untouched). Restore from git history if the binary format is required. Not emitted when the file is present with no recorded bytes. Never blocks its group. | | `redirect_pnpm_trust_scaffold_modified` | rollback/remove `warnings[]` | hosted replay (v5.0): the redirect-created `pnpm-workspace.yaml` scaffold was modified since; the file was kept and only the `trustLockfile: true` line removed. | | `vendor_stale_artifact_removed` | `removed` | vendor / scan `--vendor`: re-vendor under a newer patch uuid removed the previous uuid's orphaned artifact dir. | | `vendor_unsupported_ecosystem` | `skipped` | vendor: no vendor backend for this purl's ecosystem (jsr). | @@ -1057,7 +1060,13 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `vendor_wiring_unknown_revert_blocked` | `skipped` (beside the `failed`/`revert_failed` event) | vendor --revert: the ledger entry was reconstructed by `repair` without wiring records and the live lockfile still resolves through the artifact — the revert refuses (fail-closed) instead of deleting a tarball the lock points at. Recovery: `socket-patch repair`, then restore the pre-vendor lock (or re-lock without the override) and re-run the revert. | | `ecosystem_not_setup` | `skipped` | vex: the patch is applied and byte-verified but its ecosystem has no install hook configured and is not declared in the manifest's `setup.manual`, so it is omitted from the document (Property 7). Previously invisible in `--json`. | | `vendor_multiple_lockfiles` / `pypi_multiple_lockfiles` | `skipped` (warning) | vendor: a sibling lockfile of another package manager will still install UNPATCHED bytes; names the wired winner + the ignored locks. | -| `vendor_yarn_berry_unsupported` / `vendor_bun_lockb_unsupported` | `failed` | vendor (npm): yarn-berry PnP / bun binary lockfile — pointer to `yarn patch` / `bun install --save-text-lockfile`. | +| `vendor_yarn_berry_unsupported` / `vendor_bun_lockb_unsupported` | `failed` | vendor (npm): yarn-berry PnP / bun binary lockfile — pointer to `yarn patch` / `bun install --save-text-lockfile` (Bun ≥ 1.1.39; one detail text on the `vendor` router and on the `get`/`scan --mode vendored` pre-download preflight, where the bun code is a `failed` record with `errorCode` in `patches[]` / `download.patches[]`, or `get `'s top-level `error.code` with `status: "error"`). | +| `vendor_bun_workspace_unsupported` | `failed` | vendor / scan / get `--mode vendored` (bun): the text lock holds `workspace:` packages and its `lockfileVersion` is below 2 — Bun 1.2–1.3 resolve a workspace member's local-tarball path relative to the member; a committed version-2 lock is the proof every consumer runs Bun ≥ 1.4 (deliberate over-approximation: root-only declared packages would install on version 1 too). Detail names the version integer and a version-specific remedy: delete `bun.lock` and re-lock with Bun ≥ 1.4 (an in-place `bun install` keeps the existing version) — then, for a version-1 lock, "or use `--mode hosted`, which accepts version-1 workspace locks"; for a version-0 lock, "or delete `bun.lock`, re-lock with Bun ≥ 1.2 (which writes lockfileVersion 1) and use `--mode hosted`" (hosted refuses version-0 workspace locks, so a bare hosted pointer would send the user into a second refusal). Refused before any write — in the pre-download preflight on `get`/`scan` (see `vendor_bun_lockb_unsupported` for the placements); in the shared preflight that `vendor` and the vendor step run BEFORE a hosted → vendored takeover's revert (a hosted-redirected purl stays hosted-wired, ledger and lock untouched; `vendor --dry-run` previews the same `failed` code); and in the engine when the run would write a NEW local tuple. Exempt: purls the vendor ledger wires at the selected uuid, purls whose every `bun.lock` instance is already a `.socket/vendor/npm/` tuple (any uuid), in-sync re-runs and `repair` rebuilds. | +| `vendor_lockfile_missing` / `vendor_lockfile_version_unsupported` (bun preflight placement) | `failed` | scan / get `--mode vendored` (bun): the pre-download preflight found `bun.lock` unreadable / at a `lockfileVersion` other than 0, 1 or 2 (a newer version: update socket-patch; no integer: re-lock with Bun ≥ 1.2 — the same text as hosted's `redirect_bun_lock_unsupported`) or outside bun's single-line `packages` grammar. Same placements as `vendor_bun_lockb_unsupported`; nothing fetched, no patch record. An unreadable `.socket/vendor/state.json` met by the same preflight is `vendor_state_unreadable` (see that row), never one of these. | +| `bun_lockb_unsupported` | scan `warnings[]` (run-level) | scan (every mode): `bun.lockb` is present with no `bun.lock` beside it, so the lockfile inventory cannot read the project's lock — a fresh clone used to report a clean `scannedPackages: 0` success in every mode. The detail names `bun install --save-text-lockfile` (Bun ≥ 1.1.39); when a recognised sibling lock (`package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock`, `pnpm-lock.yaml`) sits beside it, the detail instead says the stale `bun.lockb` shadows `` in lockfile discovery — delete it if ``'s installer is in use, or run `bun install --save-text-lockfile` if bun is (the sibling is still not inventoried: fail-closed). Also a stderr `Warning (bun_lockb_unsupported): …` line. Exit code and `status` unchanged (the PnP-refusal posture). Kept in EVERY mode, hosted included: it states a fact about this run's discovery (the binary lock was never read, so its lockfile-only packages are invisible), and nothing at that point can know whether the hosted driver will speak about the file — it does so only when an npm override is granted (`redirect_bun_lockb_*` on `redirect.warnings[]`, or the `redirect_bun_lockb_migrated` edit), so on the run that migrates the two codes ride side by side (nothing is deduplicated) and on the many runs that grant nothing the warning is the only voice — never a silent no-op. (An earlier build dropped it on every non-empty hosted run, so a polyglot project or an installed-but-unpatched npm tree printed a clean hosted success.) | +| `would_refuse` | dry-run preview action (`vendor.patches[]`) | scan `--mode vendored --dry-run` / get `--mode vendored --dry-run`: the wet run's Bun preflight would refuse this npm purl; the record carries `errorCode` (one of the four Bun lock codes above, or `vendor_state_unreadable` for an unreadable vendor ledger) + `error`. Exit 0 / `status: "success"`, nothing written. | +| `vendor_would_revert_redirect` / `vendor_takeover_reverted_redirect` | `skipped` (advisory event) | vendor / scan / get `--mode vendored` over a hosted-redirected purl (cargo and the npm family, bun included): dry run — the per-purl hosted revert was PROBED and would succeed (for bun, only after the Bun vendored preflight accepted the lock; a refused lock is previewed as the wet run's `failed ` instead) / wet run — the hosted lockfile edits were reverted to their pre-redirect registry values and the redirect-ledger record dropped before vendoring (mode takeover). Fires on the run that takes over, not on re-runs. | +| `redirect_revert_failed` | `failed` | vendor / scan / get `--mode vendored` (dry and wet): the per-purl hosted revert refused (drifted lock, missing original fragment, an undecidable ledger edit) — nothing vendored for the purl, hosted wiring left in place, exit 1 `partial_failure`; the detail names the remedy (for bun: an unscoped `socket-patch rollback`). | | `vendor_yarn_berry_cache_unsupported` | `failed` | vendor (yarn berry): lock `cacheKey ≠ 10c0` or non-default `.yarnrc.yml` `compressionLevel` — the cache-zip checksum is not reproducible. | | `vendor_override_conflict` | `failed` | vendor (pnpm/yarn-berry): a user-authored override/resolution for the package already exists. | | `vendor_integrity_unverified` | `skipped` (warning) | vendor (pipenv): the lockfile format does not hash-check file entries; the committed wheel bytes are the protection. | @@ -1088,6 +1097,12 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `redirect_poetry_stale_install_risk` | `redirect.warnings[]` (warning) | scan `--mode hosted` (poetry): same writer test as above — a warm virtualenv keeps the upstream package after the redirect on Poetry < 1.4 (1.4+ re-installs from the new source); fresh installs pick up the patched wheel. Emitted once per rewritten lock, only on the run that rewrites it. | | `redirect_poetry_entry_not_found` / `redirect_poetry_missing_sha256` / `redirect_poetry_lock_unsupported` | `redirect.warnings[]` (warning) | scan `--mode hosted` (poetry): the lock has no `[[package]]` at the granted version (uv-parity twin of `redirect_uv_entry_not_found`); the grant carries no SHA-256 (gated once per dep, not per lock); the lock is refused — Poetry 0.12 layout (URL sources ignored), an unsupported `lock-version`, a forked package listed at several versions, a user-authored `[package.source]` on another origin (an earlier Socket URL for the same wheel is superseded in place), a malformed `[metadata.files]`/`[metadata.hashes]`, or a wheel whose filename does not match the locked package. Exit code and `status` unchanged (hosted-refusal posture). | | `redirect_pdm_refused` / `redirect_pdm_legacy_sync_required` | `redirect.warnings[]` (warning) | scan `--mode hosted` (pdm): the `pdm.lock` rewrite was refused — an unsupported `[metadata] lock_version` (the identity-losing `3.1` / `4.0`–`4.2` formats or an untested future format), an unsupported `strategy`, a package listed at several versions (fork) or absent, a user-authored `url`/`path`/VCS/`editable` source, hash-less or malformed `files`, or a wheel whose filename does not match the locked package (`redirect_pdm_refused`); or the lock was written in format `2` (PDM 0.12–1.4), whose upstream freshness bug lets `pdm install` regenerate the lock — use `pdm sync` (`redirect_pdm_legacy_sync_required`). A refused uuid is withheld from every other PyPI rewriter when `pdm.lock` is the install driver, and its patch is not confirmed. Exit code and `status` unchanged (hosted-refusal posture). | +| `redirect_bun_lock_unsupported` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun): the text lock's `lockfileVersion` is not 0, 1 or 2 (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2 — the shared gate's text, identical to vendored's `vendor_lockfile_version_unsupported`), or its `packages` section is not bun's single-line grammar. Nothing rewritten; exit 0 (hosted-refusal posture). | +| `redirect_bun_workspace_unsupported` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun): a lockfileVersion-0 lock (Bun 1.1.39–1.1.45 `--save-text-lockfile`) holds `workspace:` packages; frozen installs of that grammar cannot keep the hosted tuple. Detail: "Bun version-0 workspace locks cannot preserve hosted tarballs on frozen installs; delete bun.lock and re-run `bun install` with Bun >= 1.2 (which writes lockfileVersion 1, accepted by hosted mode) — a plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root -> member); otherwise it keeps version 0 or fails to resolve" (measured: Bun 1.2.0 keeps 0, 1.2.23–1.4.2 exit 1 "failed to resolve" on a root that does not depend on its members). Version-1/2 workspace locks are rewritten. Exit 0. | +| `redirect_bun_lockb_would_migrate` / `redirect_bun_lockb_manual_migration` / `redirect_bun_lockb_unsupported` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun; `bun.lockb` with no `bun.lock` and an npm override granted): `--dry-run` would run the migration, nothing spawned / the resolved `bun` exited 0 but wrote no text lock — Bun 1.1.39–1.1.42 under `--frozen-lockfile`, or Bun ≤ 1.1.38 with no text lockfile at all — run `bun install --save-text-lockfile` (Bun ≥ 1.1.39) yourself and re-run / `bun` is missing on an absolute `PATH` entry, could not be spawned, exited non-zero (detail carries bun's output tail) or the surviving `bun.lockb` could not be removed (the text lock is dropped again) — or `bun.lockb` is not a regular file (a FIFO, socket or directory squatting the name; detail "bun.lockb is not a regular file; refusing to migrate it"), refused BEFORE any spawn since bun would block on it too. `bun.lockb` is never parsed; exit 0. | +| `redirect_bun_lockb_sibling_lock` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun; `bun.lockb` with no `bun.lock`, an npm override granted, AND a recognised sibling lock — `package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock` or `pnpm-lock.yaml` — present): the stale binary lock is NOT migrated (the recipe would have converted an npm / yarn / pnpm project into a `bun.lock` project) and `bun` is not spawned; detail "bun.lockb was left alone because is also present; the redirect follows — delete the stale bun.lockb if it is debris, or remove and re-run if bun is the installer". The npm-family rewrite proceeds on the sibling lock as before; `--dry-run` reports this code in place of `redirect_bun_lockb_would_migrate`. Exit 0. | +| `redirect_bun_lockb_migration_reverted` / `redirect_bun_lockb_migrated_without_redirect` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun): the migration succeeded but no redirect landed in the new `bun.lock` (the rewriter's own warning says why) — the pre-migration `bun.lockb` was restored, the text lock removed and no ledger record kept / the restore itself failed, so the migration and its `removed` ledger record stand (git history is the restore path). Exit 0. | +| `redirect_bun_entry_not_found` / `redirect_bun_missing_sha512` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun): the lock has no rewritable entry at the granted version (re-resolved, or occupied by an unowned URL/file spec) / the grant carries no sha512 integrity. Per-dep; nothing rewritten for it; exit 0. NOT emitted for the digest-less 2-tuple Bun 1.1.39–1.3.9 re-save our URL tuple as — that entry counts as redirected and is healed. | | `vendor_prebuilt_stub_invalid` | `failed` / `skipped` (warning) | vendor (gem, `--vendor-source`): the served stub gemspec fails the rubygems `summary`/`authors` bar, so bundler would refuse the vendored path source at install time. `service`: refusal naming the missing attributes; `auto`: loud warning + local-build fallback — or, when the gem is also not installed locally (no stub to derive), a refusal naming the served defect and the install-the-gem remedy. | | `gem_spec_invalid` | `failed` | vendor (gem): the LOCAL `specifications/` stub gemspec fails the same rubygems `summary`/`authors` bar (a corrupted or hand-edited gem home); the refusal names the file — reinstall the gem (`gem pristine ` / fresh `bundle install`). | | `vendor_*` / `pypi_*` / `gemfile_*` / `lock_*` / `locked_version_mismatch` / `user_authored_*` / `native_extensions_unsupported` / `platform_gem_unsupported` | `failed`/`skipped` | vendor: per-ecosystem refusal + drift vocabulary; see the Vendor command contract section. New tags are additive (MINOR). | @@ -1168,6 +1183,7 @@ rely on these keys. ], // ----- failure path (only on action=failed) ----- + "errorCode": "vendor_bun_workspace_unsupported", // additive; today only the vendored-mode Bun preflight refusals (+ vendor_state_unreadable) "error": "could not fetch details" } ``` @@ -1185,6 +1201,17 @@ installed-version narrowing; see "get --mode and installed narrowing"), the same calm-skip vocabulary as scan's pre-download partitions. Absent on the classic "already in manifest" skip. +Additive: a `failed` record may ALSO carry `errorCode` beside `error` — +today exactly the vendored-mode Bun preflight refusals +(`vendor_bun_lockb_unsupported`, `vendor_lockfile_missing`, +`vendor_lockfile_version_unsupported`, `vendor_bun_workspace_unsupported`, +and `vendor_state_unreadable` when the preflight cannot read +`.socket/vendor/state.json`) +that `get --mode vendored` and `scan --mode vendored` (`download.patches[]`) +emit before any download; see "get --mode and installed narrowing" → +Vendored → Bun vendored preflight. Every other `failed` record carries only +`error`. The dry-run preview's `would_refuse` records carry the same pair. + `vulnerabilities[]` is always sorted by `id` so consumer diffs and test snapshots are stable. `severity` at the top level is the max across the array using the ordering `critical > high > medium = moderate > low > (unknown)`. diff --git a/crates/socket-patch-cli/Cargo.toml b/crates/socket-patch-cli/Cargo.toml index 9075a09d..2a3cc2dc 100644 --- a/crates/socket-patch-cli/Cargo.toml +++ b/crates/socket-patch-cli/Cargo.toml @@ -28,6 +28,10 @@ uuid = { workspace = true } regex = { workspace = true } glob = { workspace = true } tempfile = { workspace = true } +# The hosted redirect ledger carries a migrated bun.lockb's pre-migration +# bytes as standard base64 (`commands/scan/hosted.rs`); the core replay +# decodes them with the same crate. +base64 = { workspace = true } [target.'cfg(unix)'.dependencies] # main.rs restores the default SIGPIPE disposition so piped invocations diff --git a/crates/socket-patch-cli/src/commands/bun_preflight.rs b/crates/socket-patch-cli/src/commands/bun_preflight.rs new file mode 100644 index 00000000..b8297a05 --- /dev/null +++ b/crates/socket-patch-cli/src/commands/bun_preflight.rs @@ -0,0 +1,395 @@ +//! The Bun vendored-mode preflight shared by EVERY path that feeds the +//! vendor engine: `scan --mode vendored` (the manifest-tracked AND the +//! `--detached` download phases), `get … --mode vendored` (search and uuid +//! paths), their `--dry-run` previews, and the `vendor` command's engine +//! loop itself ([`crate::commands::vendor::vendor_records`], where it runs +//! BEFORE the hosted→vendored takeover reverts anything). +//! +//! One read-only [`preflight_vendor`] per run, evaluated before any +//! `/patches/view/` fetch and before any write, so an incompatible Bun +//! project (binary `bun.lockb` without a text lock, an unreadable lock, an +//! unsupported `lockfileVersion`, a pre-version-2 `workspace:` lock) never +//! has a patch downloaded on its behalf — let alone recorded in the +//! manifest, or its live hosted redirect stripped — and every entry point +//! reports the SAME vendor code the engine would have emitted. +//! +//! [`preflight_vendor`]: socket_patch_core::vendor::bun_lock::preflight_vendor + +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use socket_patch_core::api::types::PatchSearchResult; +use socket_patch_core::vendor::load_state; +use socket_patch_core::vendor::state::VendorEntry; + +/// The vendor ledger as the preflight consumes it: the caller's own +/// `load_state` outcome, so an UNREADABLE ledger is a fact the refusal can +/// report instead of a silently-emptied exemption set. +pub(crate) type LedgerLoad<'a> = Result<&'a HashMap, &'a std::io::Error>; + +/// The outcome of the preflight when the project is refused. +/// +/// `exempt` holds the selected purls the refusal must NOT pre-empt — they +/// flow through to the engine, which lets them in exactly as on a non-Bun +/// project. A purl is exempt only when `bun.lock` wires every instance of +/// its `name@version` to one of our `.socket/vendor/npm/` tuples, at any +/// UUID ([`wired_instances_all_ours`]). This matches the engine's workspace +/// gate: updating an already-local tuple introduces no new relative path, +/// so in-sync runs, superseding patches and repairs remain supported. +/// +/// A matching ledger UUID alone is insufficient: `rollback --preserve-state` +/// retains the entry after removing its wiring. +/// +/// An unreadable ledger exempts nothing (fail closed) and the refusal +/// itself becomes `vendor_state_unreadable` with the io/parse detail: +/// the vendor step does NOT reliably report the corrupt ledger itself +/// (`get --mode vendored` returns before it runs, and the +/// scan/search paths reach it only when the manifest already holds another +/// vendorable record), so the one refusal this run emits has to name the +/// real problem rather than send the operator off to re-lock bun.lock. +/// +/// [`wired_instances_all_ours`]: socket_patch_core::vendor::bun_lock::wired_instances_all_ours +pub(crate) struct BunVendorRefusal { + /// The stable vendor error code (`vendor_bun_lockb_unsupported`, + /// `vendor_lockfile_missing`, `vendor_lockfile_version_unsupported`, + /// `vendor_bun_workspace_unsupported`) — the same string the vendor + /// engine would have emitted as a `failed` event — or + /// `vendor_state_unreadable` when the ledger could not be read. + pub(crate) code: &'static str, + /// The engine's (or the ledger loader's) human-readable detail, relayed + /// verbatim. + pub(crate) detail: String, + exempt: HashSet, +} + +impl BunVendorRefusal { + /// Whether the refusal applies to `purl`: npm-family only (no other + /// ecosystem's backend consults `bun.lock`), minus the already-vendored + /// exemption. + pub(crate) fn applies_to(&self, purl: &str) -> bool { + purl.starts_with("pkg:npm/") && !self.exempt.contains(purl) + } +} + +/// Run the Bun preflight once for `selected` — only when it holds at least +/// one npm purl, since nothing else can be affected — loading the vendor +/// ledger at `cwd` to detect corruption. `None` means nothing to refuse. +pub(crate) async fn bun_vendor_preflight( + cwd: &Path, + selected: &[PatchSearchResult], +) -> Option { + let pairs = selection_pairs(selected); + if !pairs.iter().any(|(purl, _)| purl.starts_with("pkg:npm/")) { + return None; + } + let (code, detail) = socket_patch_core::vendor::bun_lock::preflight_vendor(cwd) + .await + .err()?; + // Loaded only once the project is known to refuse: an accepted project + // never touches the ledger here (the vendor step owns it). + let ledger = load_state(cwd).await; + Some( + refusal_with_exemptions( + cwd, + code, + detail, + &pairs, + ledger.as_ref().map(|s| &s.entries), + ) + .await, + ) +} + +/// [`bun_vendor_preflight`] for callers that already loaded the ledger (the +/// detached download phase, the dry-run preview), handed the load outcome +/// so an unreadable ledger reports as such. +pub(crate) async fn bun_vendor_preflight_with_ledger( + cwd: &Path, + selected: &[PatchSearchResult], + ledger: LedgerLoad<'_>, +) -> Option { + bun_vendor_preflight_pairs(cwd, &selection_pairs(selected), ledger).await +} + +/// The preflight over bare `(purl, uuid)` pairs — the `vendor` command's +/// view of its selection (manifest records, not search results) — with a +/// caller-loaded ledger. `None` when no npm purl is selected or the +/// project is accepted. +pub(crate) async fn bun_vendor_preflight_pairs( + cwd: &Path, + pairs: &[(&str, &str)], + ledger: LedgerLoad<'_>, +) -> Option { + if !pairs.iter().any(|(purl, _)| purl.starts_with("pkg:npm/")) { + return None; + } + let (code, detail) = socket_patch_core::vendor::bun_lock::preflight_vendor(cwd) + .await + .err()?; + Some(refusal_with_exemptions(cwd, code, detail, pairs, ledger).await) +} + +fn selection_pairs(selected: &[PatchSearchResult]) -> Vec<(&str, &str)> { + selected + .iter() + .map(|s| (s.purl.as_str(), s.uuid.as_str())) + .collect() +} + +/// Turn the engine's project-level refusal into the per-purl verdict: the +/// live-lock exemption described on [`BunVendorRefusal`], or the +/// `vendor_state_unreadable` refusal when the ledger cannot be read. +async fn refusal_with_exemptions( + cwd: &Path, + code: &'static str, + detail: String, + pairs: &[(&str, &str)], + ledger: LedgerLoad<'_>, +) -> BunVendorRefusal { + if let Err(e) = ledger { + return BunVendorRefusal { + code: "vendor_state_unreadable", + detail: e.to_string(), + exempt: HashSet::new(), + }; + } + // The lock-derived exemption exists only for the workspace gate: every + // other preflight code means bun.lock could not be read or parsed, so + // nothing in it can be ours and re-reading it per purl would be wasted + // (guarded, but still) I/O. + let lock_parsed = code == "vendor_bun_workspace_unsupported"; + let mut exempt = HashSet::new(); + for (purl, _) in pairs { + if !purl.starts_with("pkg:npm/") { + continue; + } + // A preserved ledger can outlive its wiring (rollback --preserve-state). + // Only live lock tuples prove the engine can skip the workspace gate. + let lock_all_ours = lock_parsed + && socket_patch_core::vendor::bun_lock::wired_instances_all_ours(cwd, purl) + .await + .unwrap_or(false); + if lock_all_ours { + exempt.insert((*purl).to_string()); + } + } + BunVendorRefusal { + code, + detail, + exempt, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use socket_patch_core::api::types::VulnerabilityResponse; + + const UUID: &str = "22222222-2222-4222-8222-222222222222"; + const OTHER_UUID: &str = "33333333-3333-4333-8333-333333333333"; + const PURL: &str = "pkg:npm/covgap-bun@1.0.0"; + + fn sel(uuid: &str, purl: &str) -> PatchSearchResult { + PatchSearchResult { + uuid: uuid.into(), + purl: purl.into(), + published_at: "2024-01-01".into(), + description: String::new(), + license: "MIT".into(), + tier: "free".into(), + vulnerabilities: HashMap::::new(), + } + } + + /// A real bun 1.3.14 lockfileVersion-1 workspace lock (matrix capture + /// grammar) resolving `covgap-bun@1.0.0` from the registry. + const BUN_V1_WORKSPACE_LOCK: &str = r#"{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "unit-fixture", + "dependencies": { + "consumer": "workspace:*", + }, + }, + "packages/consumer": { + "name": "consumer", + "version": "1.0.0", + "dependencies": { + "covgap-bun": "1.0.0", + }, + }, + }, + "packages": { + "consumer": ["consumer@workspace:packages/consumer"], + + "covgap-bun": ["covgap-bun@1.0.0", "", {}, "sha512-AAAA=="], + } +} +"#; + + fn seed_bun_vendor_entry(root: &Path, purl: &str, uuid: &str) { + let vendor = root.join(".socket/vendor"); + std::fs::create_dir_all(&vendor).unwrap(); + std::fs::write( + vendor.join("state.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "version": 1, + "entries": { purl: { + "ecosystem": "npm", + "basePurl": purl, + "uuid": uuid, + "artifact": { + "path": format!(".socket/vendor/npm/{uuid}/covgap-bun-1.0.0.tgz"), + }, + "wiring": [], + "flavor": "bun", + }} + })) + .unwrap(), + ) + .unwrap(); + } + + /// The lock as it reads once `covgap-bun` is vendored at `uuid`. + fn vendored_lock(uuid: &str) -> String { + BUN_V1_WORKSPACE_LOCK.replace( + r#"["covgap-bun@1.0.0", "", {}, "sha512-AAAA=="]"#, + &format!( + r#"["covgap-bun@.socket/vendor/npm/{uuid}/covgap-bun-1.0.0.tgz", {{}}, "sha512-OURS=="]"# + ), + ) + } + + /// `bun_vendor_preflight` never reads the lock when nothing selected is + /// npm (no needless I/O, no spurious refusal for other ecosystems); + /// a ledger alone never exempts; an unreadable ledger exempts nothing + /// (fail closed) AND is reported as the real problem + /// (`vendor_state_unreadable`), never as a Bun lock remedy. + #[tokio::test] + async fn preflight_scope_and_corrupt_ledger() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("bun.lockb"), b"\x00binary").unwrap(); + let pypi = vec![sel( + "11111111-1111-4111-8111-111111111111", + "pkg:pypi/only@1.0.0", + )]; + assert!( + bun_vendor_preflight(tmp.path(), &pypi).await.is_none(), + "no npm purl selected => no refusal" + ); + + let npm = vec![sel(UUID, PURL)]; + let refusal = bun_vendor_preflight(tmp.path(), &npm) + .await + .expect("lockb-only project is refused"); + assert_eq!(refusal.code, "vendor_bun_lockb_unsupported"); + assert!(refusal.applies_to(PURL)); + assert!(!refusal.applies_to("pkg:pypi/only@1.0.0")); + + // A ledger at this UUID cannot make a binary lock vendorable. + seed_bun_vendor_entry(tmp.path(), PURL, UUID); + let refusal = bun_vendor_preflight(tmp.path(), &npm).await.unwrap(); + assert_eq!(refusal.code, "vendor_bun_lockb_unsupported"); + assert!(refusal.applies_to(PURL), "the live lock must be compatible"); + + // …but a corrupt ledger exempts nothing and names itself. + std::fs::write(tmp.path().join(".socket/vendor/state.json"), b"{ not json").unwrap(); + let refusal = bun_vendor_preflight(tmp.path(), &npm).await.unwrap(); + assert_eq!( + refusal.code, "vendor_state_unreadable", + "the refusal must name the ledger, not the lock: {}", + refusal.detail + ); + assert!( + refusal.detail.contains("state.json"), + "the io/parse detail names the file: {}", + refusal.detail + ); + assert!( + refusal.applies_to(PURL), + "an unreadable ledger must not exempt (fail closed)" + ); + // The ledger-passing variant reports the same. + let err = std::io::Error::other("corrupt state.json: synthetic"); + let refusal = bun_vendor_preflight_with_ledger(tmp.path(), &npm, Err(&err)) + .await + .unwrap(); + assert_eq!(refusal.code, "vendor_state_unreadable"); + assert_eq!(refusal.detail, "corrupt state.json: synthetic"); + assert!(refusal.applies_to(PURL)); + } + + /// The lock-derived exemption: on a pre-v2 workspace lock whose every + /// instance of the purl is already ours, a SUPERSEDING uuid (ledger at + /// the old uuid) and a WIPED ledger are both exempt — the engine + /// re-vendors / stays in sync — while a fresh registry instance is + /// refused whatever the ledger says about other purls. + #[tokio::test] + async fn lock_derived_exemption_matches_the_engine_gate() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("bun.lock"), BUN_V1_WORKSPACE_LOCK).unwrap(); + let fresh = vec![sel(UUID, PURL)]; + let refusal = bun_vendor_preflight(tmp.path(), &fresh).await.unwrap(); + assert_eq!(refusal.code, "vendor_bun_workspace_unsupported"); + assert!( + refusal.applies_to(PURL), + "a fresh registry instance is refused" + ); + + // A preserved ledger does not make registry wiring exempt. + seed_bun_vendor_entry(tmp.path(), PURL, UUID); + let refusal = bun_vendor_preflight(tmp.path(), &fresh).await.unwrap(); + assert!(refusal.applies_to(PURL)); + + // Live vendored tuples remain exempt. + std::fs::write(tmp.path().join("bun.lock"), vendored_lock(UUID)).unwrap(); + let refusal = bun_vendor_preflight(tmp.path(), &fresh).await.unwrap(); + assert!(!refusal.applies_to(PURL), "in-sync re-run is exempt"); + + // Superseding uuid: the ledger disagrees, the lock says ours → exempt. + let superseding = vec![sel(OTHER_UUID, PURL)]; + let refusal = bun_vendor_preflight(tmp.path(), &superseding) + .await + .unwrap(); + assert_eq!(refusal.code, "vendor_bun_workspace_unsupported"); + assert!( + !refusal.applies_to(PURL), + "a patch update on an already-vendored purl must not be refused" + ); + + // Wiped ledger: no entry at all, the lock alone exempts. + std::fs::remove_file(tmp.path().join(".socket/vendor/state.json")).unwrap(); + let refusal = bun_vendor_preflight(tmp.path(), &fresh).await.unwrap(); + assert!( + !refusal.applies_to(PURL), + "a lost ledger must not turn an in-sync project into a refusal" + ); + + // A different, still-registry purl in the same lock stays refused + // and a non-npm purl is never in scope. + let others = vec![ + sel(OTHER_UUID, "pkg:npm/other@2.0.0"), + sel(OTHER_UUID, "pkg:pypi/x@1.0.0"), + ]; + let refusal = bun_vendor_preflight(tmp.path(), &others).await.unwrap(); + assert!(refusal.applies_to("pkg:npm/other@2.0.0")); + assert!(!refusal.applies_to("pkg:pypi/x@1.0.0")); + + // The pairs form (the `vendor` command's view) agrees. + let pairs = [(PURL, OTHER_UUID), ("pkg:npm/other@2.0.0", OTHER_UUID)]; + let empty = HashMap::new(); + let refusal = bun_vendor_preflight_pairs(tmp.path(), &pairs, Ok(&empty)) + .await + .unwrap(); + assert!(!refusal.applies_to(PURL)); + assert!(refusal.applies_to("pkg:npm/other@2.0.0")); + assert!( + bun_vendor_preflight_pairs(tmp.path(), &[("pkg:cargo/x@1.0.0", UUID)], Ok(&empty)) + .await + .is_none(), + "no npm pair => no refusal" + ); + } +} diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 12c5885a..b08d3b7c 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -21,6 +21,9 @@ use std::fmt; use std::path::{Path, PathBuf}; use crate::args::{apply_env_toggles, GlobalArgs}; +use crate::commands::bun_preflight::{ + bun_vendor_preflight, bun_vendor_preflight_with_ledger, BunVendorRefusal, +}; use crate::ecosystem_dispatch::{ crawl_all_ecosystems, find_packages_for_rollback, partition_purls, }; @@ -898,8 +901,7 @@ fn purl_has_version(purl: &str) -> bool { /// positive costs one grant request the rewriter's per-dep confirmation /// then ignores. fn pnpm_lock_resolves(text: &str, name: &str, version: &str) -> bool { - let version_boundary = - |c: char| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+')); + let version_boundary = |c: char| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+')); let name_boundary = |c: char| matches!(c, ' ' | '\t' | '\n' | '\r' | '\'' | '"'); for needle in [format!("{name}@{version}"), format!("/{name}/{version}")] { for (pos, _) in text.match_indices(needle.as_str()) { @@ -908,7 +910,10 @@ fn pnpm_lock_resolves(text: &str, name: &str, version: &str) -> bool { // v5/v6's leading key delimiter — legitimate only when the // char before it is itself a boundary (otherwise this is a // scoped `@scope/` tail: a DIFFERENT package). - Some('/') => text[..pos - 1].chars().next_back().is_none_or(name_boundary), + Some('/') => text[..pos - 1] + .chars() + .next_back() + .is_none_or(name_boundary), Some(c) => name_boundary(c), }; let after_ok = text[pos + needle.len()..] @@ -1031,8 +1036,7 @@ async fn filter_to_installed_purls( // keep-branch below on version membership, so a large advisory fan-out // doesn't request grants for every version ever patched (raw // `read_to_string` matches the hosted flow's own candidate-file reads). - let pnpm_pnp_lock_text: Option = (pnp_pnpm - && mode == super::scan::ScanMode::Hosted) + let pnpm_pnp_lock_text: Option = (pnp_pnpm && mode == super::scan::ScanMode::Hosted) .then(|| std::fs::read_to_string(common.cwd.join("pnpm-lock.yaml")).ok()) .flatten(); @@ -1205,9 +1209,34 @@ pub(crate) async fn download_patch_records( let (selected, narrow_warnings) = filter_to_installed_releases(selected, params, &api_client).await; - let vendor_state = socket_patch_core::vendor::load_state(¶ms.cwd) + // The ledger load outcome is handed to the preflight AS a result: an + // unreadable ledger must surface as `vendor_state_unreadable` from the + // one refusal this phase emits (fail closed, nothing exempt), not be + // flattened into an empty ledger that then reports a Bun lock remedy. + // For the idempotency lookup below it degrades to empty (no detached + // entry to reuse — the vendor step reports the corruption itself). + let vendor_state = socket_patch_core::vendor::load_state(¶ms.cwd).await; + + // The same Bun preflight the manifest-tracked download runs (see + // `download_and_apply_patches`): a detached run feeds the same vendor + // engine, so it must refuse the same projects BEFORE fetching. Without + // it the patch view was downloaded for nothing and — for a package + // installed under an alias directory, resolvable only through the + // unreadable bun.lockb inventory — the vendor step then misreported + // `package_not_installed` instead of the real `vendor_bun_*` code. + // `persist_blobs` is never set on this (vendor-only) path; the gate + // mirrors the manifest-tracked download's posture defensively. + let bun_refusal = if params.persist_blobs { + None + } else { + bun_vendor_preflight_with_ledger( + ¶ms.cwd, + &selected, + vendor_state.as_ref().map(|s| &s.entries), + ) .await - .unwrap_or_default(); + }; + let vendor_state = vendor_state.unwrap_or_default(); let mut records: HashMap = HashMap::new(); let mut downloaded = 0usize; @@ -1235,6 +1264,29 @@ pub(crate) async fn download_patch_records( continue; } + if let Some(refusal) = bun_refusal + .as_ref() + .filter(|r| r.applies_to(&search_result.purl)) + { + // Errors are exempt from --silent ("errors only"); JSON runs + // carry the code + detail in the envelope instead. + if !params.json { + eprintln!( + " [error] {} ({}): {}", + search_result.purl, refusal.code, refusal.detail + ); + } + failed += 1; + patch_records_json.push(serde_json::json!({ + "purl": search_result.purl, + "uuid": search_result.uuid, + "action": "failed", + "errorCode": refusal.code, + "error": refusal.detail, + })); + continue; + } + // org slug is already stored in the client. match api_client.fetch_patch(None, &search_result.uuid).await { Ok(Some(patch)) => { @@ -1512,7 +1564,41 @@ pub async fn download_and_apply_patches( let mut patches_downloaded = 0; let mut downloaded_patches: Vec = Vec::new(); + // Vendored downloads must not claim a patch in the manifest when Bun + // cannot consume its artifact (see `BunVendorRefusal`). Agent/save-only + // flows (`persist_blobs`) retain their record-only intent: the preflight + // is scoped to the `save_only && !persist_blobs` posture the vendored + // flows use, never the agent download. + let bun_refusal = if params.save_only && !params.persist_blobs { + bun_vendor_preflight(¶ms.cwd, &selected).await + } else { + None + }; for search_result in &selected { + if let Some(refusal) = bun_refusal + .as_ref() + .filter(|r| r.applies_to(&search_result.purl)) + { + patches_failed += 1; + downloaded_patches.push(serde_json::json!({ + "purl": search_result.purl, + "uuid": search_result.uuid, + "action": "failed", + "errorCode": refusal.code, + "error": refusal.detail, + })); + // Errors are exempt from --silent ("errors only", like the + // `[fail]` lines below); JSON runs carry the code + detail in + // the envelope instead. Code-tagged so a `--silent` operator + // can grep the stable code, not just the prose. + if !params.json { + eprintln!( + " [error] {} ({}): {}", + search_result.purl, refusal.code, refusal.detail + ); + } + continue; + } // org slug is already stored in the client. match api_client.fetch_patch(None, &search_result.uuid).await { Ok(Some(patch)) => { @@ -2788,6 +2874,7 @@ async fn run_get_vendored_search( "[dry-run] Would download and vendor {} patch(es).", selected.len() ); + super::scan::print_dry_run_refusals(&preview); } return 0; } @@ -2841,8 +2928,11 @@ async fn run_get_vendored_search( { Ok((vendor_errors, venv)) => { has_errors |= vendor_errors; + // Telemetry follows the RUN outcome, not the vendor step alone: + // a download-phase refusal/failure exits 1 and must not report + // a successful vendoring of zero patches (scan's arms agree). crate::commands::vendor::track_outcomes_for_vendor( - vendor_errors, + has_errors, &venv, args.common.dry_run, telemetry_token, @@ -2920,10 +3010,68 @@ async fn run_get_vendored_uuid( print_json(&result); } else if !args.common.silent { println!("[dry-run] Would download and vendor 1 patch."); + super::scan::print_dry_run_refusals(&preview); } return 0; } + // Bun preflight (see `BunVendorRefusal`): refuse BEFORE the manifest + // record is saved and before the vendor step, so the tree stays exactly + // as it was (no `.socket/` is created on a fresh project). The + // already-fetched patch is the only network traffic of a refused run. + // + // JSON shape (contract: `get --mode vendored` pre-record refusal; + // the record carries BOTH `errorCode` and `error` like the search path's + // failed records, and the envelope carries `skipped` like this path's + // success shape): + // + // { + // "status": "error", + // "found": 1, "downloaded": 0, "skipped": 0, "failed": 1, + // "error": { "code": "", "message": "" }, + // "patches": [{ "purl": "…", "uuid": "…", "action": "failed", + // "errorCode": "", "error": "" }] + // } + // + // Human: `Error (): ` on stderr — an error, so it is + // exempt from `--silent` like every other `Error (…)` line here. + let selected = vec![search_result_from_response(patch)]; + if let Some(refusal) = bun_vendor_preflight(&args.common.cwd, &selected) + .await + .filter(|r| r.applies_to(&patch.purl)) + { + let BunVendorRefusal { code, detail, .. } = refusal; + // Same failure telemetry as the vendor-step Err arm below: this run + // exits 1 without vendoring anything. + socket_patch_core::telemetry::track_patch_vendor_failed( + &detail, + args.common.dry_run, + telemetry_token, + telemetry_org, + ) + .await; + if args.common.json { + print_json(&serde_json::json!({ + "status": "error", + "found": 1, + "downloaded": 0, + "skipped": 0, + "failed": 1, + "error": { "code": code, "message": detail }, + "patches": [{ + "purl": patch.purl, + "uuid": patch.uuid, + "action": "failed", + "errorCode": code, + "error": detail, + }], + })); + } else { + eprintln!("Error ({code}): {detail}"); + } + return 1; + } + note_vendored_whole_manifest_scope(&manifest_path, &[patch.purl.as_str()], quiet).await; let action = match save_patch_record(args, patch, false, false).await { @@ -4157,8 +4305,16 @@ mod tests { assert!(pnpm_lock_resolves("left-pad@1.3.0:\n", "left-pad", "1.3.0")); // pos == 0, v5/v6 `/name/version` and `/name@version` spellings: the // leading `/` delimiter itself has nothing before it. - assert!(pnpm_lock_resolves("/left-pad/1.3.0:\n", "left-pad", "1.3.0")); - assert!(pnpm_lock_resolves("/left-pad@1.3.0:\n", "left-pad", "1.3.0")); + assert!(pnpm_lock_resolves( + "/left-pad/1.3.0:\n", + "left-pad", + "1.3.0" + )); + assert!(pnpm_lock_resolves( + "/left-pad@1.3.0:\n", + "left-pad", + "1.3.0" + )); // Still boundary-checked at the start of text: a scoped tail whose // name begins mid-token must NOT match. assert!(!pnpm_lock_resolves( @@ -4428,7 +4584,10 @@ mod tests { assert_eq!(code, 1, "guardrail failure must exit 1; json={json}"); assert_eq!(json["failed"], 1, "json={json}"); assert_eq!(json["downloaded"], 0, "json={json}"); - assert!(records.is_empty(), "no record may be handed to the vendor step"); + assert!( + records.is_empty(), + "no record may be handed to the vendor step" + ); assert_eq!(json["patches"][0]["action"], "failed", "json={json}"); assert_eq!( json["patches"][0]["error"], "patch has no applicable files", @@ -4502,9 +4661,10 @@ mod tests { .as_array() .unwrap_or_else(|| panic!("keep-all fallback must surface warnings; json={json}")); assert!( - warnings - .iter() - .any(|w| w.as_str().unwrap_or_default().contains("not installed locally")), + warnings.iter().any(|w| w + .as_str() + .unwrap_or_default() + .contains("not installed locally")), "warning must explain the keep-all fallback; json={json}" ); } @@ -4614,7 +4774,10 @@ mod tests { crate::commands::scan::ScanMode::Hosted, ) .await; - assert!(out.kept.is_empty(), "nothing may be kept via a corrupt ledger"); + assert!( + out.kept.is_empty(), + "nothing may be kept via a corrupt ledger" + ); assert_eq!(out.skip_records.len(), 1); assert_eq!(out.skip_records[0]["errorCode"], "package_not_installed"); } @@ -4774,7 +4937,11 @@ mod tests { ); assert!(records.is_empty()); assert!( - server.received_requests().await.unwrap_or_default().is_empty(), + server + .received_requests() + .await + .unwrap_or_default() + .is_empty(), "the failure must precede any fetch" ); assert_eq!( @@ -4826,7 +4993,10 @@ mod tests { json["patches"][0]["error"], "Blob decode or write failed", "json={json}" ); - assert!(records.is_empty(), "a blob failure must not hand back a record"); + assert!( + records.is_empty(), + "a blob failure must not hand back a record" + ); let blobs = tmp.path().join(".socket/blobs"); assert!(blobs.is_dir(), "the blobs dir itself was created"); assert_eq!( @@ -4856,7 +5026,9 @@ mod tests { let missing_purl = "pkg:npm/covgap-missing@1.0.0"; Mock::given(method("GET")) - .and(wm_path(format!("/v0/orgs/test-org/patches/view/{good_uuid}"))) + .and(wm_path(format!( + "/v0/orgs/test-org/patches/view/{good_uuid}" + ))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "uuid": good_uuid, "purl": good_purl, "publishedAt": "2024-01-01T00:00:00Z", @@ -4909,7 +5081,10 @@ mod tests { .iter() .filter_map(|p| p["error"].as_str()) .collect(); - assert!(errors.contains(&"patch has no applicable files"), "json={json}"); + assert!( + errors.contains(&"patch has no applicable files"), + "json={json}" + ); assert!(errors.contains(&"could not fetch details"), "json={json}"); } @@ -4977,11 +5152,251 @@ mod tests { "the ledger's embedded record must be reused" ); assert!( - server.received_requests().await.unwrap_or_default().is_empty(), + server + .received_requests() + .await + .unwrap_or_default() + .is_empty(), "an already-vendored entry must never touch the network" ); } + // --- download_patch_records: Bun preflight (detached parity) ----------- + // The detached download phase must refuse the same Bun projects the + // manifest-tracked one does, BEFORE any view fetch (request-log oracle), + // and with the vendor code (never the downstream `package_not_installed` + // the alias-shaped lockb project used to degrade to). + + /// A real bun 1.3.14 lockfileVersion-1 workspace lock (matrix capture + /// grammar): 1-tuple `workspace:` entry, blank line between entries, + /// trailing commas. + const BUN_V1_WORKSPACE_LOCK: &str = r#"{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "unit-fixture", + "dependencies": { + "consumer": "workspace:*", + }, + }, + "packages/consumer": { + "name": "consumer", + "version": "1.0.0", + "dependencies": { + "covgap-bun": "1.0.0", + }, + }, + }, + "packages": { + "consumer": ["consumer@workspace:packages/consumer"], + + "covgap-bun": ["covgap-bun@1.0.0", "", {}, "sha512-AAAA=="], + } +} +"#; + + #[tokio::test] + #[serial_test::serial] + async fn download_patch_records_bun_lockb_refuses_before_fetch() { + use wiremock::matchers::{method, path as wm_path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let server = MockServer::start().await; + let uuid = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; + let purl = "pkg:npm/covgap-bun@1.0.0"; + // A view that WOULD succeed — proves the refusal is decided before + // the fetch, not by a failed fetch. + Mock::given(method("GET")) + .and(wm_path(format!("/v0/orgs/test-org/patches/view/{uuid}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": uuid, "purl": purl, + "publishedAt": "2024-01-01T00:00:00Z", + "files": { "package/index.js": { + "beforeHash": "0".repeat(64), "afterHash": "1".repeat(64), + "blobContent": "cGF0Y2hlZAo=", + }}, + "vulnerabilities": {}, "description": "d", "license": "MIT", "tier": "free", + }))) + .mount(&server) + .await; + + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("bun.lockb"), b"\x00binary").unwrap(); + let selected = vec![mk_patch(uuid, purl, "free", "2024-01-01")]; + let (code, json, records) = + download_patch_records(&selected, &detached_params(tmp.path(), server.uri())).await; + + assert_eq!(code, 1, "json={json}"); + assert_eq!(json["found"], 1, "json={json}"); + assert_eq!(json["downloaded"], 0, "json={json}"); + assert_eq!(json["failed"], 1, "json={json}"); + assert_eq!(json["patches"][0]["action"], "failed", "json={json}"); + assert_eq!( + json["patches"][0]["errorCode"], "vendor_bun_lockb_unsupported", + "json={json}" + ); + assert!( + json["patches"][0]["error"] + .as_str() + .is_some_and(|d| !d.is_empty()), + "the record must carry the engine's detail; json={json}" + ); + assert!(records.is_empty(), "no record may reach the vendor step"); + assert!( + server + .received_requests() + .await + .unwrap_or_default() + .is_empty(), + "a refused Bun project must never fetch the patch view" + ); + } + + #[tokio::test] + #[serial_test::serial] + async fn download_patch_records_bun_v1_workspace_refuses_before_fetch() { + use wiremock::MockServer; + + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let server = MockServer::start().await; // trap: no mounts + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("bun.lock"), BUN_V1_WORKSPACE_LOCK).unwrap(); + let uuid = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"; + let purl = "pkg:npm/covgap-bun@1.0.0"; + let selected = vec![mk_patch(uuid, purl, "free", "2024-01-01")]; + + let (code, json, records) = + download_patch_records(&selected, &detached_params(tmp.path(), server.uri())).await; + + assert_eq!(code, 1, "json={json}"); + assert_eq!(json["failed"], 1, "json={json}"); + assert_eq!( + json["patches"][0]["errorCode"], "vendor_bun_workspace_unsupported", + "json={json}" + ); + assert!(records.is_empty()); + assert!( + server + .received_requests() + .await + .unwrap_or_default() + .is_empty(), + "refused before any fetch" + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join("bun.lock")).unwrap(), + BUN_V1_WORKSPACE_LOCK, + "the preflight is read-only" + ); + } + + /// The preflight is npm-only: a non-npm purl on a Bun-refused tree is + /// fetched as usual (here: the view is unmounted, so it fails as a fetch + /// miss — proving it reached the network, not the refusal). + #[tokio::test] + #[serial_test::serial] + async fn download_patch_records_bun_refusal_skips_non_npm_purls() { + use wiremock::MockServer; + + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let server = MockServer::start().await; + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("bun.lockb"), b"\x00binary").unwrap(); + let uuid = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"; + let purl = "pkg:pypi/covgap-not-bun@1.0.0"; + let selected = vec![mk_patch(uuid, purl, "free", "2024-01-01")]; + + let (code, json, _) = + download_patch_records(&selected, &detached_params(tmp.path(), server.uri())).await; + + assert_eq!(code, 1, "json={json}"); + assert_eq!( + json["patches"][0]["error"], "could not fetch details", + "a pypi purl must reach the fetch, not the Bun refusal; json={json}" + ); + assert!(json["patches"][0].get("errorCode").is_none(), "json={json}"); + assert_eq!( + server.received_requests().await.unwrap_or_default().len(), + 1, + "exactly the view fetch" + ); + } + + /// Ledger entries at either the selected or an older UUID must not + /// bypass the refusal when the live lock contains registry wiring. + #[tokio::test] + #[serial_test::serial] + async fn download_patch_records_bun_refusal_rejects_unwired_ledger_entries() { + use wiremock::MockServer; + + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let server = MockServer::start().await; + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("bun.lock"), BUN_V1_WORKSPACE_LOCK).unwrap(); + let same = "ffffffff-ffff-4fff-8fff-ffffffffffff"; + let older = "abababab-abab-4bab-8bab-abababababab"; + let newer = "cdcdcdcd-cdcd-4dcd-8dcd-cdcdcdcdcdcd"; + let in_sync = "pkg:npm/covgap-bun@1.0.0"; + let stale = "pkg:npm/covgap-bun-stale@1.0.0"; + // Two ledger entries: one in sync with the selection, one stale. + let vendor = tmp.path().join(".socket/vendor"); + std::fs::create_dir_all(&vendor).unwrap(); + let entry = |purl: &str, uuid: &str| { + serde_json::json!({ + "ecosystem": "npm", "basePurl": purl, "uuid": uuid, + "artifact": { "path": format!(".socket/vendor/npm/{uuid}/x.tgz") }, + "wiring": [], "flavor": "bun", + }) + }; + std::fs::write( + vendor.join("state.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "version": 1, + "entries": { in_sync: entry(in_sync, same), stale: entry(stale, older) }, + })) + .unwrap(), + ) + .unwrap(); + + let selected = vec![ + mk_patch(same, in_sync, "free", "2024-01-01"), + mk_patch(newer, stale, "free", "2024-01-01"), + ]; + let (code, json, _) = + download_patch_records(&selected, &detached_params(tmp.path(), server.uri())).await; + + assert_eq!(code, 1, "json={json}"); + let by_purl = |purl: &str| { + json["patches"] + .as_array() + .unwrap() + .iter() + .find(|p| p["purl"] == purl) + .cloned() + .unwrap_or_else(|| panic!("no record for {purl}: {json}")) + }; + let refused_same = by_purl(in_sync); + assert_eq!( + refused_same["errorCode"], "vendor_bun_workspace_unsupported", + "UUID equality alone cannot bypass the refusal; json={json}" + ); + let refused = by_purl(stale); + assert_eq!( + refused["errorCode"], "vendor_bun_workspace_unsupported", + "a stale-uuid entry is refused like a fresh vendoring; json={json}" + ); + let paths: Vec = server + .received_requests() + .await + .unwrap_or_default() + .iter() + .map(|r| r.url.path().to_string()) + .collect(); + assert!(paths.is_empty(), "no refused purl may fetch: {paths:?}"); + } + /// An unreadable vendor ledger silences the drift warning (the main /// vendor path reports unreadable state itself) instead of panicking or /// fabricating a warning. diff --git a/crates/socket-patch-cli/src/commands/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs index d2502b71..b969dba1 100644 --- a/crates/socket-patch-cli/src/commands/mod.rs +++ b/crates/socket-patch-cli/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod apply; +pub(crate) mod bun_preflight; pub(crate) mod fetch_stage; pub mod get; pub mod list; diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index a58cbe79..9ae88d37 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -644,9 +644,13 @@ pub(crate) async fn run_hosted_leg( }; let mut out = HostedLegOutcome::default(); - // bun.lock edits hard-refuse the per-purl npm revert; when the replay - // will run it owns them instead, so npm purls on bun projects defer - // rather than fail. + // When the whole-ledger replay will run anyway (the scope covers every + // record), npm purls on bun projects defer to it: the replay stages the + // bun group all-or-nothing together with the rideshare `bun.lockb` + // migration marker the per-purl revert never claims. A SCOPED unwind of + // one of several bun records takes the per-purl revert instead, which + // claims that purl's `redirect_bun_lock_package` edits by the recorded + // line's spec and replays them like the yarn/pnpm text kinds. let has_bun_edits = state .edits .iter() diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 03d6b768..81b63c14 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -20,6 +20,145 @@ mod python; /// of appending; see the ledger merge below. const REBASE_KINDS: &[&str] = &["redirect_poetry_lock_package", "redirect_pdm_lock_package"]; +/// Largest `bun.lockb` (raw bytes) whose pre-migration content the redirect +/// ledger captures for `rollback`. The ledger is a JSON document read whole +/// on every hosted/rollback run, and standard base64 grows the payload by a +/// third, so a lock above this cap is recorded WITHOUT its bytes (today's +/// `redirect_bun_lockb_unrestorable` path applies). Real bun.lockb files are +/// tens of KiB to low MiB; 8 MiB is far outside anything measured. +const LOCKB_ORIGINAL_CAP: usize = 8 * 1024 * 1024; + +/// The exact `bun install` flag set the lockb→text migration spawns: no +/// network, fails closed on drift, writes the text lock without touching +/// node_modules. Its per-release behaviour is documented at the call site. +const BUN_MIGRATION_ARGS: [&str; 4] = [ + "install", + "--save-text-lockfile", + "--frozen-lockfile", + "--lockfile-only", +]; + +/// How the spawned `bun install …` migration ended. +enum LockbMigration { + /// exit 0 and a text `bun.lock` now exists (bun.lockb may or may not: + /// 1.1.43–1.1.45 keep it, ≥ 1.2 delete it — the caller normalizes). + Migrated, + /// exit 0 but NO `bun.lock` was written: bun 1.1.39 accepts the flags + /// and saves nothing under `--frozen-lockfile` (bare + /// `bun install --save-text-lockfile` does write there); bun ≤ 1.1.38 has + /// no text lockfile at all. Neither is "bun failed or is unavailable". + NoLockWritten, + /// bun is not on PATH, could not be spawned, or exited non-zero — with + /// the human-readable reason (bun's own output tail included). + Failed(String), +} + +/// Spawn the lockb→text migration in `cwd`. `bun` is resolved through the +/// shared PATH resolver (absolute entries only, PATHEXT on Windows) and the +/// RESOLVED path is spawned: a bare `Command::new("bun")` would run a `bun` +/// planted in the scanned repository via a relative PATH entry (the child's +/// cwd IS the repository), and on Windows would never find the npm-global +/// `bun.cmd` shim. +fn migrate_bun_lockb(cwd: &Path) -> LockbMigration { + let Some(mut command) = socket_patch_core::utils::process::tool_command("bun") else { + return LockbMigration::Failed("bun not found on PATH".into()); + }; + // `.output()` (not `.status()`): bun's install chatter must not + // interleave with the machine `--json` envelope on stdout. + match command.args(BUN_MIGRATION_ARGS).current_dir(cwd).output() { + Err(e) => LockbMigration::Failed(format!("bun could not be spawned: {e}")), + Ok(output) if !output.status.success() => { + let tail = output_tail(&output, 10); + LockbMigration::Failed(if tail.is_empty() { + format!("bun exited with {}", output.status) + } else { + format!("bun exited with {}; bun output: {tail}", output.status) + }) + } + Ok(_) if cwd.join("bun.lock").exists() => LockbMigration::Migrated, + Ok(_) => LockbMigration::NoLockWritten, + } +} + +/// The last `max_lines` non-empty lines of a child's stderr and stdout +/// (stderr first — bun's errors go there), each capped to 200 chars, joined +/// with ` | ` so the tail fits one warning line in human output and CI logs. +fn output_tail(output: &std::process::Output, max_lines: usize) -> String { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec = stderr + .lines() + .chain(stdout.lines()) + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(|line| { + let mut line = line.to_string(); + if line.chars().count() > 200 { + line = line.chars().take(200).collect::() + "…"; + } + line + }) + .collect(); + let skip = lines.len().saturating_sub(max_lines); + lines[skip..].join(" | ") +} + +/// The ledger payload for a migrated bun.lockb's pre-migration bytes: the +/// STANDARD base64 (RFC 4648 §4, padded) of the raw file as a JSON string in +/// `FileEdit.original` — the ledger is JSON, so binary content cannot ride it +/// raw — or `None` above [`LOCKB_ORIGINAL_CAP`] (and when the read failed), +/// in which case `rollback` cannot restore the binary lock and says so. The +/// replay decodes with the same alphabet (`replay.rs`, `BunLockbMigrated`). +fn lockb_original_payload(bytes: Option<&[u8]>) -> Option { + use base64::Engine as _; + bytes + .filter(|bytes| bytes.len() <= LOCKB_ORIGINAL_CAP) + .map(|bytes| { + serde_json::Value::String(base64::engine::general_purpose::STANDARD.encode(bytes)) + }) +} + +/// Recognised lockfiles that, beside a `bun.lockb` and no `bun.lock`, mark +/// the binary lock as probable debris of a migration AWAY from bun — the +/// same names the vendored router knows, in its precedence order once the +/// lockb is gone. Migrating the lockb there would turn an npm/yarn/pnpm +/// project into a bun.lock project as a side effect, so the driver leaves +/// it alone and the redirect follows the sibling instead. +const LOCKB_SIBLING_LOCKS: [&str; 4] = [ + "pnpm-lock.yaml", + "yarn.lock", + "npm-shrinkwrap.json", + "package-lock.json", +]; + +/// The `redirect_bun_lockb_unsupported` detail for a `bun.lockb` that is not +/// a regular file (FIFO, socket, directory): refused BEFORE any bun spawn, +/// because bun's own open of the lock would block on the same FIFO. +const LOCKB_NOT_REGULAR_DETAIL: &str = "bun.lockb is not a regular file; refusing to migrate it"; + +/// The [`LOCKB_SIBLING_LOCKS`] present in `cwd` as regular files (a lock +/// the redirect cannot read is not one it follows), in probe order. +fn present_lockb_sibling_locks(cwd: &Path) -> Vec<&'static str> { + LOCKB_SIBLING_LOCKS + .iter() + .copied() + .filter(|name| cwd.join(name).is_file()) + .collect() +} + +/// The `redirect_bun_lockb_sibling_lock` detail: names every sibling lock +/// present and both remedies (delete the debris, or remove the sibling and +/// re-run when bun really is the installer). +fn lockb_sibling_lock_detail(siblings: &[&str]) -> String { + let list = siblings.join(", "); + let verb = if siblings.len() == 1 { "is" } else { "are" }; + format!( + "bun.lockb was left alone because {list} {verb} also present; the redirect follows \ + {list} — delete the stale bun.lockb if it is debris, or remove {list} and re-run if \ + bun is the installer" + ) +} + const REDIRECT_CANDIDATE_FILES: &[&str] = &[ "package-lock.json", "npm-shrinkwrap.json", @@ -628,7 +767,8 @@ async fn gem_stale_install_warnings( }); if verify_patch_record(&pkg.path, record).await.is_ok() { entry.patched = true; - } else if !entry.positive && installed_stale_positive_evidence(&pkg.path, record).await { + } else if !entry.positive && installed_stale_positive_evidence(&pkg.path, record).await + { entry.positive = true; entry.purl = (*purl).to_string(); } @@ -995,6 +1135,31 @@ pub(crate) async fn run_redirect_selected( .map(|l| l.records.clone()) .unwrap_or_default(); + // The migration unlinks its input, so check links before any takeover + // can mutate another dependency in this run as well as before Bun runs. + if overrides.iter().any(|o| o.ecosystem == "npm") + && !common.cwd.join("bun.lock").exists() + && present_lockb_sibling_locks(&common.cwd).is_empty() + && socket_patch_core::utils::fs::first_symlink(&common.cwd, ["bun.lockb"]) + .await + .is_some() + { + // Neither Bun's migration nor our byte-only backup can restore + // a link. Refuse before spawning Bun, including during preview. + let message = "bun.lockb is a symbolic link; replace it with a regular file (or run \ + socket-patch in the directory it points to) before migrating; nothing \ + was written"; + eprintln!("Error (redirect_symlinked_file_unsupported): {message}"); + if common.json { + emit_json_error_with_code( + scan_result.take(), + Some("redirect_symlinked_file_unsupported"), + message, + ); + } + return 1; + } + // Cross-mode takeover: a purl this run is about to redirect may still be // VENDORED — for cargo a committed `[patch.crates-io]` path entry, a // detached Cargo.lock entry, a committed copy, and a vendored ledger @@ -1029,6 +1194,25 @@ pub(crate) async fn run_redirect_selected( use socket_patch_core::utils::purl::{normalize_purl, strip_purl_qualifiers}; let canon = |p: &str| normalize_purl(strip_purl_qualifiers(p)).into_owned(); let vendor_state = socket_patch_core::vendor::load_state(&common.cwd).await; + // Compatibility must be known before the takeover removes a live + // patch. In particular, a v0 workspace can keep an existing local + // tuple even though hosted mode cannot replace it with a URL. + let bun_takeover_refusal = if candidates.iter().any(|(p, ..)| p.starts_with("pkg:npm/")) { + match socket_patch_core::utils::fs::read_regular_to_string(&common.cwd.join("bun.lock")) + .await + { + Ok(content) => { + socket_patch_core::patch::redirect::preflight_bun_hosted(&content).err() + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => Some(socket_patch_core::patch::redirect::RewriteWarning { + code: "redirect_bun_lock_unsupported".into(), + detail: format!("cannot read bun.lock before mode takeover: {e}"), + }), + } + } else { + None + }; let patch_entries = socket_patch_core::vendor::cargo_config::read_patch_entries(&common.cwd).await; let mut refused: Vec = Vec::new(); @@ -1043,6 +1227,19 @@ pub(crate) async fn run_redirect_selected( .and_then(|s| socket_patch_core::vendor::lookup_entry(&s.entries, stripped)) .cloned(); if let Some(entry) = ledger_entry { + if let Some(warning) = bun_takeover_refusal + .as_ref() + .filter(|_| purl.starts_with("pkg:npm/")) + { + refused.push(purl.clone()); + if !takeover_pre_warnings + .iter() + .any(|w| w["code"] == warning.code) + { + takeover_pre_warnings.push(serde_json::json!(warning)); + } + continue; + } if common.dry_run { // Preview through the same per-purl revert machinery the // wet run dispatches (write-free under dry_run): a @@ -1174,8 +1371,12 @@ pub(crate) async fn run_redirect_selected( if !refused.is_empty() { for purl in &refused { if let Some((_, uuid, ..)) = candidates.iter().find(|(p, ..)| p == purl) { + let reason = bun_takeover_refusal + .as_ref() + .filter(|_| purl.starts_with("pkg:npm/")) + .map_or("vendored_revert_failed", |w| w.code.as_str()); skipped.push(serde_json::json!({ - "purl": purl, "uuid": uuid, "reason": "vendored_revert_failed", + "purl": purl, "uuid": uuid, "reason": reason, })); } } @@ -1209,12 +1410,38 @@ pub(crate) async fn run_redirect_selected( // bun.lockb auto-migration: the redirect rewriter only edits the TEXT // lockfile, so a project locked to a binary `bun.lockb` must be re-locked // to `bun.lock` first. `bun install --save-text-lockfile --frozen-lockfile - // --lockfile-only` writes bun.lock, DELETES bun.lockb, needs no network, - // and fails closed on drift. Dry-run only warns; a failure degrades to the - // rewriter's own presence-only refusal (the .lockb stays a candidate file). + // --lockfile-only` needs no network and fails closed on drift, but what it + // leaves behind depends on the installed Bun (measured against real + // releases): + // • ≤ 1.1.38: no text lockfile exists — nothing usable is written; + // • 1.1.39: the flags are accepted, exit 0, and NO bun.lock is written + // (`--frozen-lockfile` suppresses the text-lock save there); + // • 1.1.43–1.1.45: bun.lock is written and bun.lockb is KEPT; + // • ≥ 1.2.0: bun.lock is written and bun.lockb is DELETED. + // Every success is normalized to the ≥ 1.2 shape — a kept bun.lockb is + // removed here — so the ledger's `removed` record is always TRUE, and the + // pre-migration bytes are captured (base64, capped) so `rollback` can put + // the binary lock back. Dry-run only warns; the exit-0-but-no-lock shape + // gets its own code naming the manual command; a missing/unspawnable bun + // or a non-zero exit degrades to `redirect_bun_lockb_unsupported` carrying + // bun's output tail (the .lockb stays in place, never parsed). // Gated on an npm-ecosystem override: the migration exists solely so the // bun rewriter has a text lock to edit — with nothing to redirect it would // re-lock (and delete) the user's lockfile as a side effect of a no-op run. + // Two more gates run BEFORE any bun spawn, both fail-closed: + // • a recognised sibling lock (package-lock.json, npm-shrinkwrap.json, + // yarn.lock, pnpm-lock.yaml) beside the bun.lockb: the project most + // likely migrated AWAY from bun and left the binary lock as debris, + // and re-locking it would convert an npm/yarn/pnpm project into a + // bun.lock project as a side effect. The lockb is left alone with + // `redirect_bun_lockb_sibling_lock` naming both files and both + // remedies; the redirect follows the sibling lock, rewritten as + // today. Dry-run reports the same code instead of the preview. + // • the pre-migration bytes are read through the FIFO-safe opener: a + // FIFO (socket, directory) squatting `bun.lockb` passes `exists()` + // but wedges a plain open(2) forever — and bun itself blocks on the + // same FIFO — so a non-regular file refuses with + // `redirect_bun_lockb_unsupported` and bun is never spawned. let mut migration_warnings: Vec = Vec::new(); let mut migration_edits: Vec = Vec::new(); // The pre-migration bun.lockb bytes, held so the migration can be undone @@ -1223,55 +1450,132 @@ pub(crate) async fn run_redirect_selected( // refused) must not permanently convert the user's lockfile format as a // side effect of a zero-redirect run. let mut lockb_backup: Option> = None; + // True once a migration LANDED (bun.lock written, bun.lockb gone). The + // zero-redirect unwind keys off this flag and the in-memory bytes, never + // off the ledger record's `original` — which is deliberately absent for + // an oversize or unreadable lock. + let mut lockb_migrated = false; let has_lockb = common.cwd.join("bun.lockb").exists(); let has_bun_lock = common.cwd.join("bun.lock").exists(); let has_npm_override = overrides.iter().any(|o| o.ecosystem == "npm"); if has_lockb && !has_bun_lock && has_npm_override { - if common.dry_run { + let lockb_path = common.cwd.join("bun.lockb"); + let siblings = present_lockb_sibling_locks(&common.cwd); + if !siblings.is_empty() { migration_warnings.push(serde_json::json!({ - "code": "redirect_bun_lockb_would_migrate", - "detail": "bun.lockb would be migrated to a text bun.lock \ - (`bun install --save-text-lockfile`) before redirecting; \ - re-run without --dry-run to apply", + "code": "redirect_bun_lockb_sibling_lock", + "detail": lockb_sibling_lock_detail(&siblings), })); - } else { - // Read the binary lock BEFORE bun deletes it, so a zero-rewrite - // run can restore it below. - let lockb_bytes = std::fs::read(common.cwd.join("bun.lockb")).ok(); - // `.output()` (not `.status()`): bun's install chatter must not - // interleave with the machine `--json` envelope on stdout. - let output = std::process::Command::new("bun") - .args([ - "install", - "--save-text-lockfile", - "--frozen-lockfile", - "--lockfile-only", - ]) - .current_dir(&common.cwd) - .output(); - let migrated = matches!(output, Ok(o) if o.status.success()) - && common.cwd.join("bun.lock").exists(); - if migrated { - lockb_backup = lockb_bytes; - // bun deleted bun.lockb itself. Record the removal so `--revert` - // knows the file was replaced (binary — git history is the - // restore path, so no `original` bytes are captured). - migration_edits.push(socket_patch_core::patch::redirect::FileEdit { - path: "bun.lockb".into(), - kind: "redirect_bun_lockb_migrated".into(), - action: "removed".into(), - key: None, - original: None, - new: None, - }); + } else if common.dry_run { + // stat, never open: a FIFO would wedge the open, and the preview + // must predict the refusal the wet run makes. + if std::fs::metadata(&lockb_path).is_ok_and(|m| m.is_file()) { + migration_warnings.push(serde_json::json!({ + "code": "redirect_bun_lockb_would_migrate", + "detail": "bun.lockb would be migrated to a text bun.lock \ + (`bun install --save-text-lockfile`) before redirecting; \ + re-run without --dry-run to apply", + })); } else { migration_warnings.push(serde_json::json!({ "code": "redirect_bun_lockb_unsupported", - "detail": "bun.lockb could not be migrated to a text bun.lock \ - (`bun install --save-text-lockfile` failed or is unavailable); \ - the redirect cannot pin a binary lockfile", + "detail": LOCKB_NOT_REGULAR_DETAIL, })); } + } else { + // Read the binary lock BEFORE the migration replaces it: the + // zero-rewrite unwind and the ledger's restore payload both need + // the original bytes. Through the FIFO-safe opener (non-blocking + // open + fstat regular-file check): a FIFO squatting the path + // wedged this read — and would wedge bun's own open — forever. + // Only the non-regular kind refuses; any other read error + // (PermissionDenied) keeps today's contract: the migration + // proceeds and the lock is recorded without restorable bytes. + let lockb_read = socket_patch_core::utils::fs::read_regular_to_bytes_sync(&lockb_path); + if lockb_read + .as_ref() + .is_err_and(|e| e.kind() == std::io::ErrorKind::InvalidInput) + { + migration_warnings.push(serde_json::json!({ + "code": "redirect_bun_lockb_unsupported", + "detail": LOCKB_NOT_REGULAR_DETAIL, + })); + } else { + let lockb_bytes = lockb_read.ok(); + match migrate_bun_lockb(&common.cwd) { + LockbMigration::Migrated => { + // bun 1.1.43–1.1.45 leave bun.lockb beside the new text + // lock; bun ≥ 1.2 deletes it. Remove a kept one ourselves + // so the project is text-only on every release and the + // `removed` record below is true (a stale binary lock + // beside the redirected text lock is also what bun + // ≤ 1.1.38 would silently install the UNPATCHED bytes + // from). NotFound is the ≥ 1.2 case — already gone. + let removal = match std::fs::remove_file(&lockb_path) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e), + _ => Ok(()), + }; + match removal { + Ok(()) => { + lockb_migrated = true; + lockb_backup = lockb_bytes; + // Record the removal so `rollback` knows the file + // was replaced, carrying the pre-migration bytes + // (base64, capped) it needs to put it back. + let original = lockb_original_payload(lockb_backup.as_deref()); + migration_edits.push( + socket_patch_core::patch::redirect::FileEdit { + path: "bun.lockb".into(), + kind: "redirect_bun_lockb_migrated".into(), + action: "removed".into(), + key: None, + original, + new: None, + }, + ); + } + Err(e) => { + // Fail closed: a bun.lockb we cannot remove would + // make the `removed` record a lie and leave two + // lockfiles behind. Undo the migration (drop the + // text lock bun just wrote — bun.lockb is intact) + // and refuse exactly like a failed spawn. + let _ = std::fs::remove_file(common.cwd.join("bun.lock")); + migration_warnings.push(serde_json::json!({ + "code": "redirect_bun_lockb_unsupported", + "detail": format!( + "bun.lockb could not be migrated to a text bun.lock (bun \ + wrote bun.lock but the binary lock could not be removed: \ + {e}); the redirect cannot pin a binary lockfile" + ), + })); + } + } + } + LockbMigration::NoLockWritten => { + migration_warnings.push(serde_json::json!({ + "code": "redirect_bun_lockb_manual_migration", + "detail": "bun.lockb was not migrated: the installed Bun accepted \ + `bun install --save-text-lockfile --frozen-lockfile \ + --lockfile-only` (exit 0) but wrote no text bun.lock — Bun \ + 1.1.39 behaves this way, and Bun <= 1.1.38 has no text \ + lockfile at all and must be upgraded. Run `bun install \ + --save-text-lockfile` yourself (Bun >= 1.1.39), then re-run; \ + the redirect cannot pin a binary lockfile", + })); + } + LockbMigration::Failed(why) => { + migration_warnings.push(serde_json::json!({ + "code": "redirect_bun_lockb_unsupported", + "detail": format!( + "bun.lockb could not be migrated to a text bun.lock (`bun install \ + --save-text-lockfile --frozen-lockfile --lockfile-only` failed: \ + {why}); the redirect cannot pin a binary lockfile" + ), + })); + } + } + } } } @@ -1296,8 +1600,7 @@ pub(crate) async fn run_redirect_selected( .strip_suffix(".py.lock") .map(|prefix| format!("{prefix}.py")) { - if let Ok(content) = read_regular_to_string(&common.cwd.join(&script_path)).await - { + if let Ok(content) = read_regular_to_string(&common.cwd.join(&script_path)).await { files.insert(script_path, content); } } @@ -1421,6 +1724,19 @@ pub(crate) async fn run_redirect_selected( pipenv_major, ); + // A bun.lockb-only project is a Bun project: when no text lock was + // produced (migration refused / manual / dry-run) the npm rewriter's + // "no package-lock.json" warning is noise beside the bun.lockb diagnosis + // that says what is actually wrong. The core's own sibling-lock gate + // would suppress it on a `bun.lockb` candidate key, but the driver never + // reads the binary lock into `files` (never parse a .lockb), so the + // suppression lives here. + if has_lockb && !files.contains_key("bun.lock") { + rewrite + .warnings + .retain(|w| w.code != "redirect_npm_no_lockfile"); + } + // Unknown installer → the modern `file` shape was chosen; say so only // when the lock was (or, on --dry-run, would be) rewritten. if targets_pipenv_lock && pipenv_major.is_none() && rewrite.files.contains_key("Pipfile.lock") { @@ -1439,7 +1755,9 @@ pub(crate) async fn run_redirect_selected( // remove the generated text lock, and drop the ledger removal record so // the no-op run leaves the lockfile format untouched. The rewriter's own // warning (entry-not-found / unsupported) explains WHY nothing landed. - if !migration_edits.is_empty() && !rewrite.files.contains_key("bun.lock") { + // Keyed on the in-memory flag + bytes, not on the ledger record's + // `original` (absent for an oversize lock). + if lockb_migrated && !rewrite.files.contains_key("bun.lock") { let restored = lockb_backup .as_deref() .is_some_and(|bytes| std::fs::write(common.cwd.join("bun.lockb"), bytes).is_ok()); @@ -1918,7 +2236,11 @@ pub(crate) async fn run_redirect_selected( // invertible link: replay swaps the fragment this run wrote back to // the fragment the very first run found. let mut rebased: Vec = Vec::new(); - for edit in rewrite.edits.iter().filter(|e| REBASE_KINDS.contains(&e.kind.as_str())) { + for edit in rewrite + .edits + .iter() + .filter(|e| REBASE_KINDS.contains(&e.kind.as_str())) + { let siblings: Vec = ledger .edits .iter() @@ -1972,7 +2294,10 @@ pub(crate) async fn run_redirect_selected( let is_rebased = REBASE_KINDS.contains(&edit.kind.as_str()) && rebased.iter().any(|&t| { let old = &ledger.edits[t]; - old.path == edit.path && old.kind == edit.kind && old.key == edit.key && old.new == edit.new + old.path == edit.path + && old.kind == edit.kind + && old.key == edit.key + && old.new == edit.new }); if !is_rebased && !ledger.edits.contains(edit) { ledger.edits.push(edit.clone()); @@ -2307,11 +2632,13 @@ pub(crate) fn boxed_run_redirect_selected<'a>( mod tests { use super::{ build_redirect_json_envelope, gem_stale_cache_warning, gem_stale_install_warning, - gem_stale_install_warnings, installed_stale_positive_evidence, parse_purl_simple, - plan_workspace_trust, pnpm_heal_root, pnpm_lock_carries_hosted_redirect, - pnpm_lock_version_major, pnpm_trust_configured_detail, pnpm_trust_legacy_detail, - pnpm_trust_manual_guidance, pnpm_trust_workspace_unreadable_detail, - read_workspace_for_trust, TrustPlan, REDIRECT_CANDIDATE_FILES, + gem_stale_install_warnings, installed_stale_positive_evidence, lockb_original_payload, + lockb_sibling_lock_detail, output_tail, parse_purl_simple, plan_workspace_trust, + pnpm_heal_root, pnpm_lock_carries_hosted_redirect, pnpm_lock_version_major, + pnpm_trust_configured_detail, pnpm_trust_legacy_detail, pnpm_trust_manual_guidance, + pnpm_trust_workspace_unreadable_detail, present_lockb_sibling_locks, + read_workspace_for_trust, TrustPlan, LOCKB_NOT_REGULAR_DETAIL, LOCKB_ORIGINAL_CAP, + LOCKB_SIBLING_LOCKS, REDIRECT_CANDIDATE_FILES, }; use socket_patch_core::constants::npm_family; use socket_patch_core::patch::redirect::DepOverride; @@ -3446,6 +3773,139 @@ mod tests { ); } + /// The ledger payload is STANDARD (padded) base64 of the raw bytes — the + /// alphabet the replay decodes — up to the cap; above it, and for a + /// failed read, nothing is recorded. + #[test] + fn lockb_original_payload_is_standard_base64_up_to_the_cap() { + assert_eq!( + lockb_original_payload(Some(b"\x00BUN\xff")), + Some(serde_json::Value::String("AEJVTv8=".into())) + ); + assert_eq!( + lockb_original_payload(Some(b"")), + Some(serde_json::Value::String(String::new())), + "an empty lock is still captured (restorable)" + ); + assert_eq!( + lockb_original_payload(None), + None, + "unreadable → nothing recorded" + ); + let at_cap = vec![0u8; LOCKB_ORIGINAL_CAP]; + assert!( + lockb_original_payload(Some(&at_cap)).is_some(), + "the cap is inclusive" + ); + let over = vec![0u8; LOCKB_ORIGINAL_CAP + 1]; + assert_eq!( + lockb_original_payload(Some(&over)), + None, + "one byte over → None" + ); + } + + /// The sibling-lock probe reports every recognised lock present as a + /// REGULAR file, in the router's precedence order, and nothing for a + /// lockb-only project; `bun.lock` itself is not a sibling (the gate that + /// reaches the probe already requires its absence) and a directory or + /// FIFO squatting a sibling name is not a lock the redirect follows. + #[test] + fn present_lockb_sibling_locks_reports_regular_files_in_router_order() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("bun.lockb"), b"\x00BUN").unwrap(); + assert!(present_lockb_sibling_locks(tmp.path()).is_empty()); + std::fs::write(tmp.path().join("package-lock.json"), "{}").unwrap(); + assert_eq!( + present_lockb_sibling_locks(tmp.path()), + vec!["package-lock.json"] + ); + std::fs::write( + tmp.path().join("pnpm-lock.yaml"), + "lockfileVersion: '9.0'\n", + ) + .unwrap(); + std::fs::create_dir(tmp.path().join("yarn.lock")).unwrap(); + assert_eq!( + present_lockb_sibling_locks(tmp.path()), + vec!["pnpm-lock.yaml", "package-lock.json"], + "pnpm first (router precedence), the yarn.lock DIRECTORY skipped" + ); + assert!( + !LOCKB_SIBLING_LOCKS.contains(&"bun.lock") + && !LOCKB_SIBLING_LOCKS.contains(&"bun.lockb") + ); + for name in LOCKB_SIBLING_LOCKS { + assert!( + REDIRECT_CANDIDATE_FILES.contains(&name), + "{name} must be a lock the hosted rewriters actually read" + ); + } + } + + /// The sibling warning names the sibling(s) twice — as the reason and as + /// what the redirect follows — and both remedies, agreeing in number. + #[test] + fn lockb_sibling_lock_detail_names_the_sibling_and_both_remedies() { + let one = lockb_sibling_lock_detail(&["package-lock.json"]); + assert_eq!( + one, + "bun.lockb was left alone because package-lock.json is also present; the redirect \ + follows package-lock.json — delete the stale bun.lockb if it is debris, or remove \ + package-lock.json and re-run if bun is the installer" + ); + let two = lockb_sibling_lock_detail(&["pnpm-lock.yaml", "yarn.lock"]); + assert!( + two.starts_with( + "bun.lockb was left alone because pnpm-lock.yaml, yarn.lock are also present" + ) && two.contains("remove pnpm-lock.yaml, yarn.lock and re-run"), + "{two}" + ); + assert_eq!( + LOCKB_NOT_REGULAR_DETAIL, + "bun.lockb is not a regular file; refusing to migrate it" + ); + } + + /// The failure detail carries bun's own last lines: stderr first, blank + /// lines dropped, trimmed, capped to the newest `max_lines`, one line. + #[test] + fn output_tail_keeps_the_last_lines_stderr_first() { + let output = std::process::Output { + status: std::process::ExitStatus::default(), + stdout: b"bun install v1.1.39\n\n Checked 1 install (no changes) \n".to_vec(), + stderr: b"error: lockfile had changes, but lockfile is frozen\n".to_vec(), + }; + assert_eq!( + output_tail(&output, 10), + "error: lockfile had changes, but lockfile is frozen | bun install v1.1.39 | \ + Checked 1 install (no changes)" + ); + assert_eq!(output_tail(&output, 1), "Checked 1 install (no changes)"); + let empty = std::process::Output { + status: std::process::ExitStatus::default(), + stdout: b"\n \n".to_vec(), + stderr: Vec::new(), + }; + assert_eq!( + output_tail(&empty, 10), + "", + "whitespace-only output is no tail" + ); + let long = std::process::Output { + status: std::process::ExitStatus::default(), + stdout: Vec::new(), + stderr: "x".repeat(500).into_bytes(), + }; + let tail = output_tail(&long, 10); + assert_eq!( + tail.chars().count(), + 201, + "each line is capped at 200 chars + ellipsis" + ); + assert!(tail.ends_with('…')); + } + #[test] fn redirect_candidates_match_the_shared_npm_family_table() { // Drift guard, both directions, without classifying the non-npm diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index aa48ef6c..882ec650 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -47,7 +47,9 @@ pub(crate) use self::discovery::unsupported_layout_warnings; use self::gc::{gc_json, print_gc_vendored_line, run_apply_gc}; pub(crate) use self::hosted::boxed_run_redirect_selected; use self::hosted::run_redirect; -pub(crate) use self::vendor_flow::{boxed_scan_vendor_step, preview_vendor_json}; +pub(crate) use self::vendor_flow::{ + boxed_scan_vendor_step, preview_vendor_json, print_dry_run_refusals, +}; use self::vendor_flow::{ boxed_vendor_interactive_path, boxed_vendor_json_path, fold_vendored_skips_into_apply, partition_skipped_selected, @@ -1487,10 +1489,13 @@ pub async fn run(mut args: ScanArgs) -> i32 { // are flagged "not yet installed" everywhere a user could act on them. let lockfile_only = lockfile_supplement(&args.common, &all_crawled).await; // Explicit refusals for npm layouts whose packages are structurally - // unreachable (yarn PnP, pnpm node-linker=pnp). Under yarn PnP the - // crawler leg above is ALSO empty (no `node_modules/`), so without this - // channel every mode used to print a clean success with - // `scannedPackages: 0` — a silent no-op the user read as "protected". + // unreachable (yarn PnP, pnpm node-linker=pnp) — and for a bun project + // whose only lock is the legacy binary `bun.lockb` + // (`bun_lockb_unsupported`: the inventory cannot read it). Under yarn + // PnP the crawler leg above is ALSO empty (no `node_modules/`), as it + // is on a fresh clone of a bun.lockb project, so without this channel + // every mode used to print a clean success with `scannedPackages: 0` — + // a silent no-op the user read as "protected". // Surfaced as run-level `warnings[]` in the JSON envelope (omitted when // empty) and a stderr line on the human path; exit code and `status` // stay deliberately unchanged (same posture as hosted refusals, which @@ -1736,6 +1741,23 @@ pub async fn run(mut args: ScanArgs) -> i32 { return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; } + // The bun.lockb discovery diagnosis (`bun_lockb_unsupported`) stays in + // `layout_refusals` in EVERY mode on this non-empty path too — hosted + // included. It reports a fact about THIS run's discovery (the binary + // lock was never read, so its lockfile-only packages are invisible), + // and nothing here can tell whether the hosted driver about to run + // will say anything about the file: the driver speaks only when an npm + // override is actually granted (`redirect_bun_lockb_*` on + // `redirect.warnings`, or a `redirect_bun_lockb_migrated` edit), a + // network-dependent outcome decided inside `run_redirect`, which owns + // the envelope from here on. An earlier version dropped the warning on + // every non-empty hosted run — so a polyglot project or an + // installed-but-unpatched npm tree printed a clean hosted success with + // no mention that the bun lock was skipped, the very silent no-op this + // channel exists to close. Two voices about one file on the run that + // does migrate beat silence on the many that never mention it; nothing + // is deduplicated. + // Build ecosystem summary let mut eco_parts = Vec::new(); for eco in Ecosystem::all() { @@ -2703,6 +2725,15 @@ pub async fn run(mut args: ScanArgs) -> i32 { "\n[dry-run] Would {action} {} patch(es). No changes made.", selected.len() ); + // Vendored preview: the same ledger classification the JSON arm + // nests under `vendor`, rendered as `[would-refuse]` lines so a + // human preview never advertises vendoring the wet run's Bun + // preflight is known to refuse (the `get --mode vendored + // --dry-run` arms print the identical lines). + if vendor { + let preview = preview_vendor_json(&args.common.cwd, &selected).await; + print_dry_run_refusals(&preview); + } } return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; } diff --git a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs index c9f9c337..60351e37 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -16,6 +16,7 @@ use std::path::Path; use std::time::Duration; use crate::args::GlobalArgs; +use crate::commands::bun_preflight::bun_vendor_preflight_with_ledger; use crate::commands::fetch_stage::{stage_vendor_sources_in_memory, MemStageOutcome}; use crate::commands::get::{download_and_apply_patches, download_patch_records, DownloadParams}; use crate::commands::vendor::{ @@ -29,19 +30,47 @@ use super::{ note_vendor_supersedes_redirect, ScanArgs, }; -/// Dry-run preview for `scan --vendor`: classify each selected patch -/// against the vendor ledger without touching disk or the network beyond -/// discovery. Action values are part of the CLI contract: -/// `would_vendor` (no ledger entry), `already_vendored` (entry at this -/// uuid), `would_revendor` + `oldUuid` (entry at an older uuid). +/// Dry-run preview for `scan --vendor` (and `get … --mode vendored +/// --dry-run`): classify each selected patch against the vendor ledger +/// without writing anything or touching the network beyond discovery. +/// Action values are part of the CLI contract: `would_vendor` (no ledger +/// entry), `already_vendored` (entry at this uuid), `would_revendor` + +/// `oldUuid` (entry at an older uuid), and — additive — `would_refuse` + +/// `errorCode` + `error` for npm purls the wet run's Bun preflight +/// ([`crate::commands::bun_preflight::BunVendorRefusal`]) would refuse +/// before any download. The preview stays a ledger classification otherwise +/// (engine refusals outside the preflight are not predicted), and it never +/// flips the run's status or exit code: `would_refuse` is best-effort +/// advice so a preview never advertises vendoring the wet run is known to +/// refuse. The preflight reads `bun.lock`/`bun.lockb` (plus, on a refused +/// workspace lock, the lock once more per npm purl for the exemption) — +/// the only disk access here — and runs only when the selection holds an +/// npm purl. pub(crate) async fn preview_vendor_json( cwd: &Path, selected: &[PatchSearchResult], ) -> serde_json::Value { - let state = load_state(cwd).await.unwrap_or_default(); + // The ledger load outcome reaches the preflight AS a result, so an + // unreadable ledger previews as `vendor_state_unreadable` (nothing + // exempt) instead of being flattened into an empty ledger that then + // predicts a Bun lock refusal; the classification below degrades it to + // empty (every npm record then reads `would_refuse` with that code). + let state = load_state(cwd).await; + let refusal = + bun_vendor_preflight_with_ledger(cwd, selected, state.as_ref().map(|s| &s.entries)).await; + let state = state.unwrap_or_default(); let mut patches: Vec = selected .iter() .map(|p| match lookup_entry(&state.entries, &p.purl) { + // Refusal takes priority: a preserved ledger can name this + // UUID even after rollback has removed its live wiring. + _ if refusal.as_ref().is_some_and(|r| r.applies_to(&p.purl)) => { + let r = refusal.as_ref().expect("checked by the guard"); + serde_json::json!({ + "purl": p.purl, "uuid": p.uuid, "action": "would_refuse", + "errorCode": r.code, "error": r.detail, + }) + } Some(e) if e.uuid == p.uuid => serde_json::json!({ "purl": p.purl, "uuid": p.uuid, "action": "already_vendored", }), @@ -58,6 +87,28 @@ pub(crate) async fn preview_vendor_json( serde_json::json!({ "dryRun": true, "patches": patches }) } +/// Human rendering of the vendored dry-run preview's `would_refuse` records +/// (see [`preview_vendor_json`]): the count line above it still says +/// "would download and vendor", so name what the wet run would refuse and +/// why. Shared by `scan --mode vendored --dry-run`'s interactive arm and +/// both `get … --mode vendored --dry-run` arms so the two commands' human +/// previews cannot drift (the contract promises the line for both). +/// Informational (the preview exits 0), hence behind the caller's +/// `--silent` gate. +pub(crate) fn print_dry_run_refusals(preview: &serde_json::Value) { + let Some(patches) = preview["patches"].as_array() else { + return; + }; + for p in patches.iter().filter(|p| p["action"] == "would_refuse") { + println!( + " [would-refuse] {} ({}): {}", + p["purl"].as_str().unwrap_or_default(), + p["errorCode"].as_str().unwrap_or_default(), + p["error"].as_str().unwrap_or_default() + ); + } +} + /// Build the vendoring-service config for scan's vendored flow — the SAME /// shape the standalone `vendor` command builds (see `vendor::run`), so both /// entry points honor `--vendor-source` / `--vendor-url` / @@ -344,8 +395,11 @@ async fn run_vendor_json_path( { Ok((vendor_errors, venv)) => { has_errors |= vendor_errors; + // Telemetry follows the RUN outcome: a download-phase failure + // (a Bun refusal, a failed view fetch) exits 1 and must not + // report a successful vendoring of zero patches. track_outcomes_for_vendor( - vendor_errors, + has_errors, &venv, args.common.dry_run, telemetry_token, @@ -459,8 +513,9 @@ async fn run_vendor_interactive_path( { Ok((vendor_errors, venv)) => { has_errors |= vendor_errors; + // Run-outcome telemetry, same as the JSON arm above. track_outcomes_for_vendor( - vendor_errors, + has_errors, &venv, args.common.dry_run, telemetry_token, @@ -758,6 +813,182 @@ mod service_config_tests { } } +#[cfg(test)] +mod preview_tests { + use super::preview_vendor_json; + use socket_patch_core::api::types::PatchSearchResult; + use std::collections::HashMap; + use std::path::Path; + + const UUID: &str = "11111111-1111-4111-8111-111111111111"; + const OLD_UUID: &str = "00000000-0000-4000-8000-000000000000"; + const NPM: &str = "pkg:npm/preview-bun@1.0.0"; + const PYPI: &str = "pkg:pypi/preview-other@1.0.0"; + + /// Real bun 1.3.14 lockfileVersion-1 workspace grammar (1-tuple + /// `workspace:` entry) — the shape the wet run refuses. + const V1_WORKSPACE_LOCK: &str = r#"{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "preview-fixture", + "dependencies": { + "consumer": "workspace:*", + }, + }, + "packages/consumer": { + "name": "consumer", + "version": "1.0.0", + "dependencies": { + "preview-bun": "1.0.0", + }, + }, + }, + "packages": { + "consumer": ["consumer@workspace:packages/consumer"], + + "preview-bun": ["preview-bun@1.0.0", "", {}, "sha512-AAAA=="], + } +} +"#; + + fn sel(uuid: &str, purl: &str) -> PatchSearchResult { + PatchSearchResult { + uuid: uuid.into(), + purl: purl.into(), + published_at: "2024-01-01T00:00:00Z".into(), + description: String::new(), + license: "MIT".into(), + tier: "free".into(), + vulnerabilities: HashMap::new(), + } + } + + fn seed_entry(root: &Path, purl: &str, uuid: &str) { + let vendor = root.join(".socket/vendor"); + std::fs::create_dir_all(&vendor).unwrap(); + std::fs::write( + vendor.join("state.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "version": 1, + "entries": { purl: { + "ecosystem": "npm", "basePurl": purl, "uuid": uuid, + "artifact": { "path": format!(".socket/vendor/npm/{uuid}/x.tgz") }, + "wiring": [], "flavor": "bun", + }} + })) + .unwrap(), + ) + .unwrap(); + } + + fn action_of<'a>(preview: &'a serde_json::Value, purl: &str) -> &'a serde_json::Value { + preview["patches"] + .as_array() + .unwrap() + .iter() + .find(|p| p["purl"] == purl) + .unwrap_or_else(|| panic!("no preview record for {purl}: {preview}")) + } + + /// Without a Bun lock the preview is the pre-existing ledger + /// classification, byte for byte: `would_vendor` and nothing else. + #[tokio::test] + async fn preview_without_bun_lock_is_plain_would_vendor() { + let tmp = tempfile::tempdir().unwrap(); + let preview = preview_vendor_json(tmp.path(), &[sel(UUID, NPM)]).await; + assert_eq!( + preview, + serde_json::json!({ + "dryRun": true, + "patches": [{ "purl": NPM, "uuid": UUID, "action": "would_vendor" }], + }) + ); + } + + /// A refused Bun tree flips npm purls to the additive `would_refuse` + /// (with the vendor code + detail) and leaves other ecosystems alone. + #[tokio::test] + async fn preview_marks_would_refuse_for_refused_bun_tree() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("bun.lock"), V1_WORKSPACE_LOCK).unwrap(); + let preview = preview_vendor_json(tmp.path(), &[sel(UUID, NPM), sel(UUID, PYPI)]).await; + let npm = action_of(&preview, NPM); + assert_eq!(npm["action"], "would_refuse", "{preview}"); + assert_eq!( + npm["errorCode"], "vendor_bun_workspace_unsupported", + "{preview}" + ); + assert!( + npm["error"].as_str().is_some_and(|d| !d.is_empty()), + "{preview}" + ); + assert_eq!( + action_of(&preview, PYPI)["action"], + "would_vendor", + "{preview}" + ); + assert_eq!(preview["dryRun"], true); + // The preflight is read-only. + assert_eq!( + std::fs::read_to_string(tmp.path().join("bun.lock")).unwrap(), + V1_WORKSPACE_LOCK + ); + } + + /// A ledger cannot override the live-lock refusal. Already-vendored + /// classification remains available when the lock is actually wired. + #[tokio::test] + async fn preview_bun_refusal_requires_live_wiring_even_at_the_same_uuid() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("bun.lock"), V1_WORKSPACE_LOCK).unwrap(); + + seed_entry(tmp.path(), NPM, UUID); + let preview = preview_vendor_json(tmp.path(), &[sel(UUID, NPM)]).await; + assert_eq!( + action_of(&preview, NPM)["action"], + "would_refuse", + "{preview}" + ); + + seed_entry(tmp.path(), NPM, OLD_UUID); + let preview = preview_vendor_json(tmp.path(), &[sel(UUID, NPM)]).await; + let rec = action_of(&preview, NPM); + assert_eq!(rec["action"], "would_refuse", "{preview}"); + assert!( + rec.get("oldUuid").is_none(), + "a refused record is not a revendor preview: {preview}" + ); + + let wired = V1_WORKSPACE_LOCK.replace( + r#"["preview-bun@1.0.0", "", {}, "sha512-AAAA=="]"#, + &format!(r#"["preview-bun@.socket/vendor/npm/{UUID}/preview-bun-1.0.0.tgz", {{}}, "sha512-AAAA=="]"#), + ); + std::fs::write(tmp.path().join("bun.lock"), wired).unwrap(); + seed_entry(tmp.path(), NPM, UUID); + let preview = preview_vendor_json(tmp.path(), &[sel(UUID, NPM)]).await; + assert_eq!( + action_of(&preview, NPM)["action"], + "already_vendored", + "{preview}" + ); + } + + /// bun.lockb without a text lock: `would_refuse` with the lockb code. + #[tokio::test] + async fn preview_marks_lockb_only_tree_would_refuse() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("bun.lockb"), b"\x00binary").unwrap(); + let preview = preview_vendor_json(tmp.path(), &[sel(UUID, NPM)]).await; + assert_eq!( + action_of(&preview, NPM)["errorCode"], + "vendor_bun_lockb_unsupported", + "{preview}" + ); + } +} + #[cfg(test)] mod fold_vendored_skips_tests { use super::fold_vendored_skips_into_apply; diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 24a4b6f1..54a29772 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -37,6 +37,7 @@ use std::time::Duration; use crate::args::{apply_env_toggles, GlobalArgs}; use crate::commands::apply::{representative_file, result_to_event, variant_matches_installed}; +use crate::commands::bun_preflight::bun_vendor_preflight_pairs; use crate::commands::fetch_stage::{stage_vendor_sources_in_memory, MemStageOutcome}; use crate::commands::lock_cli::acquire_or_emit; use crate::commands::vex::{generate_vex_from_manifest_path, VexEmbedArgs}; @@ -951,6 +952,31 @@ pub(crate) async fn vendor_records( } }; + // Bun vendored preflight (see `crate::commands::bun_preflight`), run + // ONCE per run over the in-scope npm records and consulted per + // candidate in the dispatch loop BEFORE the hosted→vendored takeover. + // The bun engine refuses a pre-v2 `workspace:` lock (and a lockb-only / + // unreadable / unsupported-version project) before its own writes — + // but the takeover below reverts a hosted purl's lockfile edits and + // persists the redirect-ledger drop FIRST, so without this gate a + // `vendor` over a hosted-wired v1 workspace lock stripped the live + // hosted redirect, then failed `vendor_bun_workspace_unsupported`: + // unpatched in both modes, with the refusal telling the user to use + // the hosted mode it had just destroyed. `scan`/`get --mode vendored` + // already run this preflight at download time; here it is the ONLY + // gate the plain `vendor` command has, and its dry-run arm previews the + // same refusal instead of the takeover advisory. A purl the vendor + // ledger wires at the record's uuid, or whose lock instances are all + // already ours, is exempt (the engine handles in-sync re-runs and + // superseding-uuid re-vendors itself) — the ledger loaded above is the + // one it consults. Pairs are the in-scope vendorable records, so an + // `--ecosystems` filter that excludes npm never reads the lock. + let bun_pairs: Vec<(&str, &str)> = vendorable + .iter() + .filter_map(|p| records.get(p).map(|r| (p.as_str(), r.uuid.as_str()))) + .collect(); + let bun_refusal = bun_vendor_preflight_pairs(&common.cwd, &bun_pairs, Ok(&state.entries)).await; + // Release-variant grouping (pypi `?artifact_id=`, gem `?platform=`): // the crawler emits base purls; match the manifest's qualified variants // against the installed distribution via the first-file probe. @@ -1032,6 +1058,29 @@ pub(crate) async fn vendor_records( } matched.insert(candidate.clone()); + // The Bun preflight verdict (computed once above): the engine + // would refuse this project for this purl, so refuse HERE — the + // same `failed` event, code and detail the engine would have + // produced, in the dry run and the wet run alike — before the + // takeover block below can revert a live hosted redirect on its + // behalf. Hosted wiring, redirect ledger and lockfile stay + // byte-untouched for a refused purl. + if let Some(refusal) = bun_refusal.as_ref().filter(|r| r.applies_to(candidate)) { + has_errors = true; + env.record( + PatchEvent::new(PatchAction::Failed, candidate.clone()) + .with_error(refusal.code, refusal.detail.clone()), + ); + if !common.json { + eprintln!( + "Cannot vendor {}: {}", + normalize_purl(candidate), + refusal.detail + ); + } + continue; + } + // Cross-mode takeover: vendoring over a LIVE hosted redirect // must first revert the hosted edits from the redirect ledger. // Cargo: `[patch.crates-io]` only patches crates-io-sourced @@ -1048,7 +1097,10 @@ pub(crate) async fn vendor_records( // lock fragment to record as the ledger's originals. A purl // whose hosted edits cannot be cleanly reverted is REFUSED; the // cargo backend's own fail-closed guard (`hosted_redirect_live`) - // backstops states with no usable ledger at all. + // backstops states with no usable ledger at all. A purl whose + // bun project the vendor engine would refuse outright never + // reaches this block (the Bun preflight `continue`d above), so + // a refusal can no longer land AFTER the revert was persisted. if socket_patch_core::patch::redirect::redirect_revert_supported(candidate) { if let Some(corrupt) = &redirect_ledger_corrupt { has_errors = true; @@ -1071,20 +1123,79 @@ pub(crate) async fn vendor_records( .as_ref() .is_some_and(|l| l.records.keys().any(|k| canon(k) == canon(candidate))); if claimed && common.dry_run { - record_warning( - env, + // Probe the takeover exactly as the wet run would — the + // per-purl revert's dry run resolves every inverse and + // drift check, flushes nothing, and mutates only this + // throwaway clone — so the preview never promises a + // takeover the wet run then refuses (a drifted lock, a + // corrupt edit): those surface here with the SAME + // `redirect_revert_failed` code and detail. + let mut probe = redirect_ledger.clone().expect("claimed implies Some"); + match socket_patch_core::patch::redirect::revert_redirect_purl( + &common.cwd, + &mut probe, candidate, - &VendorWarning::new( - "vendor_would_revert_redirect", - format!( - "{} is hosted-redirected; a non-dry-run vendor will \ - revert the hosted redirect edits first, then vendor \ - (mode takeover)", - normalize_purl(candidate) - ), - ), - common, - ); + true, + ) + .await + { + Ok(revert) => { + record_warning( + env, + candidate, + &VendorWarning::new( + "vendor_would_revert_redirect", + format!( + "{} is hosted-redirected; a non-dry-run vendor will \ + revert the hosted redirect edits first, then vendor \ + (mode takeover)", + normalize_purl(candidate) + ), + ), + common, + ); + // The backend preview below reads the lock from + // disk, where the hosted wiring is still live. A + // flavor whose hosted rewrite keeps the entry's + // `name@version` identity (yarn, pnpm, package-lock) + // previews fine over it; bun's hosted rewrite + // REPLACES that spec, so the backend would refuse a + // `vendor_lock_entry_not_found` the wet run never + // sees. When the revert would rewrite a lock this + // backend reads, the advisory already states the + // whole plan (revert, then vendor) and the preview + // stops here — the hosted dry run makes the same + // choice after `redirect_would_revert_vendored`. + // The project-level refusals the engine WOULD + // raise after the revert (workspace gate, lock + // version) were already previewed by the Bun + // preflight above, so stopping here promises + // nothing the wet run then refuses. + if revert.reverted_files.iter().any(|f| f == "bun.lock") { + continue; + } + } + Err(detail) => { + has_errors = true; + env.record( + PatchEvent::new(PatchAction::Failed, candidate.clone()).with_error( + "redirect_revert_failed", + format!( + "cannot vendor over the live hosted redirect: \ + {detail}" + ), + ), + ); + if !common.silent && !common.json { + eprintln!( + "Cannot vendor {}: cannot revert the hosted redirect: \ + {detail}", + normalize_purl(candidate) + ); + } + continue; + } + } } else if claimed { let ledger = redirect_ledger.as_mut().expect("claimed implies Some"); match socket_patch_core::patch::redirect::revert_redirect_purl( diff --git a/crates/socket-patch-cli/tests/common/cache_env.rs b/crates/socket-patch-cli/tests/common/cache_env.rs index 7571f061..2c0dea12 100644 --- a/crates/socket-patch-cli/tests/common/cache_env.rs +++ b/crates/socket-patch-cli/tests/common/cache_env.rs @@ -250,6 +250,48 @@ pub fn isolate(cmd: &mut Command) -> &mut Command { cmd } +// ── Ambient-env scrub for the bun suites ────────────────────────────── + +/// True for an ambient variable the real-bun suites must strip from every +/// child (`bun install` fixtures AND the CLI under test) before [`isolate`]: +/// +/// * `SOCKET_*` except the hermetic `SOCKET_NO_CONFIG` — the CLI's own env +/// surface (`SOCKET_DRY_RUN`, `SOCKET_API_TOKEN`, …); +/// * every `BUN_*` — the harness re-pins `BUN_INSTALL` / +/// `BUN_INSTALL_CACHE_DIR` per project after the scrub, and bun's other +/// knobs (`BUN_CONFIG_REGISTRY`, `BUN_CONFIG_TOKEN`) must not reach a +/// fixture install; +/// * `npm_config_*` case-insensitively — bun honours `npm_config_registry` +/// and `NPM_CONFIG_REGISTRY` alike. Measured (bun 1.2.23 / 1.4.2): an +/// ambient URL-rewriting mirror (npmmirror, Verdaccio, Artifactory) makes +/// bun record the mirror tarball URL in the 4-tuple's registry slot +/// instead of `""`, and every pre-rewrite `["name@ver", "", {}, "sha512-…"]` +/// assertion fails as a false negative; +/// * `VIRTUAL_ENV` — leaks the caller's Python env into the CLI's probes. +/// +/// One predicate for the three bun suites so they cannot drift: two of them +/// once scrubbed only `SOCKET_*` and went red under `BUN_CONFIG_REGISTRY` +/// while the third, with the full scrub, stayed green. +pub fn is_ambient_bun_var(name: &str) -> bool { + (name.starts_with("SOCKET_") && name != "SOCKET_NO_CONFIG") + || name.starts_with("BUN_") + || name.to_ascii_lowercase().starts_with("npm_config_") + || name == "VIRTUAL_ENV" +} + +/// Remove every [`is_ambient_bun_var`] variable of the PARENT environment +/// from `cmd`. Call it BEFORE [`isolate`] and before the suite's own +/// per-project `BUN_INSTALL` / `BUN_INSTALL_CACHE_DIR` pins (the ordering +/// rule in the module docs: the scrub iterates the parent env and would +/// otherwise remove what those seed). +pub fn scrub_ambient_bun_env(cmd: &mut Command) { + for (k, _) in std::env::vars_os() { + if is_ambient_bun_var(&k.to_string_lossy()) { + cmd.env_remove(&k); + } + } +} + // ── Self-tests ──────────────────────────────────────────────────────── // // Integration-test crates do not get `cfg(test)`, so — exactly as in @@ -259,6 +301,75 @@ pub fn isolate(cmd: &mut Command) -> &mut Command { mod cache_env_selftests { use super::*; + /// The names the bun suites' hermeticity depends on — the registry + /// overrides that reproduced the false negative, bun's auth and cache + /// knobs, the CLI's own surface — are covered; the hermetic switch, the + /// toolchain vars and look-alike prefixes (`BUNDLE_*`) survive. + #[test] + fn ambient_bun_scrub_covers_the_registry_and_config_overrides() { + for name in [ + "BUN_CONFIG_REGISTRY", + "BUN_CONFIG_TOKEN", + "BUN_INSTALL", + "BUN_INSTALL_CACHE_DIR", + "BUN_RUNTIME_TRANSPILER_CACHE_PATH", + "npm_config_registry", + "NPM_CONFIG_REGISTRY", + "Npm_Config_Registry", + "npm_config__auth", + "npm_config_cache", + "SOCKET_API_TOKEN", + "SOCKET_DRY_RUN", + "VIRTUAL_ENV", + ] { + assert!(is_ambient_bun_var(name), "{name} must be scrubbed"); + } + for name in [ + "SOCKET_NO_CONFIG", + "PATH", + "HOME", + "BUNDLE_PATH", + "BUNDLE_USER_HOME", + "npm_lifecycle_event", + "XDG_CONFIG_HOME", + "RUSTUP_HOME", + ] { + assert!(!is_ambient_bun_var(name), "{name} must survive the scrub"); + } + } + + /// The scrub removes exactly the parent variables the predicate names: + /// nothing else is touched, and no matching parent variable is missed. + #[test] + fn ambient_bun_scrub_removes_only_matching_parent_vars() { + let mut cmd = Command::new("true"); + scrub_ambient_bun_env(&mut cmd); + let removed: Vec = cmd + .get_envs() + .filter(|(_, value)| value.is_none()) + .map(|(name, _)| name.to_string_lossy().into_owned()) + .collect(); + for name in &removed { + assert!( + is_ambient_bun_var(name), + "{name} was removed but is not an ambient bun var" + ); + } + for (name, _) in std::env::vars_os() { + let name = name.to_string_lossy(); + if is_ambient_bun_var(&name) { + assert!( + removed.iter().any(|r| *r == name), + "{name} is set in the parent env but was not scrubbed" + ); + } + } + assert!( + cmd.get_envs().all(|(_, value)| value.is_none()), + "the scrub only removes; it seeds nothing" + ); + } + /// The variables whose whole point is that they outrank `HOME`. A future /// edit that drops one would silently restore the leak this module /// exists to close, and nothing else in the suite would notice. diff --git a/crates/socket-patch-cli/tests/covgap_commands_rollback.rs b/crates/socket-patch-cli/tests/covgap_commands_rollback.rs index 559a53c9..6515f0b4 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_rollback.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_rollback.rs @@ -1880,8 +1880,13 @@ fn hosted_persist_failure_lands_in_hosted_failed() { /// for {purl}" line prints from the deferred path. #[test] fn bun_deferred_purl_unwinds_via_replay() { - let bun_original = r#" "left-pad": ["left-pad@1.2.3", "", {}, "sha512-UPSTREAMupstream=="],"#; - let bun_redirected = format!(r#" "left-pad": ["{LP_HOSTED_URL}", "", {{}}, "sha512-PATCHEDpatched=="],"#); + let bun_original = + r#" "left-pad": ["left-pad@1.2.3", "", {}, "sha512-UPSTREAMupstream=="],"#; + // The engine's real redirected shape: registry 4-tuple → URL 3-tuple + // `["name@", {deps}, "sha512-…"]` (the registry slot is dropped). + let bun_redirected = format!( + r#" "left-pad": ["left-pad@{LP_HOSTED_URL}", {{}}, "sha512-PATCHEDpatched=="],"# + ); let bun_lock = |block: &str| { format!("{{\n \"lockfileVersion\": 1,\n \"packages\": {{\n{block}\n }}\n}}\n") }; @@ -2287,8 +2292,13 @@ fn per_purl_revert_failure_prints_human_stderr_line() { /// ledger byte-identical afterwards. #[test] fn bun_deferred_purl_dry_run_previews_via_replay() { - let bun_original = r#" "left-pad": ["left-pad@1.2.3", "", {}, "sha512-UPSTREAMupstream=="],"#; - let bun_redirected = format!(r#" "left-pad": ["{LP_HOSTED_URL}", "", {{}}, "sha512-PATCHEDpatched=="],"#); + let bun_original = + r#" "left-pad": ["left-pad@1.2.3", "", {}, "sha512-UPSTREAMupstream=="],"#; + // The engine's real redirected shape: registry 4-tuple → URL 3-tuple + // `["name@", {deps}, "sha512-…"]` (the registry slot is dropped). + let bun_redirected = format!( + r#" "left-pad": ["left-pad@{LP_HOSTED_URL}", {{}}, "sha512-PATCHEDpatched=="],"# + ); let bun_lock = |block: &str| { format!("{{\n \"lockfileVersion\": 1,\n \"packages\": {{\n{block}\n }}\n}}\n") }; diff --git a/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs b/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs index 7ad275a0..ba8ad4a7 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs @@ -654,10 +654,9 @@ async fn cargo_socket_owned_wiring_without_ledger_refuses_the_redirect() { "the refusal must name the missing ledger: {detail}" ); assert!( - doc["redirect"]["skipped"] - .as_array() - .is_some_and(|s| s.iter().any(|e| e["purl"] == CPURL - && e["reason"] == "vendored_revert_failed")), + doc["redirect"]["skipped"].as_array().is_some_and(|s| s + .iter() + .any(|e| e["purl"] == CPURL && e["reason"] == "vendored_revert_failed")), "the refusal must be accounted as skipped: {doc:#}" ); assert_eq!(doc["redirect"]["redirected"], 0, "envelope: {doc:#}"); @@ -708,6 +707,11 @@ async fn bun_lockb_dry_run_warns_would_migrate_without_spawning_bun() { "a dry-run must never report a migration ATTEMPT (proof no bun was \ spawned): {doc:#}" ); + assert!( + !warning_codes(&doc).contains(&"redirect_npm_no_lockfile".to_string()), + "a bun.lockb project is a Bun project: the npm no-lockfile warning is \ + noise beside the would-migrate preview: {doc:#}" + ); assert_eq!( std::fs::read(tmp.path().join("bun.lockb")).unwrap(), lockb_before, @@ -756,11 +760,27 @@ async fn failed_bun_lockb_migration_warns_unsupported_and_keeps_the_binary_lock( &[], &[("PATH", path_value.as_str())], ); - assert_eq!(code, 0, "a failed migration is a warning, not an error: {doc:#}"); + assert_eq!( + code, 0, + "a failed migration is a warning, not an error: {doc:#}" + ); let detail = warning_detail(&doc, "redirect_bun_lockb_unsupported"); assert!( - detail.contains("cannot pin a binary lockfile"), - "the unsupported warning must explain the refusal: {detail}" + detail.contains("cannot pin a binary lockfile") && detail.contains("exit status: 1"), + "the unsupported warning must explain the refusal and carry bun's exit: {detail}" + ); + let codes = warning_codes(&doc); + assert_eq!( + codes + .iter() + .filter(|c| *c == "redirect_bun_lockb_unsupported") + .count(), + 1, + "exactly one unsupported warning: {codes:?}" + ); + assert!( + !codes.contains(&"redirect_npm_no_lockfile".to_string()), + "a bun.lockb project never gets the npm no-lockfile noise: {codes:?}" ); assert_eq!(doc["redirect"]["redirected"], 0, "envelope: {doc:#}"); assert_eq!( @@ -837,7 +857,10 @@ async fn unreadable_bun_lockb_backup_keeps_the_migration_and_warns_loudly() { &[], &[("PATH", path_value.as_str())], ); - assert_eq!(code, 0, "the kept migration is a warning, not an error: {doc:#}"); + assert_eq!( + code, 0, + "the kept migration is a warning, not an error: {doc:#}" + ); let detail = warning_detail(&doc, "redirect_bun_lockb_migrated_without_redirect"); assert!( detail.contains("git history is the restore path"), @@ -853,12 +876,383 @@ async fn unreadable_bun_lockb_backup_keeps_the_migration_and_warns_loudly() { "bun deleted the binary lock and no backup could restore it" ); // The kept migration's removal record reaches the ledger so a future - // `--revert` knows the file was replaced. - let ledger = - std::fs::read_to_string(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(); + // `--revert` knows the file was replaced — WITHOUT `original`: the + // pre-migration read failed, so there are no bytes to restore from. + let ledger: Value = serde_json::from_str( + &std::fs::read_to_string(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(), + ) + .unwrap(); + let migration = &ledger["edits"][0]; + assert_eq!( + migration["kind"], "redirect_bun_lockb_migrated", + "{ledger:#}" + ); + assert_eq!(migration["action"], "removed", "{ledger:#}"); + assert!( + migration.get("original").is_none(), + "an unreadable lock is recorded without bytes: {ledger:#}" + ); +} + +/// A fake `bun` that records the spawn in `marker` (an absolute path, so the +/// child's cwd is irrelevant) and then fails. Every test below asserts the +/// marker is ABSENT: the gate under test must refuse before any spawn. +#[cfg(unix)] +fn install_marker_bun_shim(root: &Path) -> (String, std::path::PathBuf) { + let marker = root.join("bun-was-spawned"); + let path_value = install_bun_shim( + root, + &format!("#!/bin/sh\ntouch \"{}\"\nexit 1\n", marker.display()), + ); + (path_value, marker) +} + +#[cfg(unix)] +fn mkfifo(path: &Path) { + use std::os::unix::ffi::OsStrExt as _; + let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).unwrap(); + let rc = unsafe { libc::mkfifo(c_path.as_ptr(), 0o644) }; + assert_eq!(rc, 0, "mkfifo(2): {}", std::io::Error::last_os_error()); +} + +/// `scan_hosted_json` on a worker thread with a deadline: a wedged run never +/// returns, so on timeout a writer is connected to `fifo` (releasing any +/// reader blocked in open(2)) and the test FAILS instead of hanging the +/// suite. The deadline is generous on purpose — a healthy run finishes in +/// seconds, the regression under test never finishes — so a slow CI box +/// (first-launch dyld stall, parallel test load) cannot turn into a flake. +#[cfg(unix)] +fn scan_hosted_json_or_release_fifo( + cwd: &Path, + api_url: &str, + extra: &[&str], + env: &[(&str, &str)], + fifo: &Path, +) -> (i32, Value) { + let cwd = cwd.to_path_buf(); + let api_url = api_url.to_string(); + let extra: Vec = extra.iter().map(|s| s.to_string()).collect(); + let env: Vec<(String, String)> = env + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let extra: Vec<&str> = extra.iter().map(String::as_str).collect(); + let env: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); + let _ = tx.send(scan_hosted_json(&cwd, &api_url, &extra, &env)); + }); + let deadline = std::time::Duration::from_secs(60); + match rx.recv_timeout(deadline) { + Ok(result) => result, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + use std::os::unix::fs::OpenOptionsExt as _; + let released = std::fs::OpenOptions::new() + .write(true) + .custom_flags(libc::O_NONBLOCK) + .open(fifo) + .is_ok(); + panic!( + "scan --mode hosted wedged on a FIFO bun.lockb for {deadline:?} (a reader \ + was blocked in open(2): {released})" + ); + } + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + panic!("the scan thread panicked before reporting (see the output above)") + } + } +} + +/// A FIFO squatting `bun.lockb` passes the `exists()` gate but is not a +/// lock the migration can capture: the pre-migration read goes through the +/// FIFO-safe opener and refuses `redirect_bun_lockb_unsupported` ("not a +/// regular file") BEFORE spawning bun — bun's own open of the lock blocks on +/// the same FIFO, so a guarded read that still spawned would only relocate +/// the hang into the child. Pinned: the run returns (a plain `std::fs::read` +/// wedged forever here), the shim never ran, the FIFO is left in place, no +/// text lock appears, and `--dry-run` predicts the same refusal instead of +/// the would-migrate preview. +#[cfg(unix)] +#[tokio::test] +async fn fifo_bun_lockb_refuses_before_spawning_bun_and_never_wedges() { + let server = MockServer::start().await; + mock_discovery(&server, PURL, UUID).await; + mock_granted_reference(&server, UUID, PURL, HOSTED_URL).await; + + let tmp = tempfile::tempdir().unwrap(); + write_bun_lockb_project(tmp.path()); + let fifo = tmp.path().join("bun.lockb"); + std::fs::remove_file(&fifo).unwrap(); + mkfifo(&fifo); + let (path_value, marker) = install_marker_bun_shim(tmp.path()); + let env = [("PATH", path_value.as_str())]; + + for extra in [&["--dry-run"][..], &[][..]] { + let (code, doc) = + scan_hosted_json_or_release_fifo(tmp.path(), &server.uri(), extra, &env, &fifo); + assert_eq!( + code, 0, + "{extra:?}: the refusal is a warning, not an error: {doc:#}" + ); + let detail = warning_detail(&doc, "redirect_bun_lockb_unsupported"); + assert_eq!( + detail, "bun.lockb is not a regular file; refusing to migrate it", + "{extra:?}: the refusal must name the reason, not a bun failure" + ); + let codes = warning_codes(&doc); + assert_eq!( + codes + .iter() + .filter(|c| *c == "redirect_bun_lockb_unsupported") + .count(), + 1, + "{extra:?}: exactly one unsupported warning: {codes:?}" + ); + for absent in [ + "redirect_bun_lockb_would_migrate", + "redirect_bun_lockb_manual_migration", + "redirect_bun_lockb_migrated_without_redirect", + "redirect_npm_no_lockfile", + ] { + assert!( + !codes.contains(&absent.to_string()), + "{extra:?}: {absent} must not accompany the not-a-regular-file refusal: {codes:?}" + ); + } + assert_eq!(doc["redirect"]["redirected"], 0, "{extra:?}: {doc:#}"); + assert!( + !marker.exists(), + "{extra:?}: bun must never be spawned on a FIFO bun.lockb (it blocks on it too)" + ); + let meta = std::fs::symlink_metadata(&fifo).unwrap(); + assert!( + std::os::unix::fs::FileTypeExt::is_fifo(&meta.file_type()), + "{extra:?}: the FIFO is left in place, never replaced or removed" + ); + assert!( + !tmp.path().join("bun.lock").exists(), + "{extra:?}: no text lock may appear" + ); + } +} + +/// The verbatim `redirect_bun_lockb_sibling_lock` detail for one sibling — +/// the string the contract documents. +#[cfg(unix)] +fn sibling_lock_detail(sibling: &str) -> String { + format!( + "bun.lockb was left alone because {sibling} is also present; the redirect follows \ + {sibling} — delete the stale bun.lockb if it is debris, or remove {sibling} and re-run \ + if bun is the installer" + ) +} + +/// Shared assertions for a stale `bun.lockb` beside a live sibling lock: +/// the migration is skipped with `redirect_bun_lockb_sibling_lock` naming +/// the sibling (dry-run: instead of the would-migrate preview), bun is never +/// spawned, bun.lockb is byte-identical, no text lock appears, and the +/// redirect lands in (or, dry-run, previews) the sibling lock. +#[cfg(unix)] +fn assert_sibling_lock_outcome( + doc: &Value, + root: &Path, + sibling: &str, + lockb_before: &[u8], + marker: &Path, + dry_run: bool, +) { + assert_eq!( + warning_detail(doc, "redirect_bun_lockb_sibling_lock"), + sibling_lock_detail(sibling), + "dry_run={dry_run}: the warning must name the sibling and both remedies" + ); + let codes = warning_codes(doc); + for absent in [ + "redirect_bun_lockb_would_migrate", + "redirect_bun_lockb_unsupported", + "redirect_bun_lockb_manual_migration", + "redirect_bun_lockb_migration_reverted", + "redirect_bun_lockb_migrated_without_redirect", + "redirect_npm_no_lockfile", + ] { + assert!( + !codes.contains(&absent.to_string()), + "dry_run={dry_run}: {absent} must not accompany the sibling-lock skip: {codes:?}" + ); + } + assert_eq!(doc["redirect"]["dryRun"], dry_run, "{doc:#}"); + let rewritten: Vec<&str> = doc["redirect"]["rewrittenFiles"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect(); + assert!( + rewritten.contains(&sibling), + "dry_run={dry_run}: the redirect must follow the sibling lock: {doc:#}" + ); + assert!( + !rewritten.contains(&"bun.lock"), + "dry_run={dry_run}: no bun.lock may be rewritten (none was created): {doc:#}" + ); + assert_eq!( + doc["redirect"]["redirected"], 1, + "dry_run={dry_run}: {doc:#}" + ); assert!( - ledger.contains("redirect_bun_lockb_migrated") && ledger.contains("\"removed\""), - "the ledger must keep the migration's removal record: {ledger}" + !marker.exists(), + "dry_run={dry_run}: bun must never be spawned beside a live {sibling}" + ); + assert_eq!( + std::fs::read(root.join("bun.lockb")).unwrap(), + lockb_before, + "dry_run={dry_run}: the stale bun.lockb is left byte-identical" + ); + assert!( + !root.join("bun.lock").exists(), + "dry_run={dry_run}: no text lock may be created for a {sibling} project" + ); +} + +/// A project that migrated from bun to npm and left `bun.lockb` committed: +/// the hosted driver must NOT run the lockb→text migration (it would turn the +/// npm project into a bun.lock project — bun 1.4.2 really did, deleting +/// bun.lockb and writing a lockfileVersion-2 bun.lock beside the redirected +/// package-lock.json). The redirect follows package-lock.json as today, the +/// binary lock is left alone with `redirect_bun_lockb_sibling_lock`, and the +/// human leg prints that detail. +#[cfg(unix)] +#[tokio::test] +async fn stale_bun_lockb_beside_package_lock_is_left_alone_and_the_redirect_follows_it() { + let server = MockServer::start().await; + mock_discovery(&server, PURL, UUID).await; + mock_granted_reference(&server, UUID, PURL, HOSTED_URL).await; + mock_view(&server, UUID, PURL).await; + + let tmp = tempfile::tempdir().unwrap(); + write_npm_project(tmp.path(), NAME); + let lockb_before = b"\x00stale-bun-lockb-debris".to_vec(); + std::fs::write(tmp.path().join("bun.lockb"), &lockb_before).unwrap(); + let (path_value, marker) = install_marker_bun_shim(tmp.path()); + let env = [("PATH", path_value.as_str())]; + + // Dry-run: the same code, never the would-migrate preview. + let (code, doc) = scan_hosted_json(tmp.path(), &server.uri(), &["--dry-run"], &env); + assert_eq!(code, 0, "dry-run exits 0: {doc:#}"); + assert_sibling_lock_outcome( + &doc, + tmp.path(), + "package-lock.json", + &lockb_before, + &marker, + true, + ); + let lock_before = std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(); + assert!( + !lock_before.contains(HOSTED_URL), + "dry-run must not rewrite the sibling lock" + ); + + // Live: package-lock.json is redirected, bun.lockb untouched, no spawn. + let (code, doc) = scan_hosted_json(tmp.path(), &server.uri(), &[], &env); + assert_eq!(code, 0, "live run exits 0: {doc:#}"); + assert_sibling_lock_outcome( + &doc, + tmp.path(), + "package-lock.json", + &lockb_before, + &marker, + false, + ); + let lock = std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(); + assert!( + lock.contains(&format!("\"resolved\": \"{HOSTED_URL}\"")), + "the redirect must land in package-lock.json:\n{lock}" + ); + let ledger: Value = serde_json::from_str( + &std::fs::read_to_string(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(), + ) + .unwrap(); + assert!( + ledger["edits"] + .as_array() + .unwrap() + .iter() + .all(|e| e["path"] != "bun.lockb" && e["kind"] != "redirect_bun_lockb_migrated"), + "no lockb migration may be recorded: {ledger:#}" + ); + + // Human leg (fresh project so the warning fires again): the + // migration-warning loop prints the bare detail. + let tmp2 = tempfile::tempdir().unwrap(); + write_npm_project(tmp2.path(), NAME); + std::fs::write(tmp2.path().join("bun.lockb"), &lockb_before).unwrap(); + let (path_value2, marker2) = install_marker_bun_shim(tmp2.path()); + let (code, _stdout, stderr) = scan_hosted( + tmp2.path(), + &server.uri(), + &[], + &[("PATH", path_value2.as_str())], + ); + assert_eq!(code, 0, "human run exits 0; stderr=\n{stderr}"); + assert!( + stderr.contains(&format!( + " warning: {}", + sibling_lock_detail("package-lock.json") + )), + "the sibling-lock detail must reach human stderr; stderr=\n{stderr}" + ); + assert!(!marker2.exists(), "human leg: bun must never be spawned"); + assert_eq!( + std::fs::read(tmp2.path().join("bun.lockb")).unwrap(), + lockb_before + ); +} + +/// The pnpm twin: a stale `bun.lockb` beside a live v9 `pnpm-lock.yaml`. The +/// lock the redirect follows is pnpm's; the binary lock is left alone, bun +/// is never spawned, and the npm rewriter's no-lockfile noise stays +/// suppressed by the core's own sibling gate. +#[cfg(unix)] +#[tokio::test] +async fn stale_bun_lockb_beside_pnpm_lock_is_left_alone_and_the_redirect_follows_it() { + let server = MockServer::start().await; + mock_discovery(&server, PURL, UUID).await; + mock_granted_reference(&server, UUID, PURL, HOSTED_URL).await; + mock_view(&server, UUID, PURL).await; + + let tmp = tempfile::tempdir().unwrap(); + write_pnpm_project(tmp.path()); + let lockb_before = b"\x00stale-bun-lockb-debris".to_vec(); + std::fs::write(tmp.path().join("bun.lockb"), &lockb_before).unwrap(); + let (path_value, marker) = install_marker_bun_shim(tmp.path()); + let env = [("PATH", path_value.as_str())]; + + let (code, doc) = scan_hosted_json(tmp.path(), &server.uri(), &["--dry-run"], &env); + assert_eq!(code, 0, "dry-run exits 0: {doc:#}"); + assert_sibling_lock_outcome( + &doc, + tmp.path(), + "pnpm-lock.yaml", + &lockb_before, + &marker, + true, + ); + + let (code, doc) = scan_hosted_json(tmp.path(), &server.uri(), &[], &env); + assert_eq!(code, 0, "live run exits 0: {doc:#}"); + assert_sibling_lock_outcome( + &doc, + tmp.path(), + "pnpm-lock.yaml", + &lockb_before, + &marker, + false, + ); + let lock = std::fs::read_to_string(tmp.path().join("pnpm-lock.yaml")).unwrap(); + assert!( + lock.contains(&format!("tarball: {HOSTED_URL}")), + "the redirect must land in pnpm-lock.yaml:\n{lock}" ); } @@ -894,7 +1288,10 @@ async fn unreadable_pnpm_workspace_gets_warning_only_guidance_in_a_live_run() { // be read back. std::fs::set_permissions(&ws, std::fs::Permissions::from_mode(0o644)).unwrap(); - assert_eq!(code, 0, "the fallback is warning-only, never an error: {doc:#}"); + assert_eq!( + code, 0, + "the fallback is warning-only, never an error: {doc:#}" + ); let detail = warning_detail(&doc, "redirect_pnpm_trust_lockfile"); assert!( detail.contains("exists but could not be read") && detail.contains("left untouched"), @@ -1023,7 +1420,10 @@ async fn live_hosted_overlap_fires_redirect_supersedes_vendored() { ); let (code, doc) = scan_hosted_json(root, &server.uri(), &[], &[]); - assert_eq!(code, 0, "the overlap warning never flips the exit code: {doc:#}"); + assert_eq!( + code, 0, + "the overlap warning never flips the exit code: {doc:#}" + ); assert_eq!( doc["redirect"]["redirected"], 1, "anchor: Y must redirect normally: {doc:#}" @@ -1038,8 +1438,7 @@ async fn live_hosted_overlap_fires_redirect_supersedes_vendored() { "the per-package remediation must be prescribed: {detail}" ); // Warn-only contract: the stale vendored ledger is NOT deleted. - let state = - std::fs::read_to_string(root.join(".socket/vendor/state.json")).unwrap(); + let state = std::fs::read_to_string(root.join(".socket/vendor/state.json")).unwrap(); assert!( state.contains(XPURL), "the takeover warning must not delete the other mode's ledger: {state}" @@ -1083,7 +1482,10 @@ async fn human_dry_run_prints_would_rewrite_pnpm_guidance_and_vex_skip() { ], &[], ); - assert_eq!(code, 0, "dry-run exits 0; stdout=\n{stdout}\nstderr=\n{stderr}"); + assert_eq!( + code, 0, + "dry-run exits 0; stdout=\n{stdout}\nstderr=\n{stderr}" + ); assert!( stdout.contains("Redirected 1 package(s)") && stdout.contains("; would rewrite"), "the dry-run summary must use the preview verb; stdout=\n{stdout}" @@ -1132,7 +1534,10 @@ async fn human_vex_success_summary_names_statements_path_and_ledger_caveat() { ], &[], ); - assert_eq!(code, 0, "scan --vex exits 0; stdout=\n{stdout}\nstderr=\n{stderr}"); + assert_eq!( + code, 0, + "scan --vex exits 0; stdout=\n{stdout}\nstderr=\n{stderr}" + ); assert!( stdout.contains("Redirected 1 package(s); rewrote"), "anchor: the wet-run summary verb; stdout=\n{stdout}" @@ -1146,10 +1551,9 @@ async fn human_vex_success_summary_names_statements_path_and_ledger_caveat() { stderr.contains("attested from the ledger"), "the no-verify caveat is load-bearing; stderr=\n{stderr}" ); - let doc: Value = serde_json::from_str( - &std::fs::read_to_string(tmp.path().join("out.vex.json")).unwrap(), - ) - .unwrap(); + let doc: Value = + serde_json::from_str(&std::fs::read_to_string(tmp.path().join("out.vex.json")).unwrap()) + .unwrap(); assert_eq!( doc["statements"].as_array().map(Vec::len), Some(1), @@ -1172,7 +1576,10 @@ async fn human_rush_run_prints_the_repo_state_stale_warning_line() { write_rush_project(tmp.path()); let (code, stdout, stderr) = scan_hosted(tmp.path(), &server.uri(), &[], &[]); - assert_eq!(code, 0, "rush run exits 0; stdout=\n{stdout}\nstderr=\n{stderr}"); + assert_eq!( + code, 0, + "rush run exits 0; stdout=\n{stdout}\nstderr=\n{stderr}" + ); assert!( stdout.contains("Redirected 1 package(s); rewrote"), "anchor: the rush lock must be rewritten; stdout=\n{stdout}" @@ -1242,10 +1649,9 @@ async fn ledger_save_failure_after_successful_revert_fails_closed() { "the post-revert ledger-save failure must be named: {detail}" ); assert!( - doc["redirect"]["skipped"] - .as_array() - .is_some_and(|s| s.iter().any(|e| e["purl"] == PURL - && e["reason"] == "vendored_revert_failed")), + doc["redirect"]["skipped"].as_array().is_some_and(|s| s + .iter() + .any(|e| e["purl"] == PURL && e["reason"] == "vendored_revert_failed")), "the refusal must be accounted as skipped: {doc:#}" ); assert_eq!(doc["redirect"]["redirected"], 0, "envelope: {doc:#}"); diff --git a/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs b/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs index d53e6887..f20594af 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs @@ -1205,3 +1205,231 @@ mod pty { assert_eq!(view_gets(&reqs), 0, "declining must not download the patch"); } } + +// --------------------------------------------------------------------------- +// bun.lockb-only project: a diagnosis, never a silent success-0 +// --------------------------------------------------------------------------- +// A bun project whose only lockfile is the legacy binary `bun.lockb` (bun +// <= 1.1.38 always; 1.1.39–1.1.45 without `--save-text-lockfile`) with no +// `node_modules/` (fresh clone, CI lockfile-only checkout) used to scan as +// `status: success / scannedPackages: 0` with NO warnings in every mode — +// indistinguishable from an empty project (54 such matrix cells passed as +// "clean"). The lock inventory now surfaces `bun_lockb_unsupported`, which +// rides scan's additive run-level `warnings[]` (PnP-refusal precedent); +// exit code and `status` stay unchanged. + +fn write_bun_lockb_only_project(root: &Path) { + std::fs::write( + root.join("package.json"), + r#"{ "name": "covgap-lockb-only", "version": "0.0.0", "dependencies": { "minimist": "1.2.2" } }"#, + ) + .unwrap(); + std::fs::write(root.join("bun.lockb"), b"\0binary bun lockfile").unwrap(); +} + +fn bun_lockb_warning(v: &serde_json::Value) -> Option<&serde_json::Value> { + v["warnings"] + .as_array() + .and_then(|ws| ws.iter().find(|w| w["code"] == "bun_lockb_unsupported")) +} + +/// Zero-package path, every mode (agent, vendored, hosted): exit 0, the +/// envelope stays a `success` with `scannedPackages: 0`, and `warnings[]` +/// carries `bun_lockb_unsupported` with the `--save-text-lockfile` remedy. +/// Hosted mode keeps it here because the hosted driver — which owns the +/// bun.lockb migration story on a NON-empty scan — never runs on an empty +/// one; without the warning this is exactly the old silent no-op. +#[test] +fn scan_bun_lockb_only_project_warns_instead_of_silent_success() { + for mode in [None, Some("vendored"), Some("hosted")] { + let tmp = tempfile::tempdir().unwrap(); + write_bun_lockb_only_project(tmp.path()); + let mut args = vec!["--json"]; + if let Some(mode) = mode { + args.extend(["--mode", mode]); + } + let (code, stdout, stderr) = run_scan(tmp.path(), &args); + assert_eq!( + code, 0, + "mode={mode:?}: refusals never flip the exit; stdout={stdout}; stderr={stderr}" + ); + let v: serde_json::Value = serde_json::from_str(stdout.trim()) + .unwrap_or_else(|e| panic!("bad JSON ({e}): {stdout}")); + assert_eq!(v["status"], "success", "mode={mode:?}: {v}"); + assert_eq!(v["scannedPackages"], 0, "mode={mode:?}: {v}"); + assert_eq!(v["lockfileOnlyPackages"], 0, "mode={mode:?}: {v}"); + let warning = bun_lockb_warning(&v).unwrap_or_else(|| { + panic!("mode={mode:?}: warnings[] must carry bun_lockb_unsupported: {v}") + }); + let detail = warning["detail"].as_str().unwrap_or_default(); + assert!( + detail.contains("bun install --save-text-lockfile") && detail.contains("1.1.39"), + "mode={mode:?}: the remedy and its version floor: {detail}" + ); + assert!( + !stdout.contains("Warning ("), + "mode={mode:?}: the human warning line must not leak into the JSON stream: {stdout}" + ); + } + + // Human path: the same diagnosis as a stderr `Warning (code): detail` + // line, exit 0, and the generic "No packages found" hint still prints. + let tmp = tempfile::tempdir().unwrap(); + write_bun_lockb_only_project(tmp.path()); + let (code, stdout, stderr) = run_scan(tmp.path(), &[]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + assert!( + stderr + .contains("Warning (bun_lockb_unsupported): bun.lockb is bun's legacy binary lockfile"), + "the human path must name the layout and the code; got {stderr:?}" + ); + assert!( + stderr.contains("--save-text-lockfile"), + "the human path must carry the remedy; got {stderr:?}" + ); + assert!(stdout.contains("No packages found"), "{stdout:?}"); +} + +/// NON-empty scan (an installed package beside the bun.lockb), EVERY mode: +/// the discovery-side `bun_lockb_unsupported` rides the non-empty envelope +/// too — hosted included. An earlier version dropped it on every non-empty +/// hosted run on the theory that the hosted driver "owns the bun.lockb +/// story", but the driver only speaks about the file when an npm override +/// is actually granted (`redirect_bun_lockb_*` / a migration edit); with +/// nothing to redirect (this fixture: no patches) a hosted scan printed a +/// clean success that never mentioned the unread bun lock — the silent +/// no-op this channel exists to close. Nothing is deduplicated: on the run +/// that does migrate, the envelope may carry both voices about the file. +#[tokio::test] +async fn scan_nonempty_keeps_the_bun_lockb_discovery_warning_in_every_mode() { + let mock = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [], + "canAccessPaidPatches": false, + }))) + .mount(&mock) + .await; + + for mode in [None, Some("vendored"), Some("hosted")] { + let tmp = tempfile::tempdir().unwrap(); + write_bun_lockb_only_project(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2", b"module.exports = 1;\n"); + let uri = mock.uri(); + let mut args = vec![ + "--json", + "--api-url", + uri.as_str(), + "--api-token", + "fake-token-for-test", + "--org", + ORG_SLUG, + ]; + if let Some(mode) = mode { + args.extend(["--mode", mode]); + } + let (code, stdout, stderr) = run_scan(tmp.path(), &args); + assert_eq!(code, 0, "mode={mode:?}: stdout={stdout}; stderr={stderr}"); + let v: serde_json::Value = serde_json::from_str(stdout.trim()) + .unwrap_or_else(|e| panic!("bad JSON ({e}): {stdout}")); + assert_eq!(v["scannedPackages"], 1, "mode={mode:?}: {v}"); + assert_eq!(v["status"], "success", "mode={mode:?}: {v}"); + let warning = bun_lockb_warning(&v).unwrap_or_else(|| { + panic!("mode={mode:?}: the non-empty envelope must keep bun_lockb_unsupported: {v}") + }); + assert!( + warning["detail"] + .as_str() + .is_some_and(|d| d.contains("--save-text-lockfile")), + "mode={mode:?}: {v}" + ); + } + + // Human hosted path: the same warning line on stderr before the driver + // runs (exit 0; nothing to redirect). + let tmp = tempfile::tempdir().unwrap(); + write_bun_lockb_only_project(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2", b"module.exports = 1;\n"); + let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &["--mode", "hosted"]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + assert!( + stderr.contains("Warning (bun_lockb_unsupported):"), + "the human hosted path must keep the warning; got {stderr:?}" + ); +} + +// --------------------------------------------------------------------------- +// Human `--mode vendored --dry-run`: the `[would-refuse]` lines +// --------------------------------------------------------------------------- +// The contract promises the human vendored preview names what the wet run +// would refuse. Only `get --mode vendored --dry-run` printed the lines; a +// human `scan --mode vendored --dry-run` on a refused Bun project said +// "Would download and vendor 1 patch(es). No changes made." for a run that +// exits 1. Both commands now share the printer. + +/// A real bun 1.3.14 lockfileVersion-1 workspace lock resolving minimist +/// from the registry: the shape the vendored preflight refuses. +fn write_bun_v1_workspace_lock(root: &Path) { + std::fs::write( + root.join("bun.lock"), + "{\n \"lockfileVersion\": 1,\n \"configVersion\": 1,\n \"workspaces\": {\n \"\": {\n \"name\": \"covgap-scan-root\",\n \"dependencies\": {\n \"consumer\": \"workspace:*\",\n },\n },\n \"packages/consumer\": {\n \"name\": \"consumer\",\n \"version\": \"1.0.0\",\n \"dependencies\": {\n \"minimist\": \"1.2.2\",\n },\n },\n },\n \"packages\": {\n \"consumer\": [\"consumer@workspace:packages/consumer\"],\n\n \"minimist\": [\"minimist@1.2.2\", \"\", {}, \"sha512-AAAA==\"],\n }\n}\n", + ) + .unwrap(); +} + +#[tokio::test] +async fn scan_human_vendored_dry_run_names_would_refuse_records() { + let mock = MockServer::start().await; + let purl = "pkg:npm/minimist@1.2.2"; + mount_batch_one(&mock, purl, UUID, "free", &[], false).await; + mount_by_package(&mock, purl, UUID, serde_json::json!({})).await; + + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); + write_bun_v1_workspace_lock(tmp.path()); + + let (code, stdout, stderr) = run_scan_human( + tmp.path(), + &mock.uri(), + &["--mode", "vendored", "--dry-run"], + ); + assert_eq!( + code, 0, + "a preview never flips the exit; stdout={stdout}; stderr={stderr}" + ); + assert!( + stdout.contains("[dry-run] Would download and vendor 1 patch(es). No changes made."), + "the count line stays; got {stdout:?}" + ); + assert!( + stdout.contains(&format!( + " [would-refuse] {purl} (vendor_bun_workspace_unsupported): " + )), + "the human scan preview must name the refusal like get's does; got {stdout:?}" + ); + assert!( + stdout.contains("lockfileVersion-1 lock") && stdout.contains("--mode hosted"), + "the engine's detail rides along; got {stdout:?}" + ); + // Nothing written: no manifest, no blobs, no vendor ledger. (The human + // preview does fetch the patch view for its baseline-hash check — the + // same as every human dry run — so the request log is not the oracle.) + assert!( + !tmp.path().join(".socket").exists(), + "a dry run writes nothing" + ); + + // `--silent`: informational, so nothing at all on stdout. + let (code, stdout, _) = run_scan_human( + tmp.path(), + &mock.uri(), + &["--mode", "vendored", "--dry-run", "--silent"], + ); + assert_eq!(code, 0); + assert!( + stdout.trim().is_empty(), + "silent dry run prints nothing:\n{stdout}" + ); +} diff --git a/crates/socket-patch-cli/tests/e2e_redirect_bun_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_bun_build.rs index afe5e8c1..157f63a1 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_bun_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_bun_build.rs @@ -8,35 +8,87 @@ //! `bun`: //! //! 1. `bun install` of left-pad@1.3.0 (network for fixture setup only, -//! private `BUN_INSTALL_CACHE_DIR`, text lockfile). -//! 2. Build a PATCHED tarball from the installed bytes; its sha512 is what +//! private `BUN_INSTALL_CACHE_DIR`). The text `bun.lock` is the default +//! from bun 1.2.0 (lockfileVersion 1; 2 from 1.4.0); on 1.1.39–1.1.x it +//! is the `--save-text-lockfile` opt-in (lockfileVersion 0), which the +//! fixture passes for those releases, and the fixture ASSERTS the +//! version it got matches that era table. Bun before 1.1.39 has no +//! text lockfile and the suite skips (or, under the REQUIRED gate, +//! fails — such a leg must not be scheduled). The registry 4-tuple +//! spelling is identical across 0/1/2, so everything after the +//! fixture guard is version-independent. +//! 2. Build a PATCHED tarball from the installed bytes (tar crate — no +//! system `tar`, so Windows runners need nothing); its sha512 is what //! the redirect mock hands back (bun verifies the downloaded tarball's //! sha512 directly — no cache-zip conversion like yarn berry, so no //! bootstrap is needed). //! 3. `scan --mode hosted --json --vex` (the real binary): bun.lock now -//! pins the hosted URL + the patched sha512, the ledger embeds the -//! record, the in-run VEX is the `(redirected)` attestation. +//! pins the hosted URL + the patched sha512 and keeps its own +//! lockfileVersion line, the ledger embeds the record, the in-run VEX +//! is the `(redirected)` attestation. //! 4. FRESH-CHECKOUT PROOF: only package.json + bun.lock + .socket/ travel; //! `bun install --frozen-lockfile` with a fresh `BUN_INSTALL_CACHE_DIR` -//! MUST install the patched bytes from the hosted tarball. +//! MUST install the patched bytes from the hosted tarball. Then the +//! ORDINARY install (`node_modules` removed, another empty cache, plain +//! `bun install`) MUST leave bun.lock byte-identical — frozen mode +//! never writes the lock, so only a plain install can observe bun +//! re-serializing the URL tuple (the backtest's `ordinaryStableLock` +//! is the matrix twin) — and land the marker bytes again. //! -//! The negative twin serves TAMPERED tarball bytes while the lock keeps the -//! real sha512: the fresh frozen install MUST fail with an integrity error. +//! The rollback leg continues from step 4: `rollback --yes` must restore +//! bun.lock byte-for-byte to the pre-redirect snapshot, delete the redirect +//! ledger, and a fresh frozen install of the restored lock must produce the +//! ORIGINAL registry bytes (marker gone). +//! +//! The negative twin serves TAMPERED tarball bytes (a different, valid +//! tarball) while the lock keeps the real sha512. Bun verifies URL-tarball +//! digests only from 1.3.10 (`Integrity check failed`): there the fresh +//! frozen install MUST fail, and on every older text-lock bun it MUST +//! succeed and install the tampered bytes (reported PARTIAL) — the boundary +//! is pinned from both sides as [`TARBALL_INTEGRITY_ENFORCED_FROM`]. The +//! vendored twin lives in `e2e_vendor_bun_build.rs`. //! //! The get-driven twin (v3.6) runs step 3 as `get --mode hosted` //! instead of `scan --mode hosted` — same hosted engine by construction //! (get routes through scan's `run_redirect_selected`), so the lock/ledger //! assertions and the fresh-checkout proof are shared via [`HostedDriver`]. //! +//! The scoped leg patches a DIFFERENT target: `@scope/pkg@1.0.0`, a scoped +//! package with `dependencies` and a `bin`, served by a wiremock npm +//! registry through bun's `[install.scopes]` (a private scoped registry — +//! the common real-world shape). Bun records it as +//! `["@scope/pkg@1.0.0", "", { "dependencies": {…}, "bin": {…} +//! }, "sha512-…"]`; the rewrite must carry that meta object VERBATIM into +//! the URL 3-tuple, and the fresh install must prove bun honored it: the +//! dependency installs and the bin is linked. A meta-dropping regression is +//! silent under every left-pad leg (bun installs a `{}`-meta tuple with +//! exit 0, patched bytes and a stable lock — and no deps, no bin). +//! +//! The lock-v1-on-newer-bun leg is the one cross-version install proof a +//! single binary can give: on bun ≥ 1.4 (native lockfileVersion 2) the +//! fixture lock is relabelled to `"lockfileVersion": 1` before the rewrite +//! (the lock a 1.3.x bun wrote — grammar-identical, `configVersion` kept), +//! because bun 1.4 reads such a lock and never bumps it in place, so an +//! upgraded team keeps installing the redirect from their committed v1 +//! lock. (The former forced-v2 leg proved nothing distinct: on ≥ 1.4 it +//! was the native lock, below 1.4 unreadable.) +//! //! `bun.lockb` (bun's legacy binary lockfile) auto-migration is NOT exercised -//! here: bun 1.3.x writes the text `bun.lock` by default and offers no flag to -//! emit the binary form, so a real lockb fixture cannot be generated on this -//! toolchain. That migration branch is covered by the in-process shim test -//! `scan_redirect_migrates_bun_lockb_then_redirects` in -//! `tests/in_process_redirect.rs`. +//! here: every bun ≥ 1.2 writes the text `bun.lock` by default and offers no +//! flag to emit the binary form, so a real lockb fixture cannot be generated +//! on the toolchains this suite is wired to. That branch is covered by the +//! in-process shim tests `scan_redirect_migrates_bun_lockb_then_redirects` +//! and siblings in `tests/in_process_redirect.rs`, and against real bun +//! 1.1.45 (lockb by default, migration-capable) by the bun-compatibility +//! native matrix (`scripts/backtest-bun.py`). //! -//! Skips (with a println) when `bun`/`tar` are missing or the fixture install -//! cannot reach the registry; every assertion after is hard. +//! Gates: without `SOCKET_PATCH_BUN_E2E_REQUIRED` (set AND non-empty — CI +//! passes an empty string for non-bun legs) a missing `bun`, a failed +//! fixture install or a bun without a text lockfile is a `println` SKIP and +//! every assertion after that is HARD. With it, those skips become hard +//! failures, and `SOCKET_PATCH_BUN_E2E_VERSION` (when set, non-empty) must +//! equal `bun --version`, so a CI leg cannot pass by running the wrong bun +//! or no bun at all. use std::path::{Path, PathBuf}; use std::process::{Command, Output, Stdio}; @@ -52,54 +104,197 @@ mod cache_env; const ORG: &str = "test-org"; const DEP: &str = "left-pad"; const DEP_VERSION: &str = "1.3.0"; -const PURL: &str = "pkg:npm/left-pad@1.3.0"; const UUID: &str = "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d"; const TOKEN: &str = "22222222-2222-4222-8222-222222222222"; const MARKER: &str = "/* SOCKET-PATCHED */\n"; +/// Content of the tampered twin's served tarball — distinct from the +/// pristine AND the patched bytes so "bun installed the tampered bytes" is +/// a real assertion, not a trailing-byte no-op. +const TAMPER_MARKER: &str = "/* SOCKET-TAMPERED */\n"; const GHSA: &str = "GHSA-redirect-bun-real"; const PRODUCT: &str = "pkg:npm/app@1.0.0"; +/// The scoped, dependency-bearing target of the meta-preserving leg. It +/// exists only in the wiremock registry this suite runs; bun fetches it +/// through `[install.scopes]` and left-pad (its one dependency) from the +/// real registry like every other fixture. +const SCOPED_NAME: &str = "@scope/pkg"; +const SCOPED_VERSION: &str = "1.0.0"; +const SCOPED_BIN: &str = "scope-pkg"; +const SCOPED_INDEX: &[u8] = b"module.exports = require('left-pad');\n"; +/// The meta object bun writes for it, byte-exact (bun serializes +/// `dependencies` before `bin`; identical on lockfileVersion 0, 1 and 2). +/// The rewrite must carry this into the 3-tuple verbatim. +const SCOPED_META: &str = + r#"{ "dependencies": { "left-pad": "1.3.0" }, "bin": { "scope-pkg": "bin/cli.js" } }"#; + +/// `(major, minor, patch)` of the bun on PATH. +type BunVersion = (u64, u64, u64); + +/// First bun that verifies the sha512 of URL / local-tarball tuples on +/// install (1.3.9 installs a mismatched tarball with exit 0; 1.3.10 fails +/// with `Integrity check failed`). NOT 1.3.14 — that figure came from a +/// matrix that sampled only 1.3.0 and 1.3.14. Registry 4-tuples are +/// verified from 1.2.0 and are not what the hosted rewrite produces. +const TARBALL_INTEGRITY_ENFORCED_FROM: BunVersion = (1, 3, 10); +/// First bun with a text lockfile (`--save-text-lockfile` opt-in, +/// lockfileVersion 0). Older bun writes only the binary `bun.lockb`. +const TEXT_LOCK_FROM: BunVersion = (1, 1, 39); +/// Text lock becomes the default and bumps to lockfileVersion 1. +const LOCK_V1_FROM: BunVersion = (1, 2, 0); +/// lockfileVersion 2. +const LOCK_V2_FROM: BunVersion = (1, 4, 0); + // ── self-contained helpers ──────────────────────────────────────────── fn binary() -> PathBuf { PathBuf::from(env!("CARGO_BIN_EXE_socket-patch")) } -fn has_command(cmd: &str) -> bool { - let mut probe = Command::new(cmd); +/// The REQUIRED gate: set AND non-empty. CI's e2e matrix passes +/// `SOCKET_PATCH_BUN_E2E_REQUIRED: ${{ matrix.bun != '' && '1' || '' }}`, +/// so an empty value is the non-bun legs' "unset" — an `is_some()` gate +/// would turn every non-bun leg red. +fn bun_required() -> bool { + std::env::var_os("SOCKET_PATCH_BUN_E2E_REQUIRED").is_some_and(|v| !v.is_empty()) +} + +/// The exact bun the matrix leg pinned, when it pinned one. +fn pinned_bun_version() -> Option { + std::env::var("SOCKET_PATCH_BUN_E2E_VERSION") + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) +} + +/// `1.4.2` → `(1, 4, 2)`; a canary suffix (`1.4.3-canary.12+abc`) is cut at +/// the first `-`/`+`. `None` for anything that is not three integers. +fn parse_bun_version(raw: &str) -> Option { + let core = raw.trim().split(['-', '+']).next()?; + let mut parts = core.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next()?.parse().ok()?; + let patch = parts.next()?.parse().ok()?; + if parts.next().is_some() { + return None; + } + Some((major, minor, patch)) +} + +/// `bun --version` through the cache sandbox: `Some(trimmed stdout)` when +/// bun ran and exited 0, `None` when it is not on PATH (or cannot start). +fn bun_version_output() -> Option { + let mut probe = Command::new("bun"); probe.arg("--version"); + cache_env::scrub_ambient_bun_env(&mut probe); cache_env::isolate(&mut probe); - probe - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) -} - -fn scrub_socket_env(cmd: &mut Command) { - for (k, _) in std::env::vars_os() { - if k.to_string_lossy().starts_with("SOCKET_") && k.to_string_lossy() != "SOCKET_NO_CONFIG" { - cmd.env_remove(&k); - } + let out = probe.stderr(Stdio::null()).output().ok()?; + out.status + .success() + .then(|| String::from_utf8_lossy(&out.stdout).trim().to_string()) +} + +/// The lockfileVersion the era table says this bun writes for a FRESH +/// install: 1.1.39–1.1.x opt-in text lock → 0, 1.2–1.3 → 1, ≥ 1.4 → 2. +fn expected_lock_version(v: BunVersion) -> u64 { + if v >= LOCK_V2_FROM { + 2 + } else if v >= LOCK_V1_FROM { + 1 + } else { + 0 + } +} + +/// The fixture's `bun install` argv: lifecycle scripts never run (hygiene — +/// left-pad has none, but the fixture is a REAL registry install), and +/// `--save-text-lockfile` is passed only where the text lock is still an +/// opt-in (< 1.2.0), so newer bun is exercised exactly as users run it. +fn fixture_install_args(v: BunVersion) -> Vec<&'static str> { + let mut args = vec!["install", "--ignore-scripts"]; + if v < LOCK_V1_FROM { + args.push("--save-text-lockfile"); + } + args +} + +/// `"lockfileVersion": ` from the lock head — the same head scan as +/// `socket_patch_core::vendor::bun_lock_text::lock_version` (pub(crate) +/// there, so mirrored here). +fn lock_version(text: &str) -> Option { + text.lines() + .take(5) + .find_map(|line| line.trim().strip_prefix("\"lockfileVersion\":")) + .and_then(|rest| rest.trim().trim_end_matches(',').parse().ok()) +} + +/// The toolchain preflight every leg runs first: bun present, pinned +/// version honored, text lockfile available. `None` = this leg is skipped +/// (already reported with a println) — but under the REQUIRED gate every +/// one of those is a hard failure instead, because a CI leg that silently +/// skips is exactly the vacuous pass this suite had for months. +fn bun_toolchain(tag: &str) -> Option<(String, BunVersion)> { + let Some(raw) = bun_version_output() else { + assert!( + !bun_required(), + "SOCKET_PATCH_BUN_E2E_REQUIRED is set but `bun --version` did not run — \ + the matrix leg must install bun before running this suite" + ); + println!("SKIP e2e_redirect_bun_build ({tag}): `bun` not installed"); + return None; + }; + if let Some(pin) = pinned_bun_version() { + assert_eq!( + raw, pin, + "SOCKET_PATCH_BUN_E2E_VERSION pins bun {pin} but PATH resolves bun {raw}: the \ + matrix must run the pinned version" + ); + } + let Some(version) = parse_bun_version(&raw) else { + assert!( + !bun_required(), + "required bun toolchain reports an unparsable version {raw:?}" + ); + println!("SKIP e2e_redirect_bun_build ({tag}): unparsable `bun --version` output {raw:?}"); + return None; + }; + if version < TEXT_LOCK_FROM { + assert!( + !bun_required(), + "bun {raw} has no text lockfile (the `--save-text-lockfile` opt-in exists from \ + 1.1.39); a REQUIRED leg must not be scheduled on it" + ); + println!( + "SKIP e2e_redirect_bun_build ({tag}): bun {raw} predates the text bun.lock (1.1.39)" + ); + return None; } - cmd.env_remove("VIRTUAL_ENV"); - cmd.env_remove("BUN_INSTALL_CACHE_DIR"); + Some((raw, version)) } +/// Run `bun ` in `cwd` with the given private cache dir, the shared +/// cache sandbox for everything else bun keeps outside it, and the ambient +/// env scrubbed by the scrub the three bun suites share +/// (`cache_env::scrub_ambient_bun_env`: `SOCKET_*`, every `BUN_*`, +/// case-insensitive `npm_config_*` — an ambient registry mirror would put +/// the mirror tarball URL in the 4-tuple's registry slot and fail the +/// pre-rewrite assertions). Scrub BEFORE seeding: `Command`'s last env call +/// for a name wins, and the scrub removes `BUN_INSTALL_CACHE_DIR`. fn bun(cwd: &Path, args: &[&str], cache_dir: &Path) -> Output { let mut cmd = Command::new("bun"); cmd.args(args).current_dir(cwd); - scrub_socket_env(&mut cmd); + cache_env::scrub_ambient_bun_env(&mut cmd); cache_env::isolate(&mut cmd); cmd.env("BUN_INSTALL_CACHE_DIR", cache_dir); cmd.output().expect("failed to run bun") } +/// The real binary with `--no-telemetry` appended: nothing in this suite +/// should ever post a telemetry event, mocked API or not. fn run_socket(cwd: &Path, args: &[&str]) -> (i32, String, String) { let mut cmd = Command::new(binary()); - cmd.args(args).current_dir(cwd); - scrub_socket_env(&mut cmd); + cmd.args(args).arg("--no-telemetry").current_dir(cwd); + cache_env::scrub_ambient_bun_env(&mut cmd); let out = cmd.output().expect("failed to run socket-patch binary"); ( out.status.code().unwrap_or(-1), @@ -113,6 +308,10 @@ fn sha512_sri_b64(bytes: &[u8]) -> String { base64::engine::general_purpose::STANDARD.encode(Sha512::digest(bytes)) } +fn sri(bytes: &[u8]) -> String { + format!("sha512-{}", sha512_sri_b64(bytes)) +} + fn copy_dir_recursive(src: &Path, dst: &Path) { std::fs::create_dir_all(dst).unwrap(); for entry in std::fs::read_dir(src).unwrap() { @@ -126,14 +325,216 @@ fn copy_dir_recursive(src: &Path, dst: &Path) { } } +/// A gzipped npm tarball from `(entry name under package/, bytes, mode)` +/// triples, built with the tar crate so the suite has no system-`tar` +/// dependency (Windows runners included). +fn build_tgz(entries: &[(String, Vec, u32)]) -> Vec { + let mut builder = tar::Builder::new(flate2::write::GzEncoder::new( + Vec::new(), + flate2::Compression::default(), + )); + for (name, bytes, mode) in entries { + let mut header = tar::Header::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(*mode); + header.set_cksum(); + builder + .append_data(&mut header, format!("package/{name}"), bytes.as_slice()) + .unwrap(); + } + builder.into_inner().unwrap().finish().unwrap() +} + +/// A VALID npm tarball built from the ACTUALLY-installed package with the +/// entry point swapped for `replaced_index`. File modes travel as installed +/// (the scoped target's `bin/cli.js` keeps its exec bit); on Windows, where +/// there is no mode, `bin/` entries are marked executable. +fn make_tgz_from_installed(pkg_dir: &Path, replaced_index: &[u8]) -> Vec { + let pkg_dir = pkg_dir + .canonicalize() + .expect("installed package dir must resolve"); + let mut files: Vec = Vec::new(); + let mut stack = vec![pkg_dir.clone()]; + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(&dir).unwrap() { + let p = entry.unwrap().path(); + if p.is_dir() { + stack.push(p); + } else { + files.push(p); + } + } + } + files.sort(); + let entries: Vec<(String, Vec, u32)> = files + .iter() + .map(|p| { + let rel = p.strip_prefix(&pkg_dir).unwrap(); + // Tar entry names always use `/` regardless of host separator. + let name = rel + .components() + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .collect::>() + .join("/"); + let bytes = if rel == Path::new("index.js") { + replaced_index.to_vec() + } else { + std::fs::read(p).unwrap() + }; + (name.clone(), bytes, file_mode(p, &name)) + }) + .collect(); + build_tgz(&entries) +} + +#[cfg(unix)] +fn file_mode(p: &Path, _name: &str) -> u32 { + use std::os::unix::fs::PermissionsExt as _; + std::fs::metadata(p).unwrap().permissions().mode() & 0o777 +} + +#[cfg(not(unix))] +fn file_mode(_p: &Path, name: &str) -> u32 { + if name.starts_with("bin/") { + 0o755 + } else { + 0o644 + } +} + +/// The scoped target's registry tarball: package.json with the dependency +/// and the bin, the entry point, and the (executable) bin script. +fn scoped_registry_tgz() -> Vec { + let pkg_json = serde_json::json!({ + "name": SCOPED_NAME, + "version": SCOPED_VERSION, + "main": "index.js", + "dependencies": { DEP: DEP_VERSION }, + "bin": { SCOPED_BIN: "bin/cli.js" }, + }); + build_tgz(&[ + ( + "package.json".into(), + serde_json::to_vec_pretty(&pkg_json).unwrap(), + 0o644, + ), + ("index.js".into(), SCOPED_INDEX.to_vec(), 0o644), + ( + "bin/cli.js".into(), + b"#!/usr/bin/env node\nconsole.log('scope-pkg cli');\n".to_vec(), + 0o755, + ), + ]) +} + +/// A wiremock npm registry for the scoped target: the packument (bun asks +/// for `/@scope%2fpkg`) and the tarball it points at. Integrity only — bun +/// verifies the sha512 and needs no `shasum`. +async fn mount_scoped_registry(server: &MockServer, tgz: Vec) { + let tarball_url = format!("{}/@scope/pkg/-/pkg-{SCOPED_VERSION}.tgz", server.uri()); + Mock::given(method("GET")) + .and(path_regex(r"^/@scope(%2[fF]|/)pkg$")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "name": SCOPED_NAME, + "dist-tags": { "latest": SCOPED_VERSION }, + "versions": { + SCOPED_VERSION: { + "name": SCOPED_NAME, + "version": SCOPED_VERSION, + "dependencies": { DEP: DEP_VERSION }, + "bin": { SCOPED_BIN: "bin/cli.js" }, + "dist": { "tarball": tarball_url, "integrity": sri(&tgz) } + } + } + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path(format!("/@scope/pkg/-/pkg-{SCOPED_VERSION}.tgz"))) + .respond_with(ResponseTemplate::new(200).set_body_raw(tgz, "application/octet-stream")) + .mount(server) + .await; +} + +/// Which package the hosted rewrite targets. +#[derive(Clone, Copy, PartialEq, Debug)] +enum Target { + /// left-pad@1.3.0 from the real registry: unscoped, `{}` meta — the + /// original capstone target. + LeftPad, + /// `@scope/pkg@1.0.0` from the suite's wiremock registry via + /// `[install.scopes]`: scoped key, non-empty `{dependencies, bin}` meta. + ScopedWithDeps, +} + +impl Target { + fn name(self) -> &'static str { + match self { + Target::LeftPad => DEP, + Target::ScopedWithDeps => SCOPED_NAME, + } + } + fn version(self) -> &'static str { + match self { + Target::LeftPad => DEP_VERSION, + Target::ScopedWithDeps => SCOPED_VERSION, + } + } + /// The PURL the CLI derives for it (`@` is percent-encoded in npm PURLs). + fn purl(self) -> &'static str { + match self { + Target::LeftPad => "pkg:npm/left-pad@1.3.0", + Target::ScopedWithDeps => "pkg:npm/%40scope/pkg@1.0.0", + } + } + /// The tarball file name on the hosted URL. + fn hosted_leaf(self) -> String { + match self { + Target::LeftPad => format!("{DEP}-{DEP_VERSION}.tgz"), + Target::ScopedWithDeps => format!("pkg-{SCOPED_VERSION}.tgz"), + } + } + /// The meta object bun writes for it, byte-exact. + fn meta(self) -> &'static str { + match self { + Target::LeftPad => "{}", + Target::ScopedWithDeps => SCOPED_META, + } + } + fn installed_dir(self, root: &Path) -> PathBuf { + let nm = root.join("node_modules"); + match self { + Target::LeftPad => nm.join(DEP), + Target::ScopedWithDeps => nm.join("@scope").join("pkg"), + } + } + fn package_json(self) -> String { + format!( + r#"{{"name":"redirect-bun-capstone","version":"0.0.0","private":true,"dependencies":{{"{}":"{}"}}}}"#, + self.name(), + self.version() + ) + } +} + struct BunRedirectFixture { tmp: tempfile::TempDir, proj: PathBuf, + target: Target, + /// The pristine installed `index.js`. + orig: Vec, + /// `MARKER` + orig — what the honest hosted tarball carries. patched: Vec, - /// True when the installed bun itself wrote `"lockfileVersion": 2` - /// (bun >= 1.4) — i.e. this toolchain can also READ a v2 lock, so the - /// fresh-checkout install proof is valid on a v2 lock. - native_lock_v2: bool, + /// `TAMPER_MARKER` + orig — what the tampered twin's route serves. + tampered: Vec, + /// bun.lock as it stood right before the hosted rewrite (after any + /// relabel) — the byte-exact rollback target. + lock_before: Vec, + /// The `"lockfileVersion"` the rewrite must preserve. + lock_version: u64, + /// `bun --version`, verbatim, for messages. + bun_raw: String, + bun_version: BunVersion, _server: MockServer, } @@ -154,42 +555,87 @@ enum HostedDriver { GetUuid, } +/// Which lock the hosted rewrite runs on. +#[derive(Clone, Copy, PartialEq)] +enum LockShape { + /// Whatever the installed bun wrote (asserted against the era table). + Native, + /// On bun ≥ 1.4 only: the native lockfileVersion-2 lock relabelled to 1 + /// (`configVersion` kept — the lock a 1.3.x bun wrote; dropping it + /// would make bun add `"configVersion": 0` on the first plain install, + /// bun's own migration and not ours). Below 1.4 the leg is PARTIAL: + /// one binary cannot be both the writer and the newer reader. + V1OnNewerBun, +} + +/// The `packages` line for `name` in a bun.lock (`"name": [...]`). +fn packages_line(lock: &str, name: &str) -> String { + let key = format!("\"{name}\": ["); + lock.lines() + .find(|l| l.trim_start().starts_with(&key)) + .unwrap_or_else(|| panic!("no packages entry for {name} in:\n{lock}")) + .to_string() +} + /// Steps 1–3: real install, patched tarball + API mocks, the `driver`'s /// hosted invocation, and the envelope/lockfile/ledger assertions. /// `tamper_served_tarball` serves DIFFERENT bytes than the sha512 pinned -/// into the lock. `force_lock_version` re-pins the fixture lock's -/// `"lockfileVersion"` line before the hosted run (sound because v1 and v2 -/// share one emitted grammar). `None` = skip. +/// into the lock. `None` = skip (already reported). async fn bun_hosted_project( tag: &str, tamper_served_tarball: bool, driver: HostedDriver, - force_lock_version: Option, + shape: LockShape, + target: Target, ) -> Option { - if !has_command("bun") { - println!("SKIP e2e_redirect_bun_build ({tag}): `bun` not installed"); - return None; - } - if !has_command("tar") { - println!("SKIP e2e_redirect_bun_build ({tag}): `tar` not installed"); + let (bun_raw, bun_version) = bun_toolchain(tag)?; + if shape == LockShape::V1OnNewerBun && bun_version < LOCK_V2_FROM { + println!( + "PARTIAL e2e_redirect_bun_build ({tag}): bun {bun_raw} writes lockfileVersion {} \ + itself, so the newer-bun-on-a-v1-lock scenario needs bun >= 1.4 — leg not \ + applicable on this toolchain", + expected_lock_version(bun_version) + ); return None; } let tmp = tempfile::tempdir().unwrap(); let proj = tmp.path().join("proj"); std::fs::create_dir_all(&proj).unwrap(); - std::fs::write( - proj.join("package.json"), - format!( - r#"{{"name":"redirect-bun-capstone","version":"0.0.0","private":true,"dependencies":{{"{DEP}":"{DEP_VERSION}"}}}}"# - ), - ) - .unwrap(); + std::fs::write(proj.join("package.json"), target.package_json()).unwrap(); + + // One wiremock serves everything: the scoped target's npm registry (up + // before the fixture install), the patch API, and the hosted tarball. + let server = MockServer::start().await; + let mut registry_field = String::new(); + if target == Target::ScopedWithDeps { + let registry_tgz = scoped_registry_tgz(); + mount_scoped_registry(&server, registry_tgz).await; + // bun's scoped-registry config — a committable file, so it travels + // with every fresh checkout below. + std::fs::write( + proj.join("bunfig.toml"), + format!( + "[install.scopes]\n\"@scope\" = {{ url = \"{}/\" }}\n", + server.uri() + ), + ) + .unwrap(); + // For a non-default registry bun records the TARBALL URL as the + // 4-tuple's registry field. + registry_field = format!("{}/@scope/pkg/-/pkg-{SCOPED_VERSION}.tgz", server.uri()); + } // 1. REAL fixture: bun install (network here, private cache). Text lockfile. let cache = tmp.path().join("bun-cache"); - let install = bun(&proj, &["install", "--save-text-lockfile"], &cache); + let install = bun(&proj, &fixture_install_args(bun_version), &cache); if !install.status.success() { + assert!( + !bun_required(), + "required bun {bun_raw} fixture `bun install` failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&install.stdout), + String::from_utf8_lossy(&install.stderr) + ); println!( "SKIP e2e_redirect_bun_build ({tag}): fixture `bun install` failed (registry \ unreachable?):\n{}", @@ -197,78 +643,124 @@ async fn bun_hosted_project( ); return None; } - if !proj.join("bun.lock").is_file() { + let lock_path = proj.join("bun.lock"); + if !lock_path.is_file() { + assert!( + !bun_required(), + "required bun {bun_raw} produced no text bun.lock after {:?}", + fixture_install_args(bun_version) + ); println!( "SKIP e2e_redirect_bun_build ({tag}): bun produced no text bun.lock (binary \ lockfile?)" ); return None; } - let native_lock = std::fs::read_to_string(proj.join("bun.lock")).unwrap(); - let native_lock_v2 = native_lock.contains("\"lockfileVersion\": 2"); - if let Some(v) = force_lock_version { - // Splice ONLY the version line (v1 and v2 share one emitted grammar). - let forced: String = native_lock - .split_inclusive('\n') - .map(|line| { - if line.trim_start().starts_with("\"lockfileVersion\":") { - format!(" \"lockfileVersion\": {v},\n") - } else { - line.to_string() - } - }) - .collect(); - assert!( - forced.contains(&format!("\"lockfileVersion\": {v},")), - "the fixture lock must carry a lockfileVersion line to force:\n{native_lock}" - ); - std::fs::write(proj.join("bun.lock"), forced).unwrap(); - } + // Hermeticity guard: the install must have gone through the PRIVATE + // cache, or the fresh-checkout "empty cache" premise below is void. + assert!( + cache.is_dir() && std::fs::read_dir(&cache).unwrap().next().is_some(), + "fixture install did not populate the private BUN_INSTALL_CACHE_DIR at {}", + cache.display() + ); + let native_lock = std::fs::read_to_string(&lock_path).unwrap(); + // The era table, asserted rather than assumed: 1.1.39–1.1.x opt-in + // text lock → 0, 1.2–1.3 → 1, ≥ 1.4 → 2. Pinning the mapping is what + // makes a lock-era CI leg prove the era it claims to cover. + let native_version = lock_version(&native_lock).unwrap_or_else(|| { + panic!("fixture bun.lock has no integer lockfileVersion in its head:\n{native_lock}") + }); + assert_eq!( + native_version, + expected_lock_version(bun_version), + "bun {bun_raw} wrote lockfileVersion {native_version}; the era table expects {} \ + (1.1.39–1.1.x → 0, 1.2–1.3 → 1, ≥ 1.4 → 2):\n{native_lock}", + expected_lock_version(bun_version) + ); + // Pre-redirect: the registry 4-tuple, with bun's real registry field + // and meta object for this target — one spelling across 0/1/2. + let registry_tuple_head = format!( + "\"{}@{}\", \"{registry_field}\", {}, \"sha512-", + target.name(), + target.version(), + target.meta() + ); + assert!( + native_lock.contains(®istry_tuple_head), + "pre-redirect packages entry must be the registry 4-tuple {registry_tuple_head}…:\n\ + {native_lock}" + ); + let lock_version = match shape { + LockShape::Native => native_version, + LockShape::V1OnNewerBun => { + // Splice ONLY the version line (v1 and v2 share one emitted + // grammar; `configVersion` stays, see `LockShape`). + let relabelled: String = native_lock + .split_inclusive('\n') + .map(|line| { + if line.trim_start().starts_with("\"lockfileVersion\":") { + " \"lockfileVersion\": 1,\n".to_string() + } else { + line.to_string() + } + }) + .collect(); + assert_eq!( + lock_version(&relabelled), + Some(1), + "the relabelled fixture lock must read back as lockfileVersion 1:\n{relabelled}" + ); + std::fs::write(&lock_path, relabelled).unwrap(); + 1 + } + }; + let lock_before = std::fs::read(&lock_path).unwrap(); + let lock_before_str = String::from_utf8(lock_before.clone()).unwrap(); - let installed_dir = proj.join("node_modules").join(DEP); + let installed_dir = target.installed_dir(&proj); let orig = std::fs::read(installed_dir.join("index.js")).expect("installed index.js"); assert!( !orig.starts_with(MARKER.as_bytes()), "pristine install must not carry the marker" ); + if target == Target::ScopedWithDeps { + assert_eq!( + orig, SCOPED_INDEX, + "bun must have installed the mock registry's bytes" + ); + } let patched: Vec = [MARKER.as_bytes(), orig.as_slice()].concat(); + let tampered: Vec = [TAMPER_MARKER.as_bytes(), orig.as_slice()].concat(); // 2. Patched tarball from the installed package; its sha512 is the pin. - let stage = tmp.path().join("tarstage"); - copy_dir_recursive(&installed_dir, &stage.join("package")); - std::fs::write(stage.join("package").join("index.js"), &patched).unwrap(); - let tgz_path = tmp.path().join(format!("{DEP}-{DEP_VERSION}.tgz")); - let tar = Command::new("tar") - .args(["-czf", tgz_path.to_str().unwrap(), "package"]) - .current_dir(&stage) - .output() - .expect("failed to run tar"); - assert!( - tar.status.success(), - "tar failed: {}", - String::from_utf8_lossy(&tar.stderr) - ); - let tgz = std::fs::read(&tgz_path).unwrap(); - let sri = format!("sha512-{}", sha512_sri_b64(&tgz)); + // The negative twin only tampers what the route SERVES (a different, + // still-valid tarball), so the pin is what catches the swap. + let tgz = make_tgz_from_installed(&installed_dir, &patched); + let patched_sri = sri(&tgz); let served: Vec = if tamper_served_tarball { - [tgz.as_slice(), &[0u8][..]].concat() + let tampered_tgz = make_tgz_from_installed(&installed_dir, &tampered); + assert_ne!(tampered_tgz, tgz, "the tampered tarball must differ"); + tampered_tgz } else { tgz.clone() }; // 3. API mocks + the hosted tarball route bun will hit at install time. - let server = MockServer::start().await; - let hosted_url = format!( - "{}/patch/npm/{DEP}/{DEP_VERSION}/{TOKEN}/{UUID}/{DEP}-{DEP_VERSION}.tgz", - server.uri() + let purl = target.purl(); + let hosted_path = format!( + "/patch/npm/{}/{}/{TOKEN}/{UUID}/{}", + target.name(), + target.version(), + target.hosted_leaf() ); + let hosted_url = format!("{}{hosted_path}", server.uri()); Mock::given(method("POST")) .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "packages": [{ - "purl": PURL, + "purl": purl, "patches": [{ - "uuid": UUID, "purl": PURL, "tier": "free", + "uuid": UUID, "purl": purl, "tier": "free", "cveIds": [], "ghsaIds": [], "severity": "high", "title": "redirect bun capstone fixture" }] @@ -283,7 +775,7 @@ async fn bun_hosted_project( ))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "patches": [{ - "uuid": UUID, "purl": PURL, + "uuid": UUID, "purl": purl, "publishedAt": "2026-01-01T00:00:00Z", "description": "x", "license": "MIT", "tier": "free", "vulnerabilities": {} @@ -299,11 +791,11 @@ async fn bun_hosted_project( UUID: { "status": "granted", "url": hosted_url, - "purl": PURL, + "purl": purl, "artifacts": [{ "kind": "tarball", "url": hosted_url, - "integrity": { "sha512": sri } + "integrity": { "sha512": patched_sri } }], "registryOverride": null } @@ -315,7 +807,7 @@ async fn bun_hosted_project( .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "uuid": UUID, - "purl": PURL, + "purl": purl, "publishedAt": "2026-01-01T00:00:00Z", "files": { "package/index.js": { @@ -334,9 +826,7 @@ async fn bun_hosted_project( .mount(&server) .await; Mock::given(method("GET")) - .and(path(format!( - "/patch/npm/{DEP}/{DEP_VERSION}/{TOKEN}/{UUID}/{DEP}-{DEP_VERSION}.tgz" - ))) + .and(path(hosted_path.clone())) .respond_with(ResponseTemplate::new(200).set_body_raw(served, "application/octet-stream")) .mount(&server) .await; @@ -425,7 +915,7 @@ async fn bun_hosted_project( ); assert_eq!(stmts[0]["status"], "not_affected", "vex doc: {vex_doc}"); assert_eq!( - stmts[0]["products"][0]["subcomponents"][0]["@id"], PURL, + stmts[0]["products"][0]["subcomponents"][0]["@id"], purl, "vex doc: {vex_doc}" ); assert_eq!( @@ -445,11 +935,11 @@ async fn bun_hosted_project( "hosted get downloads/applies nothing — those keys must be absent: {env}" ); assert!( - !proj.join(".socket/manifest.json").exists(), + !proj.join(".socket").join("manifest.json").exists(), "get --mode hosted must NOT write the manifest" ); assert!( - !proj.join(".socket/blobs").exists(), + !proj.join(".socket").join("blobs").exists(), "get --mode hosted must NOT persist blobs" ); // Anti-vacuity oracle: the grant really came from the reference @@ -468,24 +958,38 @@ async fn bun_hosted_project( } } - // Lockfile pin: the hosted URL (as the tuple spec) + the patched sha512. - let lock = std::fs::read_to_string(proj.join("bun.lock")).unwrap(); + // Lockfile pin: the URL 3-tuple — hosted URL as the tuple spec, the + // meta object carried VERBATIM, the patched sha512 — with the registry + // 4-tuple gone and the lock's own version line kept. + let lock = std::fs::read_to_string(&lock_path).unwrap(); + let url_tuple = format!( + "\"{}@{hosted_url}\", {}, \"{patched_sri}\"]", + target.name(), + target.meta() + ); assert!( - lock.contains(&format!("\"{DEP}@{hosted_url}\"")), - "bun.lock tuple spec must be name@; got:\n{lock}" + lock.contains(&url_tuple), + "bun.lock packages entry must be the URL 3-tuple {url_tuple}; got:\n{lock}" ); assert!( - lock.contains(&sri), - "bun.lock integrity must be the patched sha512 ({sri}); got:\n{lock}" + !lock.contains(®istry_tuple_head), + "the registry 4-tuple must be gone after the rewrite:\n{lock}" ); - if let Some(v) = force_lock_version { - assert!( - lock.contains(&format!("\"lockfileVersion\": {v},")), - "the rewrite must preserve the lockfileVersion line verbatim; got:\n{lock}" + assert_eq!( + self::lock_version(&lock), + Some(lock_version), + "the rewrite must preserve the lockfileVersion line verbatim; got:\n{lock}" + ); + if target == Target::ScopedWithDeps { + // The dependency's own registry entry is not the target: untouched. + assert_eq!( + packages_line(&lock, DEP), + packages_line(&lock_before_str, DEP), + "the un-patched dependency's registry 4-tuple must be byte-identical:\n{lock}" ); } - let ledger = std::fs::read_to_string(proj.join(".socket/vendor/redirect-state.json")).unwrap(); + let ledger = std::fs::read_to_string(redirect_ledger(&proj)).unwrap(); assert!( ledger.contains("\"records\"") && ledger.contains(GHSA), "redirect ledger must embed the patch record + vulnerability: {ledger}" @@ -494,43 +998,89 @@ async fn bun_hosted_project( Some(BunRedirectFixture { tmp, proj, + target, + orig, patched, - native_lock_v2, + tampered, + lock_before, + lock_version, + bun_raw, + bun_version, _server: server, }) } -/// Fresh dir with only the committable files, then `bun install -/// --frozen-lockfile` against an empty cache. -fn fresh_checkout_bun_install(fx: &BunRedirectFixture) -> (PathBuf, Output) { - let fresh = fx.tmp.path().join("fresh"); +fn redirect_ledger(proj: &Path) -> PathBuf { + proj.join(".socket") + .join("vendor") + .join("redirect-state.json") +} + +/// Fresh dir `/` with only the committable files (package.json, +/// bun.lock, bunfig.toml when the project has one, and `.socket/` when it +/// exists — rollback removes it). +fn fresh_checkout(fx: &BunRedirectFixture, name: &str) -> PathBuf { + let fresh = fx.tmp.path().join(name); std::fs::create_dir_all(&fresh).unwrap(); std::fs::copy(fx.proj.join("package.json"), fresh.join("package.json")).unwrap(); std::fs::copy(fx.proj.join("bun.lock"), fresh.join("bun.lock")).unwrap(); - copy_dir_recursive(&fx.proj.join(".socket"), &fresh.join(".socket")); - let fresh_cache = fx.tmp.path().join("fresh-bun-cache"); - let ci = bun(&fresh, &["install", "--frozen-lockfile"], &fresh_cache); - (fresh, ci) + if fx.proj.join("bunfig.toml").is_file() { + std::fs::copy(fx.proj.join("bunfig.toml"), fresh.join("bunfig.toml")).unwrap(); + } + if fx.proj.join(".socket").is_dir() { + copy_dir_recursive(&fx.proj.join(".socket"), &fresh.join(".socket")); + } + fresh } -// ── the capstone ────────────────────────────────────────────────────── +/// `bun install --frozen-lockfile` in a fresh checkout named `name` against +/// an empty cache. +fn fresh_frozen_install(fx: &BunRedirectFixture, name: &str) -> (PathBuf, Output) { + let fresh = fresh_checkout(fx, name); + let fresh_cache = fx.tmp.path().join(format!("{name}-bun-cache")); + let ci = bun( + &fresh, + &["install", "--frozen-lockfile", "--ignore-scripts"], + &fresh_cache, + ); + (fresh, ci) +} -// #[serial]: bun shares an on-disk cache/registry-metadata directory across -// installs of the same URL; serializing keeps the tampered twin from reusing -// the main leg's honest bytes (each leg also uses its own cache dir). -#[tokio::test(flavor = "multi_thread")] -#[serial_test::serial] -async fn bun_redirect_fresh_checkout_installs_patched_bytes() { - let Some(fx) = bun_hosted_project("main", false, HostedDriver::ScanVex, None).await else { - return; - }; - assert_patched_fresh_install(&fx); +/// For the scoped target: bun must have honored the meta object it read +/// from the URL 3-tuple — the dependency is installed and the bin linked +/// (as `node_modules/.bin/scope-pkg`, or its `.exe`/`.cmd` shims on +/// Windows). Neither happens when the meta is `{}`. +fn assert_scoped_meta_honored(fresh: &Path) { + assert!( + fresh + .join("node_modules") + .join(DEP) + .join("package.json") + .is_file(), + "bun must install the scoped package's `dependencies` from the 3-tuple meta" + ); + let bin_dir = fresh.join("node_modules").join(".bin"); + let linked = std::fs::read_dir(&bin_dir) + .map(|rd| { + rd.filter_map(|e| e.ok()) + .any(|e| e.file_name().to_string_lossy().starts_with(SCOPED_BIN)) + }) + .unwrap_or(false); + assert!( + linked, + "bun must link the scoped package's `bin` from the 3-tuple meta under {}", + bin_dir.display() + ); } /// Shared fresh-checkout proof: `bun install --frozen-lockfile` against an -/// empty cache must materialize the PATCHED bytes from the hosted tarball. +/// empty cache must materialize the PATCHED bytes from the hosted tarball; +/// then an ORDINARY `bun install` (node_modules removed, another empty +/// cache) must leave bun.lock byte-identical and land the marker again. +/// Frozen mode never writes the lock, so only the plain install can catch +/// bun re-serializing the URL tuple (backtest twin: `ordinaryStableLock`). fn assert_patched_fresh_install(fx: &BunRedirectFixture) { - let (fresh, ci) = fresh_checkout_bun_install(fx); + let (fresh, ci) = fresh_frozen_install(fx, "fresh"); assert!( ci.status.success(), "fresh-checkout `bun install --frozen-lockfile` must succeed from the hosted patch \ @@ -538,7 +1088,8 @@ fn assert_patched_fresh_install(fx: &BunRedirectFixture) { String::from_utf8_lossy(&ci.stdout), String::from_utf8_lossy(&ci.stderr), ); - let installed = std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")).unwrap(); + let installed_index = fx.target.installed_dir(&fresh).join("index.js"); + let installed = std::fs::read(&installed_index).unwrap(); assert!( installed.starts_with(MARKER.as_bytes()), "bun must install the PATCHED bytes from the hosted patch; got:\n{}", @@ -548,6 +1099,62 @@ fn assert_patched_fresh_install(fx: &BunRedirectFixture) { installed, fx.patched, "fresh install must be byte-identical to the patched content" ); + if fx.target == Target::ScopedWithDeps { + assert_scoped_meta_honored(&fresh); + } + eprintln!( + "FRESH INSTALL OK (bun {}, lockfileVersion {}, {:?})", + fx.bun_raw, fx.lock_version, fx.target + ); + + // Ordinary install: the lock must survive bun's own re-serialization. + let wired_lock = std::fs::read(fx.proj.join("bun.lock")).unwrap(); + std::fs::remove_dir_all(fresh.join("node_modules")).unwrap(); + let plain_cache = fx.tmp.path().join("fresh-plain-bun-cache"); + let plain = bun(&fresh, &["install", "--ignore-scripts"], &plain_cache); + assert!( + plain.status.success(), + "plain `bun install` on the redirected lock must succeed.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&plain.stdout), + String::from_utf8_lossy(&plain.stderr), + ); + assert_eq!( + std::fs::read(fresh.join("bun.lock")).unwrap(), + wired_lock, + "an ORDINARY `bun install` must leave the redirected bun.lock byte-identical \ + (re-serialization drift would churn every commit)" + ); + assert_eq!( + std::fs::read(&installed_index).unwrap(), + fx.patched, + "the ordinary install must land the patched bytes too" + ); + if fx.target == Target::ScopedWithDeps { + assert_scoped_meta_honored(&fresh); + } + eprintln!("PLAIN INSTALL LOCK-STABLE"); +} + +// ── the capstone ────────────────────────────────────────────────────── + +// #[serial]: bun shares an on-disk cache/registry-metadata directory across +// installs of the same URL; serializing keeps the tampered twin from reusing +// the main leg's honest bytes (each leg also uses its own cache dir). +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +async fn bun_redirect_fresh_checkout_installs_patched_bytes() { + let Some(fx) = bun_hosted_project( + "main", + false, + HostedDriver::ScanVex, + LockShape::Native, + Target::LeftPad, + ) + .await + else { + return; + }; + assert_patched_fresh_install(&fx); } /// get-driven twin (v3.6): `get --mode hosted` must land the SAME @@ -558,79 +1165,474 @@ fn assert_patched_fresh_install(fx: &BunRedirectFixture) { #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn bun_get_uuid_hosted_fresh_checkout_installs() { - let Some(fx) = bun_hosted_project("get-uuid", false, HostedDriver::GetUuid, None).await else { + let Some(fx) = bun_hosted_project( + "get-uuid", + false, + HostedDriver::GetUuid, + LockShape::Native, + Target::LeftPad, + ) + .await + else { return; }; + assert_patched_fresh_install(&fx); +} - let (fresh, ci) = fresh_checkout_bun_install(&fx); - assert!( - ci.status.success(), - "fresh-checkout `bun install --frozen-lockfile` must succeed from the hosted patch \ - tarball after `get --mode hosted`.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&ci.stdout), - String::from_utf8_lossy(&ci.stderr), - ); - let installed = std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")).unwrap(); +/// Scoped, dependency-bearing target: the rewrite must carry bun's +/// `{ "dependencies": …, "bin": … }` meta object verbatim into the URL +/// 3-tuple and leave the dependency's own registry entry alone; the fresh +/// install must prove bun honored that meta — left-pad installed, the bin +/// linked — on top of the patched bytes and the stable lock. +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +async fn bun_redirect_scoped_package_keeps_deps_and_bin_meta() { + let Some(fx) = bun_hosted_project( + "scoped-with-deps", + false, + HostedDriver::ScanVex, + LockShape::Native, + Target::ScopedWithDeps, + ) + .await + else { + return; + }; + assert_patched_fresh_install(&fx); +} + +/// Cross-version leg: a team on bun ≥ 1.4 keeps installing from the +/// lockfileVersion-1 lock their 1.3.x wrote — bun 1.4 reads it and never +/// bumps it in place — so the hosted rewrite must land on that lock, keep +/// `"lockfileVersion": 1`, frozen-install the patched bytes, and survive an +/// ordinary install byte-for-byte (no bump to 2). PARTIAL below bun 1.4: +/// one binary cannot be both the older writer and the newer reader. +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +async fn bun_redirect_lock_v1_on_newer_bun_fresh_checkout_installs_patched_bytes() { + let Some(fx) = bun_hosted_project( + "lock-v1-on-newer-bun", + false, + HostedDriver::ScanVex, + LockShape::V1OnNewerBun, + Target::LeftPad, + ) + .await + else { + return; + }; assert!( - installed.starts_with(MARKER.as_bytes()), - "bun must install the PATCHED bytes from the hosted patch; got:\n{}", - String::from_utf8_lossy(&installed[..installed.len().min(120)]) + fx.bun_version >= LOCK_V2_FROM && fx.lock_version == 1, + "leg precondition: bun {} (>= 1.4) on a relabelled lockfileVersion-1 lock", + fx.bun_raw ); + assert_patched_fresh_install(&fx); + // The plain install above left the lock byte-identical; say the version + // part out loud so a future "bun 1.x bumps v1 in place" shows up by name. + let lock = std::fs::read_to_string(fx.proj.join("bun.lock")).unwrap(); assert_eq!( - installed, fx.patched, - "fresh install must be byte-identical to the patched content" + lock_version(&lock), + Some(1), + "bun {} must not bump the committed lockfileVersion-1 lock:\n{lock}", + fx.bun_raw ); } -/// The bun 1.4 leg: `"lockfileVersion": 2` shares v1's emitted grammar (the -/// bump gates stricter parse checks — integrity hashes required for -/// off-registry npm tarballs, which our URL 3-tuple always carries), so the -/// redirect must rewrite a v2 lock exactly like a v1 lock. When the installed -/// bun is itself >= 1.4 (it WROTE v2 — older bun cannot read v2 locks), the -/// fresh-checkout frozen install must again produce the patched bytes. +/// Negative twin: the hosted route serves TAMPERED bytes (a different valid +/// tarball) while the lock pins the real sha512. From bun 1.3.10 the fresh +/// frozen install must refuse on the integrity check; earlier bun installs +/// the tampered bytes with exit 0 and the leg pins THAT (PARTIAL), so the +/// digest boundary is asserted from both sides across the lock-era legs. #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] -async fn bun_redirect_lock_v2_fresh_checkout_installs_patched_bytes() { - let Some(fx) = bun_hosted_project("lock-v2", false, HostedDriver::ScanVex, Some(2)).await else { +async fn bun_redirect_tampered_hosted_tarball_digest_boundary() { + let Some(fx) = bun_hosted_project( + "tampered", + true, + HostedDriver::ScanVex, + LockShape::Native, + Target::LeftPad, + ) + .await + else { return; }; - if !fx.native_lock_v2 { + + let (fresh, ci) = fresh_frozen_install(&fx, "fresh-tampered"); + let chatter = format!( + "{}\n{}", + String::from_utf8_lossy(&ci.stdout), + String::from_utf8_lossy(&ci.stderr) + ); + if fx.bun_version >= TARBALL_INTEGRITY_ENFORCED_FROM { + assert!( + !ci.status.success(), + "bun {} install MUST fail when the served tarball does not match the pinned \ + sha512 (URL-tarball digests are enforced from 1.3.10).\n{chatter}", + fx.bun_raw + ); + let lower = chatter.to_lowercase(); + assert!( + lower.contains("integrity") + || lower.contains("checksum") + || lower.contains("hash") + || chatter.contains("IntegrityCheckFailed"), + "the failure must be the integrity check, not something incidental:\n{chatter}" + ); + eprintln!("TAMPER REJECTED OK (bun {})", fx.bun_raw); + } else { + assert!( + ci.status.success(), + "bun {} (< 1.3.10) does not verify URL-tarball digests, so the tampered install \ + must still exit 0 — a failure here means the boundary moved.\n{chatter}", + fx.bun_raw + ); + let installed = std::fs::read(fx.target.installed_dir(&fresh).join("index.js")).unwrap(); + assert_eq!( + installed, fx.tampered, + "bun {} installed neither the tampered bytes nor failed: the boundary model is wrong", + fx.bun_raw + ); println!( - "PARTIAL e2e_redirect_bun_build (lock-v2): installed bun writes lockfileVersion 1 \ - (< 1.4) and cannot read the forced v2 lock — rewrite proven, install proof skipped" + "PARTIAL e2e_redirect_bun_build (tampered): bun {} does not verify URL tarball \ + digests (enforced from 1.3.10) — rejection proof unavailable, acceptance pinned", + fx.bun_raw ); - return; } - assert_patched_fresh_install(&fx); } -/// Negative twin: the hosted route serves TAMPERED bytes while the lock pins -/// the real sha512 — the fresh frozen install must refuse. +/// Rollback leg: after the hosted rewrite and the fresh-checkout proof, +/// `rollback --yes` (unscoped — the whole-ledger reverse replay) must +/// restore bun.lock byte-for-byte to the pre-redirect snapshot and delete +/// the redirect ledger, and a fresh frozen install of the restored lock +/// must land the ORIGINAL registry bytes — the marker gone. #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] -async fn bun_redirect_tampered_hosted_tarball_fails_frozen_install() { - let Some(fx) = bun_hosted_project("tampered", true, HostedDriver::ScanVex, None).await else { +async fn bun_redirect_rollback_restores_lock_and_original_install() { + let Some(fx) = bun_hosted_project( + "rollback", + false, + HostedDriver::ScanVex, + LockShape::Native, + Target::LeftPad, + ) + .await + else { return; }; + assert_patched_fresh_install(&fx); + + let proj = &fx.proj; + let (code, stdout, stderr) = run_socket( + proj, + &[ + "rollback", + "--yes", + "--json", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "rollback failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let env: serde_json::Value = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("rollback --json output is not JSON: {e}\nstdout:\n{stdout}")); + assert_eq!(env["status"], "success", "rollback envelope: {env}"); + assert_eq!( + std::fs::read(proj.join("bun.lock")).unwrap(), + fx.lock_before, + "rollback must restore bun.lock byte-identical to the pre-redirect snapshot" + ); + assert!( + !redirect_ledger(proj).exists(), + "rollback must delete the redirect ledger" + ); + let restored = std::fs::read_to_string(proj.join("bun.lock")).unwrap(); + assert!( + restored.contains(&format!("\"{DEP}@{DEP_VERSION}\", \"\"")), + "the registry 4-tuple must be back after rollback:\n{restored}" + ); + eprintln!("ROLLBACK OK"); - let (_fresh, ci) = fresh_checkout_bun_install(&fx); + // The restored lock installs the ORIGINAL bytes from the registry. + let (fresh, ci) = fresh_frozen_install(&fx, "fresh-rolled-back"); assert!( - !ci.status.success(), - "bun install MUST fail when the served tarball does not match the pinned sha512.\n\ - stdout:\n{}\nstderr:\n{}", + ci.status.success(), + "fresh-checkout `bun install --frozen-lockfile` of the restored lock must \ + succeed.\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&ci.stdout), String::from_utf8_lossy(&ci.stderr), ); - let chatter = format!( - "{}\n{}", + let installed = std::fs::read(fx.target.installed_dir(&fresh).join("index.js")).unwrap(); + assert!( + !installed.starts_with(MARKER.as_bytes()), + "after rollback bun must install the ORIGINAL bytes, not the patch" + ); + assert_eq!( + installed, fx.orig, + "after rollback the fresh install must be byte-identical to the pristine package" + ); +} + +// ── digest-dropping lock re-saves (Bun 1.1.39–1.3.9) ───────────────── + +/// A local `file:` tarball dep added to `proj`'s package.json plus this +/// bun's ordinary install: the one network-free way to make bun RE-SAVE an +/// existing lock (a root rename does not; `bun add` needs the registry). +/// Returns the tarball's file name, which every fresh checkout below must +/// carry along. +fn grow_project_with_local_dep(fx: &BunRedirectFixture, cache_tag: &str) -> String { + let tgz_name = "local-dep-1.0.0.tgz".to_string(); + let tgz = build_tgz(&[ + ( + "package.json".to_string(), + br#"{"name":"local-dep","version":"1.0.0"}"#.to_vec(), + 0o644, + ), + ( + "index.js".to_string(), + b"module.exports = 'local';\n".to_vec(), + 0o644, + ), + ]); + std::fs::write(fx.proj.join(&tgz_name), tgz).unwrap(); + let pkg_path = fx.proj.join("package.json"); + let mut pkg: serde_json::Value = + serde_json::from_slice(&std::fs::read(&pkg_path).unwrap()).unwrap(); + pkg["dependencies"]["local-dep"] = serde_json::json!(format!("file:./{tgz_name}")); + std::fs::write(&pkg_path, serde_json::to_vec_pretty(&pkg).unwrap()).unwrap(); + let cache = fx.tmp.path().join(format!("{cache_tag}-bun-cache")); + let out = bun(&fx.proj, &fixture_install_args(fx.bun_version), &cache); + assert!( + out.status.success(), + "`bun install` after adding the local dep must succeed.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + let lock = std::fs::read_to_string(fx.proj.join("bun.lock")).unwrap(); + assert!( + lock.contains("\"local-dep\": ["), + "the re-save must have landed the local dep's entry:\n{lock}" + ); + tgz_name +} + +/// `bun install --frozen-lockfile` in a fresh checkout that also carries the +/// grown project's local tarball; returns the installed `index.js` bytes. +fn fresh_frozen_install_with_local_dep( + fx: &BunRedirectFixture, + name: &str, + tgz_name: &str, +) -> Vec { + let fresh = fresh_checkout(fx, name); + std::fs::copy(fx.proj.join(tgz_name), fresh.join(tgz_name)).unwrap(); + let cache = fx.tmp.path().join(format!("{name}-bun-cache")); + let ci = bun( + &fresh, + &["install", "--frozen-lockfile", "--ignore-scripts"], + &cache, + ); + assert!( + ci.status.success(), + "fresh-checkout `bun install --frozen-lockfile` ({name}) must succeed.\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&ci.stdout), String::from_utf8_lossy(&ci.stderr) ); + std::fs::read(fx.target.installed_dir(&fresh).join("index.js")).unwrap() +} + +/// Every text-lock bun below 1.3.10 re-saves our URL 3-tuple WITHOUT its +/// sha512 whenever the lock is re-saved for another reason (measured on +/// 1.1.45, 1.2.23 and 1.3.9; 1.3.10+ keep it). The digest-less 2-tuple is +/// still our wiring — the spec bun installs from is intact — so after a +/// real re-save: `rollback --dry-run` must resolve, the repeat hosted run +/// must report `redirected: 1` with no `redirect_bun_entry_not_found` and +/// heal the line back to the 3-tuple (a second ledger edit), a fresh +/// frozen install must land the patched bytes, and `rollback` must put the +/// registry line back inside the GROWN lock and install the original bytes. +/// On ≥ 1.3.10 the same steps prove the no-regression twin: digest kept, +/// repeat run a no-op, one ledger edit. +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +async fn bun_redirect_survives_a_digest_dropping_lock_resave() { + let Some(fx) = bun_hosted_project( + "digestless-resave", + false, + HostedDriver::ScanVex, + LockShape::Native, + Target::LeftPad, + ) + .await + else { + return; + }; + let proj = &fx.proj; + let lock_path = proj.join("bun.lock"); + let wired_line = packages_line(&std::fs::read_to_string(&lock_path).unwrap(), DEP); + assert!(wired_line.contains("\"sha512-"), "{wired_line}"); + let expect_drop = fx.bun_version < TARBALL_INTEGRITY_ENFORCED_FROM; + + // 1. Grow the project so bun re-saves the lock. + let tgz_name = grow_project_with_local_dep(&fx, "resave"); + let resaved = std::fs::read_to_string(&lock_path).unwrap(); + let live_line = packages_line(&resaved, DEP); + let digestless_spelling = format!( + "{}],", + &wired_line[..wired_line.rfind(", \"sha512-").unwrap()] + ); + if expect_drop { + assert_eq!( + live_line, digestless_spelling, + "bun {} (< 1.3.10) must re-save the URL tuple WITHOUT its sha512:\n{resaved}", + fx.bun_raw + ); + } else { + assert_eq!( + live_line, wired_line, + "bun {} (>= 1.3.10) must keep the URL tuple's sha512 on re-save:\n{resaved}", + fx.bun_raw + ); + } + eprintln!( + "RESAVE OK (bun {}, digest {})", + fx.bun_raw, + if expect_drop { "dropped" } else { "kept" } + ); + + // 2. The unwind must already resolve over the re-saved lock (dry run). + let (code, stdout, stderr) = run_socket( + proj, + &[ + "rollback", + "--dry-run", + "--yes", + "--json", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "rollback --dry-run over the re-saved lock must resolve.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert_eq!( + std::fs::read_to_string(&lock_path).unwrap(), + resaved, + "a dry run writes nothing" + ); + + // 3. Repeat hosted run: consistent envelope, digest healed (or a no-op). + let server_uri = fx._server.uri(); + let (code, stdout, stderr) = run_socket( + proj, + &[ + "scan", + "--mode", + "hosted", + "--json", + "--yes", + "--cwd", + proj.to_str().unwrap(), + "--api-url", + &server_uri, + "--org", + ORG, + "--api-token", + "fake", + ], + ); + assert_eq!( + code, 0, + "repeat scan --mode hosted failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let env: serde_json::Value = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("repeat scan output is not JSON: {e}\nstdout:\n{stdout}")); + assert_eq!(env["status"], "success", "{env:#}"); + assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + let codes: Vec<&str> = env["redirect"]["warnings"] + .as_array() + .into_iter() + .flatten() + .filter_map(|w| w["code"].as_str()) + .collect(); + assert!( + !codes.contains(&"redirect_bun_entry_not_found"), + "the digest-less spelling of our own wiring is not `entry_not_found`: {env:#}" + ); + let healed = std::fs::read_to_string(&lock_path).unwrap(); + assert_eq!( + packages_line(&healed, DEP), + wired_line, + "the repeat run must leave the canonical URL 3-tuple in place:\n{healed}" + ); assert!( - chatter.to_lowercase().contains("integrity") - || chatter.to_lowercase().contains("checksum") - || chatter.to_lowercase().contains("hash") - || chatter.contains("IntegrityCheckFailed"), - "the failure must be the integrity check, not something incidental:\n{chatter}" + healed.contains("\"local-dep\": ["), + "the grown entry survives" + ); + let ledger: serde_json::Value = + serde_json::from_slice(&std::fs::read(redirect_ledger(proj)).unwrap()).unwrap(); + let edits = ledger["edits"].as_array().unwrap(); + assert_eq!( + edits.len(), + if expect_drop { 2 } else { 1 }, + "the heal is recorded as a second edit exactly when the digest was dropped: {ledger:#}" + ); + if expect_drop { + assert_eq!( + edits[1]["original"], + serde_json::json!(digestless_spelling), + "{ledger:#}" + ); + assert_eq!(edits[1]["new"], serde_json::json!(wired_line), "{ledger:#}"); + } + eprintln!("REPEAT HOSTED RUN OK"); + + // 4. The healed lock installs the patched bytes from an empty cache. + let installed = fresh_frozen_install_with_local_dep(&fx, "fresh-healed", &tgz_name); + assert_eq!( + installed, fx.patched, + "the healed lock must install the patched bytes" + ); + + // 5. Rollback: registry line back inside the grown lock, ledger gone, + // original bytes on a fresh install. + let (code, stdout, stderr) = run_socket( + proj, + &[ + "rollback", + "--yes", + "--json", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "rollback failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let env: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(env["status"], "success", "{env:#}"); + let restored = std::fs::read_to_string(&lock_path).unwrap(); + let lock_before = String::from_utf8(fx.lock_before.clone()).unwrap(); + assert_eq!( + packages_line(&restored, DEP), + packages_line(&lock_before, DEP), + "the pristine registry 4-tuple must be back:\n{restored}" + ); + assert!( + restored.contains("\"local-dep\": ["), + "rollback must not disturb the grown entry:\n{restored}" + ); + assert!( + !redirect_ledger(proj).exists(), + "the emptied ledger is deleted" + ); + let installed = fresh_frozen_install_with_local_dep(&fx, "fresh-rolled-back", &tgz_name); + assert_eq!( + installed, fx.orig, + "after rollback bun installs the ORIGINAL bytes" ); + eprintln!("ROLLBACK AFTER RESAVE OK (bun {})", fx.bun_raw); } diff --git a/crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs index 11d9db79..e49f2e88 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs @@ -3,42 +3,82 @@ //! //! Drives the REAL `bun` (network used for fixture setup only): //! 1. `bun install` of left-pad@1.3.0 into a tempdir (private -//! `BUN_INSTALL_CACHE_DIR`). bun 1.3.x writes the text `bun.lock` by -//! default; `--save-text-lockfile` is passed as a belt-and-braces guard -//! against a future binary-lockfile default. +//! `BUN_INSTALL_CACHE_DIR`). The text `bun.lock` is the default from +//! bun 1.2.0 (lockfileVersion 1; 2 from 1.4.0); on 1.1.39–1.1.x it is +//! the `--save-text-lockfile` opt-in (lockfileVersion 0), which the +//! fixture passes for those releases, and the fixture ASSERTS the +//! version it got matches that era table. Bun before 1.1.39 has no +//! text lockfile at all and the suite skips (or, under the REQUIRED +//! gate, fails — such a leg must not be scheduled). //! 2. Hand-stage a `.socket/` manifest + blob from the ACTUAL installed //! bytes (a marker comment prepended to `index.js`). //! 3. `socket-patch vendor --json --offline` — assert the deterministic //! tarball lands at `.socket/vendor/npm//…` and the bun.lock //! `packages` entry is rewritten from the registry 4-tuple to the //! local-tarball 3-tuple `["@", {deps}, "sha512-"]` -//! (spike BN1/BN3). package.json is left UNTOUCHED. +//! (spike BN1/BN3). package.json is left UNTOUCHED. The registry +//! 4-tuple spelling is identical across lockfileVersion 0, 1 and 2, so +//! every assertion after the fixture guard is version-independent. //! 4. **Fresh-checkout proof**: copy ONLY the committable files //! (package.json + bun.lock + .socket/) to a new dir, an EMPTY //! `BUN_INSTALL_CACHE_DIR`, and run the spike's strictest invocation //! `bun install --frozen-lockfile` — the patched bytes MUST be what bun -//! installs (BN7). -//! 5. Idempotency: re-running vendor leaves bun.lock byte-identical. -//! 6. **Revert proof**: `vendor --revert` restores bun.lock byte-for-byte +//! installs (BN7). Then the ORDINARY install: `node_modules` removed, +//! another empty cache, plain `bun install` — bun.lock must stay +//! byte-identical (frozen mode never writes the lock, so only a plain +//! install can observe re-serialization drift; the backtest's +//! `ordinaryStableLock` is the matrix twin) and the marker bytes must +//! land again. +//! 5. **Repair proof**: delete `.socket/vendor/npm//` outright, +//! `repair --offline` must rebuild the tarball byte-identically from +//! the installed copy + blob without touching bun.lock, and a fresh +//! cold-cache frozen install must again land the marker bytes. +//! 6. Idempotency: re-running vendor leaves bun.lock byte-identical. +//! 7. **Revert proof**: `vendor --revert` restores bun.lock byte-for-byte //! and removes `.socket/vendor/` entirely. //! //! The get-driven twin (v3.6) replaces steps 2–3 with a wiremock //! `view/{uuid}` (same hashes, base64 `blobContent` of the after bytes) and //! `get --mode vendored --vendor-source build` — scan's vendored //! posture end to end: manifest + committed artifact + ledger + wired lock, -//! NO `.socket/blobs` — then re-runs the same fresh-checkout frozen-install -//! proof. The revert half is not repeated there: `vendor --revert` on the +//! NO `.socket/blobs` — then re-runs the same fresh-checkout install proof. +//! The revert half is not repeated there: `vendor --revert` on the //! capstone already covers it (same ledger, same engine). //! -//! LOCAL capstone (not behind docker-e2e): skips with a `println` + return -//! when `bun` is unavailable or the fixture install cannot reach the -//! registry; every assertion after that is HARD. +//! The scoped leg vendors a DIFFERENT target: `@scope/pkg@1.0.0`, a scoped +//! package with `dependencies` and a `bin`, served by a wiremock npm +//! registry through bun's `[install.scopes]` (a private scoped registry — +//! the common real-world shape). Bun records it as +//! `["@scope/pkg@1.0.0", "", { "dependencies": {…}, "bin": {…} +//! }, "sha512-…"]`; the rewrite must carry that meta object VERBATIM into +//! the local-tarball 3-tuple (whose path keeps the scope dir: +//! `.socket/vendor/npm//@scope/pkg-1.0.0.tgz`), and the fresh install +//! must prove bun honored it: the dependency installs and the bin is +//! linked. A meta-dropping regression is silent under every left-pad leg +//! (bun installs a `{}`-meta tuple with exit 0, patched bytes and a stable +//! lock — and no deps, no bin). +//! +//! The tampered twin swaps the committed tarball for a DIFFERENT valid +//! tarball while bun.lock keeps our sha512: bun verifies the digest of +//! local-tarball tuples only from 1.3.10 (`Integrity check failed`), so the +//! fresh frozen install MUST fail there and MUST succeed — installing the +//! tampered bytes — on every older text-lock bun (reported as PARTIAL). The +//! boundary is pinned as [`TARBALL_INTEGRITY_ENFORCED_FROM`]; the hosted +//! twin lives in `e2e_redirect_bun_build.rs`. +//! +//! Gates: without `SOCKET_PATCH_BUN_E2E_REQUIRED` (set AND non-empty — CI +//! passes an empty string for non-bun legs) a missing `bun`, a failed +//! fixture install or a bun without a text lockfile is a `println` SKIP and +//! every assertion after that is HARD. With it, those skips become hard +//! failures, and `SOCKET_PATCH_BUN_E2E_VERSION` (when set, non-empty) must +//! equal `bun --version`, so a CI leg cannot pass by running the wrong bun +//! or no bun at all. use std::path::{Path, PathBuf}; use std::process::{Command, Output, Stdio}; -use sha2::{Digest, Sha256}; -use wiremock::matchers::{method, path}; +use sha2::{Digest, Sha256, Sha512}; +use wiremock::matchers::{method, path, path_regex}; use wiremock::{Mock, MockServer, ResponseTemplate}; #[path = "common/cache_env.rs"] @@ -46,59 +86,196 @@ mod cache_env; const UUID: &str = "1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab"; const MARKER: &str = "/* SOCKET-PATCHED */\n"; +/// Content of the tampered twin's replacement tarball — distinct from the +/// pristine AND the patched bytes so "bun installed the tampered bytes" is +/// a real assertion, not a trailing-byte no-op. +const TAMPER_MARKER: &str = "/* SOCKET-TAMPERED */\n"; const DEP: &str = "left-pad"; const DEP_VERSION: &str = "1.3.0"; const ORG: &str = "test-org"; +/// The scoped, dependency-bearing target of the meta-preserving leg. It +/// exists only in the wiremock registry this suite runs; bun fetches it +/// through `[install.scopes]` and left-pad (its one dependency) from the +/// real registry like every other fixture. +const SCOPED_NAME: &str = "@scope/pkg"; +const SCOPED_VERSION: &str = "1.0.0"; +const SCOPED_BIN: &str = "scope-pkg"; +const SCOPED_INDEX: &[u8] = b"module.exports = require('left-pad');\n"; +/// The meta object bun writes for it, byte-exact (bun serializes +/// `dependencies` before `bin`; identical on lockfileVersion 0, 1 and 2). +/// The rewrite must carry this into the 3-tuple verbatim. +const SCOPED_META: &str = + r#"{ "dependencies": { "left-pad": "1.3.0" }, "bin": { "scope-pkg": "bin/cli.js" } }"#; + +/// `(major, minor, patch)` of the bun on PATH. +type BunVersion = (u64, u64, u64); + +/// First bun that verifies the sha512 of URL / local-tarball tuples on +/// install (1.3.9 installs a mismatched tarball with exit 0; 1.3.10 fails +/// with `Integrity check failed`). NOT 1.3.14 — that figure came from a +/// matrix that sampled only 1.3.0 and 1.3.14. Registry 4-tuples are +/// verified from 1.2.0 and are not what the vendored rewrite produces. +const TARBALL_INTEGRITY_ENFORCED_FROM: BunVersion = (1, 3, 10); +/// First bun with a text lockfile (`--save-text-lockfile` opt-in, +/// lockfileVersion 0). Older bun writes only the binary `bun.lockb`. +const TEXT_LOCK_FROM: BunVersion = (1, 1, 39); +/// Text lock becomes the default and bumps to lockfileVersion 1. +const LOCK_V1_FROM: BunVersion = (1, 2, 0); +/// lockfileVersion 2. +const LOCK_V2_FROM: BunVersion = (1, 4, 0); + // ── self-contained helpers ──────────────────────────────────────────── fn binary() -> PathBuf { PathBuf::from(env!("CARGO_BIN_EXE_socket-patch")) } -fn has_command(cmd: &str) -> bool { - let mut probe = Command::new(cmd); +/// The REQUIRED gate: set AND non-empty. CI's e2e matrix passes +/// `SOCKET_PATCH_BUN_E2E_REQUIRED: ${{ matrix.bun != '' && '1' || '' }}`, +/// so an empty value is the non-bun legs' "unset" — an `is_some()` gate +/// would turn every non-bun leg red. +fn bun_required() -> bool { + std::env::var_os("SOCKET_PATCH_BUN_E2E_REQUIRED").is_some_and(|v| !v.is_empty()) +} + +/// The exact bun the matrix leg pinned, when it pinned one. +fn pinned_bun_version() -> Option { + std::env::var("SOCKET_PATCH_BUN_E2E_VERSION") + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) +} + +/// `1.4.2` → `(1, 4, 2)`; a canary suffix (`1.4.3-canary.12+abc`) is cut at +/// the first `-`/`+`. `None` for anything that is not three integers. +fn parse_bun_version(raw: &str) -> Option { + let core = raw.trim().split(['-', '+']).next()?; + let mut parts = core.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next()?.parse().ok()?; + let patch = parts.next()?.parse().ok()?; + if parts.next().is_some() { + return None; + } + Some((major, minor, patch)) +} + +/// `bun --version` through the cache sandbox: `Some(trimmed stdout)` when +/// bun ran and exited 0, `None` when it is not on PATH (or cannot start). +fn bun_version_output() -> Option { + let mut probe = Command::new("bun"); probe.arg("--version"); + cache_env::scrub_ambient_bun_env(&mut probe); cache_env::isolate(&mut probe); - probe - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) + let out = probe.stderr(Stdio::null()).output().ok()?; + out.status + .success() + .then(|| String::from_utf8_lossy(&out.stdout).trim().to_string()) +} + +/// The lockfileVersion the era table says this bun writes for a FRESH +/// install: 1.1.39–1.1.x opt-in text lock → 0, 1.2–1.3 → 1, ≥ 1.4 → 2. +fn expected_lock_version(v: BunVersion) -> u64 { + if v >= LOCK_V2_FROM { + 2 + } else if v >= LOCK_V1_FROM { + 1 + } else { + 0 + } +} + +/// The fixture's `bun install` argv: lifecycle scripts never run (hygiene — +/// left-pad has none, but the fixture is a REAL registry install), and +/// `--save-text-lockfile` is passed only where the text lock is still an +/// opt-in (< 1.2.0), so newer bun is exercised exactly as users run it. +fn fixture_install_args(v: BunVersion) -> Vec<&'static str> { + let mut args = vec!["install", "--ignore-scripts"]; + if v < LOCK_V1_FROM { + args.push("--save-text-lockfile"); + } + args +} + +/// `"lockfileVersion": ` from the lock head — the same head scan as +/// `socket_patch_core::vendor::bun_lock_text::lock_version` (pub(crate) +/// there, so mirrored here). +fn lock_version(text: &str) -> Option { + text.lines() + .take(5) + .find_map(|line| line.trim().strip_prefix("\"lockfileVersion\":")) + .and_then(|rest| rest.trim().trim_end_matches(',').parse().ok()) +} + +/// The toolchain preflight every leg runs first: bun present, pinned +/// version honored, text lockfile available. `None` = this leg is skipped +/// (already reported with a println) — but under the REQUIRED gate every +/// one of those is a hard failure instead, because a CI leg that silently +/// skips is exactly the vacuous pass this suite had for months. +fn bun_toolchain(tag: &str) -> Option<(String, BunVersion)> { + let Some(raw) = bun_version_output() else { + assert!( + !bun_required(), + "SOCKET_PATCH_BUN_E2E_REQUIRED is set but `bun --version` did not run — \ + the matrix leg must install bun before running this suite" + ); + println!("SKIP e2e_vendor_bun_build ({tag}): `bun` not installed"); + return None; + }; + if let Some(pin) = pinned_bun_version() { + assert_eq!( + raw, pin, + "SOCKET_PATCH_BUN_E2E_VERSION pins bun {pin} but PATH resolves bun {raw}: the \ + matrix must run the pinned version" + ); + } + let Some(version) = parse_bun_version(&raw) else { + assert!( + !bun_required(), + "required bun toolchain reports an unparsable version {raw:?}" + ); + println!("SKIP e2e_vendor_bun_build ({tag}): unparsable `bun --version` output {raw:?}"); + return None; + }; + if version < TEXT_LOCK_FROM { + assert!( + !bun_required(), + "bun {raw} has no text lockfile (the `--save-text-lockfile` opt-in exists from \ + 1.1.39); a REQUIRED leg must not be scheduled on it" + ); + println!( + "SKIP e2e_vendor_bun_build ({tag}): bun {raw} predates the text bun.lock (1.1.39)" + ); + return None; + } + Some((raw, version)) } /// Run `bun ` in `cwd` with the given private cache dir, the shared /// cache sandbox for everything bun keeps outside that dir (`~/.bun`, the -/// npmrc it reads), and every `SOCKET_*` var scrubbed. +/// npmrc it reads), and the ambient env scrubbed by the scrub the three bun +/// suites share (`cache_env::scrub_ambient_bun_env`: `SOCKET_*`, every +/// `BUN_*`, case-insensitive `npm_config_*` — an ambient registry mirror +/// would put the mirror tarball URL in the 4-tuple's registry slot and fail +/// the pre-vendor assertions). fn bun(cwd: &Path, args: &[&str], cache_dir: &Path) -> Output { let mut cmd = Command::new("bun"); cmd.args(args).current_dir(cwd); - // Scrub BEFORE seeding: scrub_socket_env removes BUN_INSTALL_CACHE_DIR, - // and Command's last env call wins. - scrub_socket_env(&mut cmd); + // Scrub BEFORE seeding: the scrub removes BUN_INSTALL_CACHE_DIR, and + // Command's last env call wins. + cache_env::scrub_ambient_bun_env(&mut cmd); cache_env::isolate(&mut cmd); cmd.env("BUN_INSTALL_CACHE_DIR", cache_dir); cmd.output().expect("failed to run bun") } -/// Remove ambient `SOCKET_*` vars and the bun cache env the harness controls -/// (always passed explicitly). -fn scrub_socket_env(cmd: &mut Command) { - for (k, _) in std::env::vars_os() { - let k = k.to_string_lossy(); - if k.starts_with("SOCKET_") && k != "SOCKET_NO_CONFIG" { - cmd.env_remove(k.as_ref()); - } - } - cmd.env_remove("VIRTUAL_ENV"); - cmd.env_remove("BUN_INSTALL_CACHE_DIR"); -} - +/// The real binary with `--no-telemetry` appended: nothing in this suite +/// should ever post a telemetry event, mocked API or not. fn run_socket(cwd: &Path, args: &[&str]) -> (i32, String, String) { let mut cmd = Command::new(binary()); - cmd.args(args).current_dir(cwd); - scrub_socket_env(&mut cmd); + cmd.args(args).arg("--no-telemetry").current_dir(cwd); + cache_env::scrub_ambient_bun_env(&mut cmd); let out = cmd.output().expect("failed to run socket-patch binary"); ( out.status.code().unwrap_or(-1), @@ -119,29 +296,8 @@ fn b64(bytes: &[u8]) -> String { base64::engine::general_purpose::STANDARD.encode(bytes) } -fn stage_patch(proj: &Path, purl: &str, file_key: &str, before: &[u8], after: &[u8]) { - let socket = proj.join(".socket"); - std::fs::create_dir_all(socket.join("blobs")).unwrap(); - let manifest = serde_json::json!({ - "patches": { purl: { - "uuid": UUID, - "exportedAt": "2026-01-01T00:00:00Z", - "files": { file_key: { - "beforeHash": git_sha256(before), - "afterHash": git_sha256(after), - }}, - "vulnerabilities": {}, - "description": "capstone marker patch", - "license": "MIT", - "tier": "free", - }} - }); - std::fs::write( - socket.join("manifest.json"), - serde_json::to_string_pretty(&manifest).unwrap(), - ) - .unwrap(); - std::fs::write(socket.join("blobs").join(git_sha256(after)), after).unwrap(); +fn sri(bytes: &[u8]) -> String { + format!("sha512-{}", b64(&Sha512::digest(bytes))) } fn parse_envelope(stdout: &str) -> serde_json::Value { @@ -162,6 +318,219 @@ fn copy_dir_recursive(src: &Path, dst: &Path) { } } +/// A gzipped npm tarball from `(entry name under package/, bytes, mode)` +/// triples, built with the tar crate so the suite has no system-`tar` +/// dependency (Windows runners included). +fn build_tgz(entries: &[(String, Vec, u32)]) -> Vec { + let mut builder = tar::Builder::new(flate2::write::GzEncoder::new( + Vec::new(), + flate2::Compression::default(), + )); + for (name, bytes, mode) in entries { + let mut header = tar::Header::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(*mode); + header.set_cksum(); + builder + .append_data(&mut header, format!("package/{name}"), bytes.as_slice()) + .unwrap(); + } + builder.into_inner().unwrap().finish().unwrap() +} + +/// A VALID npm tarball built from the ACTUALLY-installed package with the +/// entry point swapped — the tampered twin's replacement artifact. The +/// point is a sha512 that differs from the one bun.lock pins while the +/// archive still extracts, so "bun installed the tampered bytes" can be +/// asserted on the pre-1.3.10 releases that never check the digest. +fn make_tgz_from_installed(pkg_dir: &Path, replaced_index: &[u8]) -> Vec { + let pkg_dir = pkg_dir + .canonicalize() + .expect("installed package dir must resolve"); + let mut files: Vec = Vec::new(); + let mut stack = vec![pkg_dir.clone()]; + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(&dir).unwrap() { + let p = entry.unwrap().path(); + if p.is_dir() { + stack.push(p); + } else { + files.push(p); + } + } + } + files.sort(); + let entries: Vec<(String, Vec, u32)> = files + .iter() + .map(|p| { + let rel = p.strip_prefix(&pkg_dir).unwrap(); + // Tar entry names always use `/` regardless of host separator. + let name = rel + .components() + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .collect::>() + .join("/"); + let bytes = if rel == Path::new("index.js") { + replaced_index.to_vec() + } else { + std::fs::read(p).unwrap() + }; + (name.clone(), bytes, file_mode(p, &name)) + }) + .collect(); + build_tgz(&entries) +} + +#[cfg(unix)] +fn file_mode(p: &Path, _name: &str) -> u32 { + use std::os::unix::fs::PermissionsExt as _; + std::fs::metadata(p).unwrap().permissions().mode() & 0o777 +} + +#[cfg(not(unix))] +fn file_mode(_p: &Path, name: &str) -> u32 { + if name.starts_with("bin/") { + 0o755 + } else { + 0o644 + } +} + +/// The scoped target's registry tarball: package.json with the dependency +/// and the bin, the entry point, and the (executable) bin script. +fn scoped_registry_tgz() -> Vec { + let pkg_json = serde_json::json!({ + "name": SCOPED_NAME, + "version": SCOPED_VERSION, + "main": "index.js", + "dependencies": { DEP: DEP_VERSION }, + "bin": { SCOPED_BIN: "bin/cli.js" }, + }); + build_tgz(&[ + ( + "package.json".into(), + serde_json::to_vec_pretty(&pkg_json).unwrap(), + 0o644, + ), + ("index.js".into(), SCOPED_INDEX.to_vec(), 0o644), + ( + "bin/cli.js".into(), + b"#!/usr/bin/env node\nconsole.log('scope-pkg cli');\n".to_vec(), + 0o755, + ), + ]) +} + +/// A wiremock npm registry for the scoped target: the packument (bun asks +/// for `/@scope%2fpkg`) and the tarball it points at. Integrity only — bun +/// verifies the sha512 and needs no `shasum`. +async fn mount_scoped_registry(server: &MockServer, tgz: Vec) { + let tarball_url = format!("{}/@scope/pkg/-/pkg-{SCOPED_VERSION}.tgz", server.uri()); + Mock::given(method("GET")) + .and(path_regex(r"^/@scope(%2[fF]|/)pkg$")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "name": SCOPED_NAME, + "dist-tags": { "latest": SCOPED_VERSION }, + "versions": { + SCOPED_VERSION: { + "name": SCOPED_NAME, + "version": SCOPED_VERSION, + "dependencies": { DEP: DEP_VERSION }, + "bin": { SCOPED_BIN: "bin/cli.js" }, + "dist": { "tarball": tarball_url, "integrity": sri(&tgz) } + } + } + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path(format!("/@scope/pkg/-/pkg-{SCOPED_VERSION}.tgz"))) + .respond_with(ResponseTemplate::new(200).set_body_raw(tgz, "application/octet-stream")) + .mount(server) + .await; +} + +/// Which package the vendored rewrite targets. +#[derive(Clone, Copy, PartialEq, Debug)] +enum Target { + /// left-pad@1.3.0 from the real registry: unscoped, `{}` meta — the + /// original capstone target. + LeftPad, + /// `@scope/pkg@1.0.0` from the suite's wiremock registry via + /// `[install.scopes]`: scoped key, non-empty `{dependencies, bin}` meta. + ScopedWithDeps, +} + +impl Target { + fn name(self) -> &'static str { + match self { + Target::LeftPad => DEP, + Target::ScopedWithDeps => SCOPED_NAME, + } + } + fn version(self) -> &'static str { + match self { + Target::LeftPad => DEP_VERSION, + Target::ScopedWithDeps => SCOPED_VERSION, + } + } + /// The PURL the CLI derives for it (`@` is percent-encoded in npm PURLs). + fn purl(self) -> &'static str { + match self { + Target::LeftPad => "pkg:npm/left-pad@1.3.0", + Target::ScopedWithDeps => "pkg:npm/%40scope/pkg@1.0.0", + } + } + /// The meta object bun writes for it, byte-exact. + fn meta(self) -> &'static str { + match self { + Target::LeftPad => "{}", + Target::ScopedWithDeps => SCOPED_META, + } + } + fn installed_dir(self, root: &Path) -> PathBuf { + let nm = root.join("node_modules"); + match self { + Target::LeftPad => nm.join(DEP), + Target::ScopedWithDeps => nm.join("@scope").join("pkg"), + } + } + /// The vendored tarball's path under `.socket/vendor/npm//` — the + /// scope dir is kept as a directory level. + fn vendored_tgz_rel(self) -> String { + match self { + Target::LeftPad => format!("{DEP}-{DEP_VERSION}.tgz"), + Target::ScopedWithDeps => format!("@scope/pkg-{SCOPED_VERSION}.tgz"), + } + } + fn package_json(self) -> String { + format!( + r#"{{"name":"bun-capstone","version":"0.0.0","private":true,"dependencies":{{"{}":"{}"}}}}"#, + self.name(), + self.version() + ) + } +} + +/// The `packages` line for `name` in a bun.lock (`"name": [...]`). +fn packages_line(lock: &str, name: &str) -> String { + let key = format!("\"{name}\": ["); + lock.lines() + .find(|l| l.trim_start().starts_with(&key)) + .unwrap_or_else(|| panic!("no packages entry for {name} in:\n{lock}")) + .to_string() +} + +/// The `sha512-…` integrity token of a packages line (its last element). +fn line_sha512(line: &str) -> String { + let start = line + .rfind("\"sha512-") + .unwrap_or_else(|| panic!("no sha512 in packages line: {line}")); + let rest = &line[start + 1..]; + let end = rest.find('"').unwrap(); + rest[..end].to_string() +} + // ── shared fixture (steps 1–2) ──────────────────────────────────────── /// The real-bun project both capstones drive, plus the pre-vendor snapshots @@ -169,41 +538,63 @@ fn copy_dir_recursive(src: &Path, dst: &Path) { struct BunProject { tmp: tempfile::TempDir, proj: PathBuf, + target: Target, orig: Vec, patched: Vec, - purl: String, lock_before: Vec, pkg_before: Vec, + /// bun's registry 4-tuple for the target, up to its integrity — the + /// spelling that must be GONE after the rewrite. + registry_tuple_head: String, + /// The registry integrity bun recorded — must NOT survive the rewrite. + registry_sha512: String, + /// `bun --version`, verbatim, for messages. + bun_raw: String, + bun_version: BunVersion, + /// The lockfileVersion this bun wrote — asserted against the era table. + lock_version: u64, } -/// Steps 1–2 of the module doc, shared by the vendor capstone and the -/// get-driven twin: a tempdir project depending on left-pad, a REAL -/// `bun install` (network here, private cache) with the hermeticity guard, -/// pristine-byte checks, and the patched-content twin of the installed -/// `index.js`. `None` = soft-skip, already reported with a println. -fn bun_project(tag: &str) -> Option { - if !has_command("bun") { - println!("SKIP e2e_vendor_bun_build ({tag}): `bun` not installed"); - return None; - } +/// Steps 1–2 of the module doc, shared by every leg: a tempdir project +/// depending on the target, a REAL `bun install` (network here, private +/// cache) with the hermeticity guard, pristine-byte checks, and the +/// patched-content twin of the installed `index.js`. `scoped_registry` is +/// the wiremock registry URI for [`Target::ScopedWithDeps`] (mounted by +/// the caller — the fixture writes the matching `bunfig.toml`). `None` = +/// soft-skip, already reported with a println (a hard failure instead +/// under the REQUIRED gate). +fn bun_project(tag: &str, target: Target, scoped_registry: Option<&str>) -> Option { + let (bun_raw, bun_version) = bun_toolchain(tag)?; let tmp = tempfile::tempdir().unwrap(); let proj = tmp.path().join("proj"); std::fs::create_dir_all(&proj).unwrap(); - std::fs::write( - proj.join("package.json"), - format!( - r#"{{"name":"bun-capstone","version":"0.0.0","private":true,"dependencies":{{"{DEP}":"{DEP_VERSION}"}}}}"# - ), - ) - .unwrap(); + std::fs::write(proj.join("package.json"), target.package_json()).unwrap(); + let mut registry_field = String::new(); + if target == Target::ScopedWithDeps { + let registry = scoped_registry.expect("the scoped target needs its wiremock registry"); + // bun's scoped-registry config — a committable file, so it travels + // with every fresh checkout below. + std::fs::write( + proj.join("bunfig.toml"), + format!("[install.scopes]\n\"@scope\" = {{ url = \"{registry}/\" }}\n"), + ) + .unwrap(); + // For a non-default registry bun records the TARBALL URL as the + // 4-tuple's registry field. + registry_field = format!("{registry}/@scope/pkg/-/pkg-{SCOPED_VERSION}.tgz"); + } // 1. REAL fixture: bun install (network allowed here, private cache). - // `--save-text-lockfile` guarantees the text bun.lock vendor wires - // (bun 1.3.x already defaults to it; the flag future-proofs the test). let cache = tmp.path().join("bun-cache"); - let install = bun(&proj, &["install", "--save-text-lockfile"], &cache); + let install = bun(&proj, &fixture_install_args(bun_version), &cache); if !install.status.success() { + assert!( + !bun_required(), + "required bun {bun_raw} fixture `bun install` failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&install.stdout), + String::from_utf8_lossy(&install.stderr) + ); println!( "SKIP e2e_vendor_bun_build ({tag}): fixture `bun install` failed (registry \ unreachable?):\n{}", @@ -213,6 +604,11 @@ fn bun_project(tag: &str) -> Option { } let lock_path = proj.join("bun.lock"); if !lock_path.is_file() { + assert!( + !bun_required(), + "required bun {bun_raw} produced no text bun.lock after {:?}", + fixture_install_args(bun_version) + ); println!( "SKIP e2e_vendor_bun_build ({tag}): bun produced no text bun.lock (binary \ lockfile?) — this bun version's default lockfile is not the wirable text form" @@ -228,105 +624,258 @@ fn bun_project(tag: &str) -> Option { cache.display() ); - let installed_index = proj.join("node_modules").join(DEP).join("index.js"); + let installed_index = target.installed_dir(&proj).join("index.js"); let orig = std::fs::read(&installed_index).expect("installed index.js"); assert!( !orig.starts_with(MARKER.as_bytes()), "pristine install must not carry the marker" ); + if target == Target::ScopedWithDeps { + assert_eq!( + orig, SCOPED_INDEX, + "bun must have installed the mock registry's bytes" + ); + } let patched: Vec = [MARKER.as_bytes(), orig.as_slice()].concat(); - let purl = format!("pkg:npm/{DEP}@{DEP_VERSION}"); let lock_before = std::fs::read(&lock_path).expect("bun.lock after bun install"); let pkg_before = std::fs::read(proj.join("package.json")).expect("package.json"); let lock_before_str = String::from_utf8(lock_before.clone()).unwrap(); - // bun 1.3 writes lockfileVersion 1, bun 1.4 writes 2 — one emitted - // grammar; both are wirable. - assert!( - lock_before_str.contains("\"lockfileVersion\": 1") - || lock_before_str.contains("\"lockfileVersion\": 2"), - "fixture must be a bun text lockfileVersion 1 or 2:\n{lock_before_str}" + // The era table, asserted rather than assumed: 1.1.39–1.1.x opt-in + // text lock → 0, 1.2–1.3 → 1, ≥ 1.4 → 2. All three are one emitted + // grammar for registry entries and all three are wirable; pinning the + // mapping is what makes a lock-era CI leg prove the era it claims. + let lock_version = lock_version(&lock_before_str).unwrap_or_else(|| { + panic!("fixture bun.lock has no integer lockfileVersion in its head:\n{lock_before_str}") + }); + assert_eq!( + lock_version, + expected_lock_version(bun_version), + "bun {bun_raw} wrote lockfileVersion {lock_version}; the era table expects {} \ + (1.1.39–1.1.x → 0, 1.2–1.3 → 1, ≥ 1.4 → 2):\n{lock_before_str}", + expected_lock_version(bun_version) + ); + // Pre-vendor: the registry 4-tuple, with bun's real registry field and + // meta object for this target — one spelling across 0/1/2. + let registry_tuple_head = format!( + "\"{}@{}\", \"{registry_field}\", {}, \"sha512-", + target.name(), + target.version(), + target.meta() ); - // Pre-vendor: the registry 4-tuple `["left-pad@1.3.0", "", {}, "sha512-…"]`. assert!( - lock_before_str.contains(&format!("\"{DEP}@{DEP_VERSION}\", \"\"")), - "pre-vendor packages entry must be the registry 4-tuple:\n{lock_before_str}" + lock_before_str.contains(®istry_tuple_head), + "pre-vendor packages entry must be the registry 4-tuple {registry_tuple_head}…:\n\ + {lock_before_str}" ); + let registry_sha512 = line_sha512(&packages_line(&lock_before_str, target.name())); Some(BunProject { tmp, proj, + target, orig, patched, - purl, lock_before, pkg_before, + registry_tuple_head, + registry_sha512, + bun_raw, + bun_version, + lock_version, }) } +fn vendored_dir(proj: &Path) -> PathBuf { + proj.join(".socket").join("vendor").join("npm").join(UUID) +} + +fn vendored_tgz(fx: &BunProject) -> PathBuf { + vendored_dir(&fx.proj).join(fx.target.vendored_tgz_rel()) +} + +/// Hand-stage the `.socket/` manifest + blob for the fixture's target from +/// the installed bytes (the capstone's step 2). +fn stage_patch(fx: &BunProject) { + let socket = fx.proj.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + let manifest = serde_json::json!({ + "patches": { fx.target.purl(): { + "uuid": UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { "package/index.js": { + "beforeHash": git_sha256(&fx.orig), + "afterHash": git_sha256(&fx.patched), + }}, + "vulnerabilities": {}, + "description": "capstone marker patch", + "license": "MIT", + "tier": "free", + }} + }); + std::fs::write( + socket.join("manifest.json"), + serde_json::to_string_pretty(&manifest).unwrap(), + ) + .unwrap(); + std::fs::write( + socket.join("blobs").join(git_sha256(&fx.patched)), + &fx.patched, + ) + .unwrap(); +} + +/// `vendor --json --offline` over the fixture; the (code, stdout, stderr). +fn run_vendor(fx: &BunProject, extra: &[&str]) -> (i32, String, String) { + let mut args = vec![ + "vendor", + "--json", + "--offline", + "--cwd", + fx.proj.to_str().unwrap(), + ]; + args.extend_from_slice(extra); + run_socket(&fx.proj, &args) +} + /// The on-disk vendored state BOTH drivers (`vendor --offline`, `get /// --mode vendored`) must produce: the committed artifact + informational /// marker + ledger, the bun.lock `packages` entry rewritten from the -/// registry 4-tuple to the local-tarball 3-tuple with OUR recomputed -/// integrity, and package.json untouched. -fn assert_vendored_on_disk(proj: &Path, pkg_before: &[u8]) { - let tgz_rel = format!(".socket/vendor/npm/{UUID}/{DEP}-{DEP_VERSION}.tgz"); +/// registry 4-tuple to the local-tarball 3-tuple with the meta object +/// carried VERBATIM and OUR recomputed integrity, and package.json +/// untouched. +fn assert_vendored_on_disk(fx: &BunProject) { + let proj = &fx.proj; + let tgz_rel = format!(".socket/vendor/npm/{UUID}/{}", fx.target.vendored_tgz_rel()); assert!( - proj.join(&tgz_rel).is_file(), + vendored_tgz(fx).is_file(), "vendored tarball missing at {tgz_rel}" ); assert!( - proj.join(format!( - ".socket/vendor/npm/{UUID}/socket-patch.vendor.json" - )) - .is_file(), + vendored_dir(proj) + .join("socket-patch.vendor.json") + .is_file(), "informational vendor marker missing" ); assert!( - proj.join(".socket/vendor/state.json").is_file(), + proj.join(".socket") + .join("vendor") + .join("state.json") + .is_file(), "vendor ledger missing" ); // bun.lock packages entry rewritten to the local-tarball 3-tuple: - // element 0 = `@` (no `file:`/`./`), the deps object - // shifts to index 1, integrity is the recomputed sha512 of OUR tarball. + // element 0 = `@` (no `file:`/`./`), the meta + // object shifts to index 1 unchanged, integrity is the recomputed + // sha512 of OUR tarball. let lock_after = std::fs::read_to_string(proj.join("bun.lock")).unwrap(); + let local_tuple_head = format!( + "\"{}@{tgz_rel}\", {}, \"sha512-", + fx.target.name(), + fx.target.meta() + ); assert!( - lock_after.contains(&format!("\"{DEP}@{tgz_rel}\", {{}}, \"sha512-")), - "bun.lock packages entry must be the local-tarball 3-tuple; got:\n{lock_after}" + lock_after.contains(&local_tuple_head), + "bun.lock packages entry must be the local-tarball 3-tuple {local_tuple_head}…; got:\n\ + {lock_after}" ); assert!( - !lock_after.contains(&format!("\"{DEP}@{DEP_VERSION}\", \"\"")), + !lock_after.contains(&fx.registry_tuple_head), "the registry 4-tuple must be gone after the rewrite:\n{lock_after}" ); assert!( - !lock_after.contains( - "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==" - ), + !lock_after.contains(&fx.registry_sha512), "the inherited registry integrity must NOT survive the rewrite:\n{lock_after}" ); + // The rewrite must keep the lock's own version line — a v0 lock stays + // v0, a v2 lock stays v2 (no silent format bump by the CLI). + assert_eq!( + lock_version(&lock_after), + Some(fx.lock_version), + "the vendored rewrite must preserve the lockfileVersion line verbatim:\n{lock_after}" + ); + if fx.target == Target::ScopedWithDeps { + // The dependency's own registry entry is not the target: untouched. + let before = String::from_utf8(fx.lock_before.clone()).unwrap(); + assert_eq!( + packages_line(&lock_after, DEP), + packages_line(&before, DEP), + "the un-patched dependency's registry 4-tuple must be byte-identical:\n{lock_after}" + ); + } // package.json is left untouched by the lock-only bun wiring. assert_eq!( std::fs::read(proj.join("package.json")).unwrap(), - pkg_before, + fx.pkg_before, "bun vendoring is lock-only; package.json must stay byte-identical" ); } -/// Step 4, shared: fresh dir with ONLY the committable files (package.json, -/// bun.lock, and .socket/), an EMPTY `BUN_INSTALL_CACHE_DIR`, and the -/// spike-proven strictest invocation `bun install --frozen-lockfile` — the -/// patched bytes MUST be what bun installs (BN7), and the committed lock -/// must stay byte-identical. -fn fresh_checkout_frozen_install(tmp: &Path, proj: &Path, patched: &[u8]) { - let fresh = tmp.join("fresh"); +/// Fresh dir `/` holding ONLY the committable files +/// (package.json, bun.lock, bunfig.toml when the project has one, and +/// .socket/). +fn fresh_checkout(fx: &BunProject, name: &str) -> PathBuf { + let fresh = fx.tmp.path().join(name); std::fs::create_dir_all(&fresh).unwrap(); - std::fs::copy(proj.join("package.json"), fresh.join("package.json")).unwrap(); - std::fs::copy(proj.join("bun.lock"), fresh.join("bun.lock")).unwrap(); - copy_dir_recursive(&proj.join(".socket"), &fresh.join(".socket")); + std::fs::copy(fx.proj.join("package.json"), fresh.join("package.json")).unwrap(); + std::fs::copy(fx.proj.join("bun.lock"), fresh.join("bun.lock")).unwrap(); + if fx.proj.join("bunfig.toml").is_file() { + std::fs::copy(fx.proj.join("bunfig.toml"), fresh.join("bunfig.toml")).unwrap(); + } + copy_dir_recursive(&fx.proj.join(".socket"), &fresh.join(".socket")); + fresh +} + +/// `bun install --frozen-lockfile` in a fresh checkout named `name` against +/// an EMPTY cache — the spike-proven strictest invocation. +fn fresh_frozen_install(fx: &BunProject, name: &str) -> (PathBuf, Output) { + let fresh = fresh_checkout(fx, name); + let fresh_cache = fx.tmp.path().join(format!("{name}-bun-cache")); + let ci = bun( + &fresh, + &["install", "--frozen-lockfile", "--ignore-scripts"], + &fresh_cache, + ); + (fresh, ci) +} - let fresh_cache = tmp.join("fresh-bun-cache"); - let ci = bun(&fresh, &["install", "--frozen-lockfile"], &fresh_cache); +/// For the scoped target: bun must have honored the meta object it read +/// from the local-tarball 3-tuple — the dependency is installed and the +/// bin linked (as `node_modules/.bin/scope-pkg`, or its `.exe`/`.cmd` +/// shims on Windows). Neither happens when the meta is `{}`. +fn assert_scoped_meta_honored(fresh: &Path) { + assert!( + fresh + .join("node_modules") + .join(DEP) + .join("package.json") + .is_file(), + "bun must install the scoped package's `dependencies` from the 3-tuple meta" + ); + let bin_dir = fresh.join("node_modules").join(".bin"); + let linked = std::fs::read_dir(&bin_dir) + .map(|rd| { + rd.filter_map(|e| e.ok()) + .any(|e| e.file_name().to_string_lossy().starts_with(SCOPED_BIN)) + }) + .unwrap_or(false); + assert!( + linked, + "bun must link the scoped package's `bin` from the 3-tuple meta under {}", + bin_dir.display() + ); +} + +/// Step 4, shared: the fresh-checkout frozen install MUST land the patched +/// bytes (BN7); then the ORDINARY install (node_modules removed, another +/// empty cache, plain `bun install`) MUST leave the committed lock +/// byte-identical and land the patched bytes again. Frozen mode never +/// writes the lock, so only the plain install can observe a +/// re-serialization of the local-tarball tuple — the property the module +/// doc calls BN3 and the backtest checks as `ordinaryStableLock`. +fn fresh_checkout_install_proof(fx: &BunProject, name: &str) { + let (fresh, ci) = fresh_frozen_install(fx, name); assert!( ci.status.success(), "fresh-checkout `bun install --frozen-lockfile` must succeed from the vendored \ @@ -334,53 +883,105 @@ fn fresh_checkout_frozen_install(tmp: &Path, proj: &Path, patched: &[u8]) { String::from_utf8_lossy(&ci.stdout), String::from_utf8_lossy(&ci.stderr), ); - let fresh_installed = - std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")).unwrap(); + let installed_index = fx.target.installed_dir(&fresh).join("index.js"); + let fresh_installed = std::fs::read(&installed_index).unwrap(); assert!( fresh_installed.starts_with(MARKER.as_bytes()), "bun must install the PATCHED bytes from the vendored tarball; got:\n{}", String::from_utf8_lossy(&fresh_installed[..fresh_installed.len().min(120)]) ); assert_eq!( - fresh_installed, patched, + fresh_installed, fx.patched, "fresh install must be byte-identical to the patched content" ); - // --frozen-lockfile would have errored if the lock drifted; prove it - // left the committed lock byte-stable. + if fx.target == Target::ScopedWithDeps { + assert_scoped_meta_honored(&fresh); + } + eprintln!("FRESH INSTALL OK ({name}, {:?})", fx.target); + + // Ordinary install: the lock must survive bun's own re-serialization. + let wired_lock = std::fs::read(fx.proj.join("bun.lock")).unwrap(); + std::fs::remove_dir_all(fresh.join("node_modules")).unwrap(); + let plain_cache = fx.tmp.path().join(format!("{name}-plain-bun-cache")); + let plain = bun(&fresh, &["install", "--ignore-scripts"], &plain_cache); + assert!( + plain.status.success(), + "plain `bun install` on the vendored lock must succeed.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&plain.stdout), + String::from_utf8_lossy(&plain.stderr), + ); assert_eq!( std::fs::read(fresh.join("bun.lock")).unwrap(), - std::fs::read(proj.join("bun.lock")).unwrap(), - "--frozen-lockfile install must leave bun.lock byte-identical" + wired_lock, + "an ORDINARY `bun install` must leave the vendored bun.lock byte-identical \ + (re-serialization drift would churn every commit)" ); - eprintln!("FRESH INSTALL OK"); + assert_eq!( + std::fs::read(&installed_index).unwrap(), + fx.patched, + "the ordinary install must land the patched bytes too" + ); + if fx.target == Target::ScopedWithDeps { + assert_scoped_meta_honored(&fresh); + } + eprintln!("PLAIN INSTALL LOCK-STABLE ({name})"); } -// ── the capstone ────────────────────────────────────────────────────── - -#[test] -fn bun_vendor_fresh_checkout_frozen_install_and_revert() { - let Some(fx) = bun_project("vendor-offline") else { - return; - }; - let proj = &fx.proj; - let lock_path = proj.join("bun.lock"); - let pkg_path = proj.join("package.json"); - let purl = &fx.purl; - - // 2. Hand-stage the .socket/ manifest + blob from the installed bytes. - stage_patch(proj, purl, "package/index.js", &fx.orig, &fx.patched); - - // 3. Vendor (offline). - let (code, stdout, stderr) = run_socket( - proj, - &[ - "vendor", - "--json", - "--offline", - "--cwd", - proj.to_str().unwrap(), - ], +/// The tampered twin's shared tail: bun.lock pins OUR sha512 while the +/// committed tarball now holds different bytes. Which outcome is correct +/// depends on the bun: from 1.3.10 the fresh frozen install MUST fail on +/// the integrity check; before it bun never verifies local-tarball digests +/// and MUST install the tampered bytes with exit 0 (reported PARTIAL — the +/// rejection proof is not available on that release, by bun's design). +fn assert_tamper_outcome(fx: &BunProject, tampered: &[u8]) { + let (fresh, ci) = fresh_frozen_install(fx, "fresh-tampered"); + let chatter = format!( + "{}\n{}", + String::from_utf8_lossy(&ci.stdout), + String::from_utf8_lossy(&ci.stderr) ); + if fx.bun_version >= TARBALL_INTEGRITY_ENFORCED_FROM { + assert!( + !ci.status.success(), + "bun {} install MUST fail when the vendored tarball does not match the pinned \ + sha512 (digests are enforced from 1.3.10).\n{chatter}", + fx.bun_raw + ); + let lower = chatter.to_lowercase(); + assert!( + lower.contains("integrity") + || lower.contains("checksum") + || lower.contains("hash") + || chatter.contains("IntegrityCheckFailed"), + "the failure must be the integrity check, not something incidental:\n{chatter}" + ); + eprintln!("TAMPER REJECTED OK (bun {})", fx.bun_raw); + } else { + assert!( + ci.status.success(), + "bun {} (< 1.3.10) does not verify local-tarball digests, so the tampered \ + install must still exit 0 — a failure here means the boundary moved.\n{chatter}", + fx.bun_raw + ); + let installed = std::fs::read(fx.target.installed_dir(&fresh).join("index.js")).unwrap(); + assert_eq!( + installed, tampered, + "bun {} installed neither the tampered bytes nor failed: the boundary model is wrong", + fx.bun_raw + ); + println!( + "PARTIAL e2e_vendor_bun_build (tampered): bun {} does not verify local tarball \ + digests (enforced from 1.3.10) — rejection proof unavailable, acceptance pinned", + fx.bun_raw + ); + } +} + +/// Steps 2–3 for the manifest-driven legs: stage the patch, `vendor +/// --offline`, assert the envelope and the on-disk vendored state. +fn stage_and_vendor(fx: &BunProject) { + stage_patch(fx); + let (code, stdout, stderr) = run_vendor(fx, &[]); assert_eq!( code, 0, "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" @@ -389,60 +990,148 @@ fn bun_vendor_fresh_checkout_frozen_install_and_revert() { assert_eq!(env["status"], "success", "envelope: {env}"); assert_eq!(env["summary"]["applied"], 1, "one package vendored: {env}"); assert_eq!(env["summary"]["failed"], 0, "no failures: {env}"); + let purl = fx.target.purl(); let applied = env["events"] .as_array() .unwrap() .iter() - .find(|e| e["action"] == "applied" && e["purl"] == purl.as_str()) + .find(|e| e["action"] == "applied" && e["purl"] == purl) .unwrap_or_else(|| panic!("expected an applied event for {purl}: {env}")); assert!( applied.get("errorCode").is_none(), "clean apply event: {applied}" ); + assert_vendored_on_disk(fx); + eprintln!( + "VENDOR OK (bun {}, lockfileVersion {}, {:?})", + fx.bun_raw, fx.lock_version, fx.target + ); +} + +// ── the capstone ────────────────────────────────────────────────────── + +// #[serial]: each fresh install gets its own empty cache dir, but bun also +// keeps state under the sandboxed `~/.bun`; serializing keeps the tampered +// twin (same local tarball spec, different bytes) from ever racing a +// sibling's honest install. +#[test] +#[serial_test::serial] +fn bun_vendor_fresh_checkout_frozen_install_and_revert() { + let Some(fx) = bun_project("vendor-offline", Target::LeftPad, None) else { + return; + }; + let proj = &fx.proj; + let lock_path = proj.join("bun.lock"); + let pkg_path = proj.join("package.json"); - assert_vendored_on_disk(proj, &fx.pkg_before); - eprintln!("VENDOR OK"); + // 2–3. Hand-stage the .socket/ manifest + blob, vendor (offline). + stage_and_vendor(&fx); // 4. FRESH-CHECKOUT PROOF: committable files only, EMPTY cache, - // spike-proven `--frozen-lockfile`. - fresh_checkout_frozen_install(fx.tmp.path(), proj, &fx.patched); + // spike-proven `--frozen-lockfile`, then the ordinary-install + // lock-stability twin. + fresh_checkout_install_proof(&fx, "fresh"); - // 5. Idempotency: a re-run exits 0 and leaves bun.lock byte-stable. + // 5. REPAIR PROOF: the committed artifact dir vanishes (a botched merge, + // an over-eager clean); `repair --offline` must rebuild the tarball + // byte-identically from the installed copy + blob, leave bun.lock + // alone, and a cold fresh checkout must install the marker bytes + // from the rebuilt artifact. + let tgz_path = vendored_tgz(&fx); + let tgz_bytes = std::fs::read(&tgz_path).unwrap(); let lock_wired = std::fs::read(&lock_path).unwrap(); + std::fs::remove_dir_all(vendored_dir(proj)).unwrap(); + assert!(!tgz_path.exists(), "precondition: the vendored dir is gone"); let (code, stdout, stderr) = run_socket( proj, &[ - "vendor", + "repair", "--json", "--offline", + "--yes", "--cwd", proj.to_str().unwrap(), ], ); + assert_eq!( + code, 0, + "repair failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let renv = parse_envelope(&stdout); + assert_eq!(renv["status"], "success", "repair envelope: {renv}"); + assert_eq!( + renv["summary"]["rebuilt"], 1, + "repair must rebuild the one deleted artifact: {renv}" + ); + assert!( + renv["events"] + .as_array() + .unwrap() + .iter() + .any(|e| e["action"] == "rebuilt" && e["purl"] == fx.target.purl()), + "repair must report a rebuilt event for {}: {renv}", + fx.target.purl() + ); + assert_eq!( + std::fs::read(&tgz_path).unwrap(), + tgz_bytes, + "the deterministic rebuild must reproduce the vendored tarball byte-for-byte" + ); + assert_eq!( + std::fs::read(&lock_path).unwrap(), + lock_wired, + "repair must not touch bun.lock" + ); + eprintln!("REPAIR OK"); + fresh_checkout_install_proof(&fx, "fresh-repaired"); + + // 6. Idempotency: a re-run exits 0, is a SKIP (`already_vendored`, not a + // re-vendor — a regression that re-classifies the in-sync local-path + // tuple as needing a rewrite re-packs the deterministic tarball and + // re-records the wiring while leaving bun.lock byte-identical, so the + // lock bytes alone cannot see it) and leaves bun.lock byte-stable. + let (code, stdout, stderr) = run_vendor(&fx, &[]); assert_eq!( code, 0, "re-vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); let env2 = parse_envelope(&stdout); assert_eq!(env2["summary"]["failed"], 0, "re-run must not fail: {env2}"); + assert_eq!( + env2["summary"]["applied"], 0, + "an in-sync re-run must not re-vendor: {env2}" + ); + assert_eq!( + env2["summary"]["skipped"], 1, + "the one in-sync entry is skipped: {env2}" + ); + assert!( + env2["events"] + .as_array() + .unwrap() + .iter() + .any(|e| e["action"] == "skipped" + && e["errorCode"] == "already_vendored" + && e["purl"] == fx.target.purl()), + "in-sync rerun must report already_vendored for {}: {env2}", + fx.target.purl() + ); + assert!( + !env2["events"] + .as_array() + .unwrap() + .iter() + .any(|e| e["action"] == "applied"), + "no entry may be re-applied on an in-sync re-run: {env2}" + ); assert_eq!( std::fs::read(&lock_path).unwrap(), lock_wired, "re-vendor must leave bun.lock byte-identical" ); - // 6. REVERT PROOF: bun.lock restored byte-for-byte, artifacts gone. - let (code, stdout, stderr) = run_socket( - proj, - &[ - "vendor", - "--revert", - "--json", - "--offline", - "--cwd", - proj.to_str().unwrap(), - ], - ); + // 7. REVERT PROOF: bun.lock restored byte-for-byte, artifacts gone. + let (code, stdout, stderr) = run_vendor(&fx, &["--revert"]); assert_eq!( code, 0, "revert failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" @@ -461,12 +1150,66 @@ fn bun_vendor_fresh_checkout_frozen_install_and_revert() { "revert must leave package.json byte-identical" ); assert!( - !proj.join(".socket/vendor").exists(), + !proj.join(".socket").join("vendor").exists(), ".socket/vendor must be fully removed after revert" ); eprintln!("REVERT OK"); } +// ── the scoped, dependency-bearing leg ──────────────────────────────── + +/// Scoped target with `dependencies` + `bin`: the vendored rewrite must +/// carry bun's meta object verbatim into the local-tarball 3-tuple (path +/// keeping the scope dir), leave the dependency's own registry entry +/// alone, and the fresh install must prove bun honored that meta — +/// left-pad installed, the bin linked — on top of the patched bytes and +/// the stable lock. +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +async fn bun_vendor_scoped_package_keeps_deps_and_bin_meta() { + let server = MockServer::start().await; + mount_scoped_registry(&server, scoped_registry_tgz()).await; + let Some(fx) = bun_project( + "scoped-with-deps", + Target::ScopedWithDeps, + Some(&server.uri()), + ) else { + return; + }; + stage_and_vendor(&fx); + fresh_checkout_install_proof(&fx, "fresh"); +} + +// ── the tampered twin ───────────────────────────────────────────────── + +/// Negative twin: the committed tarball is swapped for a DIFFERENT valid +/// tarball while bun.lock keeps our sha512. From bun 1.3.10 the fresh frozen +/// install must refuse on the integrity check; earlier bun installs the +/// tampered bytes with exit 0 and the leg pins THAT (PARTIAL), so the +/// digest boundary is asserted from both sides across the lock-era legs. +#[test] +#[serial_test::serial] +fn bun_vendor_tampered_tarball_digest_boundary() { + let Some(fx) = bun_project("tampered", Target::LeftPad, None) else { + return; + }; + stage_and_vendor(&fx); + + // Tamper: a valid tarball with different content under the same path. + // The lock still pins the sha512 of OUR tarball. + let tampered: Vec = [TAMPER_MARKER.as_bytes(), fx.orig.as_slice()].concat(); + let tampered_tgz = make_tgz_from_installed(&fx.target.installed_dir(&fx.proj), &tampered); + let tgz_path = vendored_tgz(&fx); + assert_ne!( + std::fs::read(&tgz_path).unwrap(), + tampered_tgz, + "the replacement tarball must differ from the vendored one" + ); + std::fs::write(&tgz_path, &tampered_tgz).unwrap(); + + assert_tamper_outcome(&fx, &tampered); +} + // ── the get-driven twin (v3.6) ──────────────────────────────────────── /// `view/{uuid}` carrying the SAME hashes the stager computes plus base64 @@ -498,23 +1241,25 @@ async fn mock_view(server: &MockServer, purl: &str, before: &[u8], after: &[u8]) /// get-driven twin: `get --mode vendored` must land scan's vendored /// result — manifest record + committed artifact + ledger + wired lock, NO -/// blobs — and the fresh-checkout frozen install must materialize the +/// blobs — and the fresh-checkout install proof must materialize the /// patched bytes. The revert half is deliberately not repeated here: /// `vendor --revert` on the capstone above already proves it (same ledger, /// same engine). #[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] async fn bun_get_uuid_vendored_fresh_checkout_frozen_install() { - let Some(fx) = bun_project("get-uuid-vendored") else { + let Some(fx) = bun_project("get-uuid-vendored", Target::LeftPad, None) else { return; }; let proj = &fx.proj; + let purl = fx.target.purl(); // Steps 2–3, get-driven: the patch record comes from a mocked // `view/{uuid}` instead of a hand-staged `.socket/`, and the vendor step // builds the artifact locally (`--vendor-source build` — no vendoring // service, so no grant/tarball mocks are needed). let server = MockServer::start().await; - mock_view(&server, &fx.purl, &fx.orig, &fx.patched).await; + mock_view(&server, purl, &fx.orig, &fx.patched).await; let server_uri = server.uri(); let (code, stdout, stderr) = run_socket( @@ -574,23 +1319,246 @@ async fn bun_get_uuid_vendored_fresh_checkout_frozen_install() { ); // Manifest yes, blobs no (scan-vendored parity: content stays in memory). - let manifest: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(proj.join(".socket/manifest.json")).unwrap()) - .unwrap(); + let manifest: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(proj.join(".socket").join("manifest.json")).unwrap(), + ) + .unwrap(); assert_eq!( - manifest["patches"][fx.purl.as_str()]["uuid"], - UUID, + manifest["patches"][purl]["uuid"], UUID, "the manifest must record the vendored patch: {manifest}" ); assert!( - !proj.join(".socket/blobs").exists(), + !proj.join(".socket").join("blobs").exists(), "get --mode vendored must NOT persist blobs" ); - assert_vendored_on_disk(proj, &fx.pkg_before); + assert_vendored_on_disk(&fx); eprintln!("GET VENDOR OK"); // FRESH-CHECKOUT PROOF: committable files only, EMPTY cache, - // spike-proven `--frozen-lockfile`. - fresh_checkout_frozen_install(fx.tmp.path(), proj, &fx.patched); + // spike-proven `--frozen-lockfile`, then the ordinary-install twin. + fresh_checkout_install_proof(&fx, "fresh"); +} + +// ── digest-dropping lock re-saves (Bun 1.1.39–1.3.9) ───────────────── + +/// A local `file:` tarball dep (`local-dep-`) added to package.json plus +/// this bun's ordinary install: the one network-free way to make bun +/// RE-SAVE an existing lock. Returns the tarball file name, which every +/// fresh checkout below must carry along. +fn grow_project_with_local_dep(fx: &BunProject, n: u32) -> String { + let name = format!("local-dep-{n}"); + let tgz_name = format!("{name}-1.0.0.tgz"); + let tgz = build_tgz(&[ + ( + "package.json".to_string(), + format!(r#"{{"name":"{name}","version":"1.0.0"}}"#).into_bytes(), + 0o644, + ), + ( + "index.js".to_string(), + b"module.exports = 'local';\n".to_vec(), + 0o644, + ), + ]); + std::fs::write(fx.proj.join(&tgz_name), tgz).unwrap(); + let pkg_path = fx.proj.join("package.json"); + let mut pkg: serde_json::Value = + serde_json::from_slice(&std::fs::read(&pkg_path).unwrap()).unwrap(); + pkg["dependencies"][&name] = serde_json::json!(format!("file:./{tgz_name}")); + std::fs::write(&pkg_path, serde_json::to_vec_pretty(&pkg).unwrap()).unwrap(); + let cache = fx.tmp.path().join(format!("resave-{n}-bun-cache")); + let out = bun(&fx.proj, &fixture_install_args(fx.bun_version), &cache); + assert!( + out.status.success(), + "`bun install` after adding {name} must succeed.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + let lock = std::fs::read_to_string(fx.proj.join("bun.lock")).unwrap(); + assert!( + lock.contains(&format!("\"{name}\": [")), + "the re-save must have landed {name}'s entry:\n{lock}" + ); + tgz_name +} + +/// The re-saved line must be the recorded 3-tuple (bun ≥ 1.3.10) or its +/// digest-less 2-tuple (below) — asserted per era, never guessed. +fn assert_resave_shape(fx: &BunProject, wired_line: &str) -> String { + let live = packages_line( + &std::fs::read_to_string(fx.proj.join("bun.lock")).unwrap(), + fx.target.name(), + ); + let digestless = format!( + "{}],", + &wired_line[..wired_line.rfind(", \"sha512-").unwrap()] + ); + if fx.bun_version < TARBALL_INTEGRITY_ENFORCED_FROM { + assert_eq!( + live, digestless, + "bun {} (< 1.3.10) must re-save the local tuple WITHOUT its sha512", + fx.bun_raw + ); + } else { + assert_eq!( + live, wired_line, + "bun {} (>= 1.3.10) must keep the local tuple's sha512 on re-save", + fx.bun_raw + ); + } + live +} + +/// `bun install --frozen-lockfile` in a fresh checkout carrying the grown +/// project's local tarballs; returns the installed target `index.js`. +fn fresh_frozen_install_with_local_deps(fx: &BunProject, name: &str, tgzs: &[String]) -> Vec { + let fresh = fresh_checkout(fx, name); + for tgz in tgzs { + std::fs::copy(fx.proj.join(tgz), fresh.join(tgz)).unwrap(); + } + let cache = fx.tmp.path().join(format!("{name}-bun-cache")); + let ci = bun( + &fresh, + &["install", "--frozen-lockfile", "--ignore-scripts"], + &cache, + ); + assert!( + ci.status.success(), + "fresh-checkout `bun install --frozen-lockfile` ({name}) must succeed.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&ci.stdout), + String::from_utf8_lossy(&ci.stderr) + ); + std::fs::read(fx.target.installed_dir(&fresh).join("index.js")).unwrap() +} + +/// Every text-lock bun below 1.3.10 re-saves our local-tarball 3-tuple +/// WITHOUT its sha512 whenever the lock is re-saved for another reason +/// (measured on 1.1.45, 1.2.23 and 1.3.9; 1.3.10+ keep it). The 2-tuple is +/// still our wiring, so after a real re-save: the `vendor` re-run must stay +/// a clean no-op that heals the digest, `repair` must rebuild a deleted +/// artifact through it and re-pin the digest, a fresh frozen install must +/// land the patched bytes, and — after bun drops the digest AGAIN — +/// `vendor --revert` must restore the registry line inside the grown lock. +/// On ≥ 1.3.10 the same steps are the no-regression twin (digest kept). +#[test] +#[serial_test::serial] +fn bun_vendor_survives_a_digest_dropping_lock_resave() { + let Some(fx) = bun_project("vendor-digestless-resave", Target::LeftPad, None) else { + return; + }; + let proj = &fx.proj; + let lock_path = proj.join("bun.lock"); + stage_and_vendor(&fx); + let wired_line = packages_line(&std::fs::read_to_string(&lock_path).unwrap(), DEP); + assert!(wired_line.contains("\"sha512-"), "{wired_line}"); + + // 1. Grow → re-save; assert the era's spelling. + let tgz_a = grow_project_with_local_dep(&fx, 1); + assert_resave_shape(&fx, &wired_line); + eprintln!("RESAVE OK (bun {})", fx.bun_raw); + + // 2. Re-run `vendor`: exit 0, nothing failed, in sync, digest healed. + let (code, stdout, stderr) = run_vendor(&fx, &[]); + assert_eq!( + code, 0, + "re-vendor over the re-saved lock failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let env = parse_envelope(&stdout); + assert_eq!(env["status"], "success", "{env}"); + assert_eq!(env["summary"]["failed"], 0, "{env}"); + assert!( + env["events"] + .as_array() + .unwrap() + .iter() + .all(|e| e["errorCode"] != "vendor_lock_entry_not_found" && e["action"] != "failed"), + "{env}" + ); + let healed = std::fs::read_to_string(&lock_path).unwrap(); + assert_eq!( + packages_line(&healed, DEP), + wired_line, + "the re-run must leave the canonical local 3-tuple in place:\n{healed}" + ); + eprintln!("RE-VENDOR OK"); + + // 3. Repair through a digest-less line: drop the digest again, delete + // the artifact, rebuild. + let tgz_b = grow_project_with_local_dep(&fx, 2); + assert_resave_shape(&fx, &wired_line); + std::fs::remove_dir_all(vendored_dir(proj)).unwrap(); + let (code, stdout, stderr) = run_socket( + proj, + &[ + "repair", + "--json", + "--offline", + "--yes", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "repair failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let renv = parse_envelope(&stdout); + assert_eq!(renv["status"], "success", "{renv}"); + assert_eq!(renv["summary"]["rebuilt"], 1, "{renv}"); + assert!(vendored_tgz(&fx).is_file(), "the artifact must be rebuilt"); + let repaired = std::fs::read_to_string(&lock_path).unwrap(); + assert_eq!( + packages_line(&repaired, DEP), + wired_line, + "repair re-pins the digest into the healed 3-tuple:\n{repaired}" + ); + eprintln!("REPAIR THROUGH DIGEST-LESS LOCK OK"); + + // 4. The repaired lock installs the patched bytes from an empty cache. + let installed = fresh_frozen_install_with_local_deps( + &fx, + "fresh-repaired", + &[tgz_a.clone(), tgz_b.clone()], + ); + assert_eq!( + installed, fx.patched, + "the repaired lock must install the patched bytes" + ); + + // 5. Drop the digest once more, then revert straight over it. + let tgz_c = grow_project_with_local_dep(&fx, 3); + assert_resave_shape(&fx, &wired_line); + let (code, stdout, stderr) = run_vendor(&fx, &["--revert"]); + assert_eq!( + code, 0, + "revert over the re-saved lock failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let renv = parse_envelope(&stdout); + assert_eq!(renv["status"], "success", "{renv}"); + assert_eq!(renv["summary"]["removed"], 1, "{renv}"); + let restored = std::fs::read_to_string(&lock_path).unwrap(); + let lock_before = String::from_utf8(fx.lock_before.clone()).unwrap(); + assert_eq!( + packages_line(&restored, DEP), + packages_line(&lock_before, DEP), + "the pristine registry 4-tuple must be back:\n{restored}" + ); + for n in 1..=3 { + assert!( + restored.contains(&format!("\"local-dep-{n}\": [")), + "revert must not disturb the grown entries:\n{restored}" + ); + } + assert!( + !proj.join(".socket").join("vendor").exists(), + ".socket/vendor must be fully removed after revert" + ); + let installed = + fresh_frozen_install_with_local_deps(&fx, "fresh-reverted", &[tgz_a, tgz_b, tgz_c]); + assert_eq!( + installed, fx.orig, + "after revert bun installs the ORIGINAL bytes" + ); + eprintln!("REVERT AFTER RESAVE OK (bun {})", fx.bun_raw); } diff --git a/crates/socket-patch-cli/tests/get_modes_e2e.rs b/crates/socket-patch-cli/tests/get_modes_e2e.rs index f3e3c274..87387960 100644 --- a/crates/socket-patch-cli/tests/get_modes_e2e.rs +++ b/crates/socket-patch-cli/tests/get_modes_e2e.rs @@ -672,7 +672,14 @@ async fn get_vendored_then_hosted_takes_over_cleanly() { let (code, _stdout, stderr) = run_get( tmp.path(), &server.uri(), - &[UUID1, "--mode", "vendored", "--json", "--vendor-source", "build"], + &[ + UUID1, + "--mode", + "vendored", + "--json", + "--vendor-source", + "build", + ], ); assert_eq!(code, 0, "vendored step failed: {stderr}"); let artifact = tmp @@ -794,8 +801,11 @@ async fn get_hosted_runs_release_variant_filter() { ) .unwrap(); - let (code, stdout, stderr) = - run_get(tmp.path(), &server.uri(), &[GHSA, "--mode", "hosted", "--json"]); + let (code, stdout, stderr) = run_get( + tmp.path(), + &server.uri(), + &[GHSA, "--mode", "hosted", "--json"], + ); assert_eq!(code, 0, "stderr: {stderr}\nstdout: {stdout}"); let envelope = parse_single_json_doc(&stdout); let warnings = envelope["warnings"].to_string(); @@ -839,3 +849,202 @@ async fn get_pnp_only_narrowing_message_names_the_layout() { "the layout refusal warning must be on stderr; stderr:\n{stderr}" ); } + +// --------------------------------------------------------------------------- +// Bun vendored-mode preflight through `get`: --silent, --dry-run, --save-only +// --------------------------------------------------------------------------- + +const ENCODED1: &str = "pkg%3Anpm%2Fgetmodes-pkg%401.0.0"; +const BUN_WS_CODE: &str = "vendor_bun_workspace_unsupported"; + +/// The per-package search for the exact PURL identifier path. +async fn mock_by_package(server: &MockServer) { + Mock::given(method("GET")) + .and(path(format!( + "/v0/orgs/{ORG}/patches/by-package/{ENCODED1}" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": [{ + "uuid": UUID1, "purl": PURL1, + "publishedAt": "2024-01-01T00:00:00Z", + "description": "get-modes fixture", "license": "MIT", "tier": "free", + "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; +} + +/// `write_project` re-locked by bun 1.3.14 as a workspace: the real +/// lockfileVersion-1 grammar (1-tuple `workspace:` entry, blank line +/// between entries, trailing commas), `getmodes-pkg` declared by the +/// member — the shape the vendored gate refuses. +fn write_bun_workspace_project(root: &Path) { + write_project(root); + std::fs::remove_file(root.join("package-lock.json")).unwrap(); + std::fs::write( + root.join("package.json"), + r#"{ "name": "consumer", "version": "0.0.0", "private": true, "workspaces": ["packages/*"], "dependencies": { "app": "workspace:*" } }"#, + ) + .unwrap(); + let app = root.join("packages/app"); + std::fs::create_dir_all(&app).unwrap(); + std::fs::write( + app.join("package.json"), + format!( + r#"{{ "name": "app", "version": "1.0.0", "dependencies": {{ "{NAME}": "1.0.0" }} }}"# + ), + ) + .unwrap(); + std::fs::write( + root.join("bun.lock"), + format!( + "{{\n \"lockfileVersion\": 1,\n \"configVersion\": 1,\n \"workspaces\": {{\n \"\": {{\n \"name\": \"consumer\",\n \"dependencies\": {{\n \"app\": \"workspace:*\",\n }},\n }},\n \"packages/app\": {{\n \"name\": \"app\",\n \"version\": \"1.0.0\",\n \"dependencies\": {{\n \"{NAME}\": \"1.0.0\",\n }},\n }},\n }},\n \"packages\": {{\n \"app\": [\"app@workspace:packages/app\"],\n\n \"{NAME}\": [\"{NAME}@1.0.0\", \"\", {{}}, \"sha512-UPSTREAMupstream==\"],\n }}\n}}\n" + ), + ) + .unwrap(); +} + +/// `--silent` is "errors only": the Bun refusal is an error, so BOTH +/// identifier kinds keep it on stderr — code-tagged — with an empty +/// stdout and exit 1. Regression guard: the refusal lines were gated on +/// `!silent`, so a `--silent` run exited 1 with no text anywhere. +#[tokio::test] +async fn get_vendored_refusal_visible_under_silent() { + let server = MockServer::start().await; + mock_view(&server, UUID1, PURL1).await; + mock_by_package(&server).await; + + for (label, ident) in [("purl", PURL1), ("uuid", UUID1)] { + let tmp = tempfile::tempdir().unwrap(); + write_bun_workspace_project(tmp.path()); + let lock_before = std::fs::read(tmp.path().join("bun.lock")).unwrap(); + let (code, stdout, stderr) = run_get( + tmp.path(), + &server.uri(), + &[ + ident, + "--mode", + "vendored", + "--vendor-source", + "build", + "--silent", + ], + ); + assert_eq!(code, 1, "{label}: stdout={stdout}\nstderr={stderr}"); + assert!( + stdout.trim().is_empty(), + "{label}: --silent must print nothing on stdout:\n{stdout}" + ); + assert!( + stderr.contains(BUN_WS_CODE), + "{label}: --silent must keep the refusal code on stderr:\n{stderr}" + ); + if label == "purl" { + assert!( + stderr.contains(&format!("[error] {PURL1} ({BUN_WS_CODE}):")), + "purl path prints the code-tagged per-patch line:\n{stderr}" + ); + } else { + assert!( + stderr.contains(&format!("Error ({BUN_WS_CODE}):")), + "uuid path prints the code-tagged Error line:\n{stderr}" + ); + } + assert_eq!( + std::fs::read(tmp.path().join("bun.lock")).unwrap(), + lock_before, + "{label}: refused runs never touch the lock" + ); + assert!(!tmp.path().join(".socket/vendor").exists(), "{label}"); + } +} + +/// The vendored dry-run preview names what the wet run would refuse: +/// `would_refuse` + `errorCode` + `error` (additive) instead of +/// `would_vendor`, on both identifier kinds — exit 0, `status:"success"`, +/// nothing written, exactly like every other vendored preview. +#[tokio::test] +async fn get_vendored_dry_run_reports_bun_refusal() { + let server = MockServer::start().await; + mock_view(&server, UUID1, PURL1).await; + mock_by_package(&server).await; + + for (label, ident) in [("purl", PURL1), ("uuid", UUID1)] { + let tmp = tempfile::tempdir().unwrap(); + write_bun_workspace_project(tmp.path()); + let lock_before = std::fs::read(tmp.path().join("bun.lock")).unwrap(); + let (code, stdout, stderr) = run_get( + tmp.path(), + &server.uri(), + &[ + ident, + "--mode", + "vendored", + "--vendor-source", + "build", + "--dry-run", + "--json", + ], + ); + assert_eq!(code, 0, "{label}: stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + assert_eq!(v["status"], "success", "{label}: {v}"); + assert_eq!(v["found"], 1, "{label}: {v}"); + assert_eq!(v["vendor"]["dryRun"], true, "{label}: {v}"); + let rec = &v["vendor"]["patches"][0]; + assert_eq!(rec["purl"], PURL1, "{label}: {v}"); + assert_eq!(rec["uuid"], UUID1, "{label}: {v}"); + assert_eq!(rec["action"], "would_refuse", "{label}: {v}"); + assert_eq!(rec["errorCode"], BUN_WS_CODE, "{label}: {v}"); + assert!( + rec["error"].as_str().is_some_and(|d| !d.is_empty()), + "{label}: {v}" + ); + assert_eq!( + std::fs::read(tmp.path().join("bun.lock")).unwrap(), + lock_before, + "{label}: dry-run must not touch the lock" + ); + assert!( + !tmp.path().join(".socket").exists(), + "{label}: vendored dry-run is a preview: no manifest, no artifacts, no ledger" + ); + } +} + +/// The preflight is scoped to the vendored posture: an agent-mode +/// `get --save-only` on the same refused workspace project records the +/// patch and persists the blob like on any other project (record-only +/// intent has no Bun precondition; the fresh-clone record→vendor flow +/// keeps working). +#[tokio::test] +async fn get_save_only_agent_ignores_bun_preflight() { + let server = MockServer::start().await; + mock_view(&server, UUID1, PURL1).await; + mock_by_package(&server).await; + let tmp = tempfile::tempdir().unwrap(); + write_bun_workspace_project(tmp.path()); + + let (code, stdout, stderr) = + run_get(tmp.path(), &server.uri(), &[PURL1, "--save-only", "--json"]); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + assert_eq!(v["status"], "success", "{v}"); + assert_eq!(v["patches"][0]["action"], "added", "{v}"); + assert!(v["patches"][0].get("errorCode").is_none(), "{v}"); + assert_eq!(requests_containing(&server, "/patches/view/").await, 1); + let manifest: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).unwrap(), + ) + .unwrap(); + assert_eq!(manifest["patches"][PURL1]["uuid"], UUID1, "{manifest}"); + assert!( + tmp.path() + .join(".socket/blobs") + .join(common::git_sha256(AFTER_BYTES)) + .is_file(), + "the agent download persists the after-blob" + ); +} diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index f034c7f6..caee765c 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -967,15 +967,153 @@ async fn scan_redirect_refuses_bun_lock_v3() { ); } +/// The packages-entry line keyed `key` in a bun.lock (verbatim, no line +/// terminator). +fn bun_packages_line(lock: &str, key: &str) -> String { + let prefix = format!(" \"{key}\": ["); + lock.split('\n') + .find(|l| l.starts_with(&prefix)) + .unwrap_or_else(|| panic!("no `{key}` packages entry in:\n{lock}")) + .trim_end_matches('\r') + .to_string() +} + +/// Re-spell a URL 3-tuple line the way Bun 1.1.39–1.3.9 re-save it on any +/// later lock re-save (`bun add `, `bun install` after a package.json +/// change): the trailing `"sha512-…"` element dropped, everything else +/// verbatim (measured on real 1.1.45, 1.2.23 and 1.3.9; 1.3.10+ keep it). +fn drop_bun_digest(line: &str) -> String { + let cut = line + .rfind(", \"sha512-") + .unwrap_or_else(|| panic!("no sha512 element in {line}")); + let tail = if line.ends_with("],") { "]," } else { "]" }; + format!("{}{tail}", &line[..cut]) +} + +/// Bun 1.1.39–1.3.9 re-save our URL 3-tuple WITHOUT its sha512 whenever the +/// lock is re-saved for another reason. The digest-less 2-tuple is still +/// our wiring (the spec bun installs from is intact): a repeat `scan +/// --mode hosted` must report a CONSISTENT envelope — `redirected: 1` with +/// no `redirect_bun_entry_not_found` — heal the line back to the 3-tuple +/// and record the heal as a second ledger edit for the key (`original` = +/// the 2-tuple); a third run appends nothing; and `rollback` must unwind +/// the chain to the pristine registry line whether the lock is the healed +/// 3-tuple or Bun has since dropped the digest again. Before the fix the +/// repeat scan warned `entry_not_found` beside `redirected: 1` and +/// rollback refused `partial_failure` ("matches neither the redirected nor +/// the original fragment"), stranding every user on those releases. +#[tokio::test] +#[serial] +async fn scan_redirect_heals_digestless_bun_tuple_and_rollback_restores_the_registry_line() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + // The record fetch too, so the "no warnings at all" assertion below is + // exact (without it every run carries a `record_fetch_failed` advisory). + mock_view(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + write_bun_project(tmp.path(), 1); + let lock_path = tmp.path().join("bun.lock"); + let pristine = std::fs::read_to_string(&lock_path).unwrap(); + let ledger_path = tmp.path().join(".socket/vendor/redirect-state.json"); + + for drop_again_before_rollback in [false, true] { + let env = run_redirect_subprocess(tmp.path(), &server.uri()); + assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + let wired = std::fs::read_to_string(&lock_path).unwrap(); + let wired_line = bun_packages_line(&wired, NAME); + assert!( + wired_line.contains(HOSTED_URL) && wired_line.contains(PATCHED_SHA512), + "{wired_line}" + ); + let digestless = drop_bun_digest(&wired_line); + assert!( + digestless.ends_with("{}],") && !digestless.contains("sha512"), + "{digestless}" + ); + std::fs::write(&lock_path, wired.replace(&wired_line, &digestless)).unwrap(); + + // Repeat hosted run over the digest-less lock. + let env = run_redirect_subprocess(tmp.path(), &server.uri()); + assert_eq!(env["status"], "success", "{env:#}"); + assert_eq!( + env["redirect"]["redirected"], 1, + "the wired dep still counts as redirected: {env:#}" + ); + let codes = warning_codes(&env); + assert!( + !codes.contains(&"redirect_bun_entry_not_found".to_string()), + "a digest-less instance of our own wiring is not `entry_not_found`: {env:#}" + ); + assert_eq!( + std::fs::read_to_string(&lock_path).unwrap(), + wired, + "the digest is healed back — lock byte-identical to the first run's" + ); + let ledger = read_ledger(tmp.path()); + let edits = ledger["edits"].as_array().unwrap(); + assert_eq!(edits.len(), 2, "first edit + the heal: {ledger:#}"); + assert_eq!( + edits[0]["original"], + serde_json::json!(bun_packages_line(&pristine, NAME)), + "{ledger:#}" + ); + assert_eq!(edits[1]["key"], NAME, "{ledger:#}"); + assert_eq!( + edits[1]["original"], + serde_json::json!(digestless), + "{ledger:#}" + ); + assert_eq!(edits[1]["new"], serde_json::json!(wired_line), "{ledger:#}"); + + // A third run over the healed lock is a no-op for the ledger. + let env = run_redirect_subprocess(tmp.path(), &server.uri()); + assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + assert!(warning_codes(&env).is_empty(), "{env:#}"); + assert_eq!( + read_ledger(tmp.path())["edits"].as_array().unwrap().len(), + 2, + "a re-run over the healed lock must not append edits" + ); + + if drop_again_before_rollback { + // Another `bun add` on Bun < 1.3.10: the digest is gone again. + std::fs::write(&lock_path, wired.replace(&wired_line, &digestless)).unwrap(); + } + let (code, env) = rollback_json(tmp.path()); + assert_eq!( + code, + Some(0), + "rollback (digest dropped again: {drop_again_before_rollback}) must succeed: {env:#}" + ); + assert_eq!(env["status"], "success", "{env:#}"); + assert_eq!( + std::fs::read_to_string(&lock_path).unwrap(), + pristine, + "rollback lands on the pristine registry line (digest dropped again: \ + {drop_again_before_rollback})" + ); + assert!( + !ledger_path.exists(), + "the emptied ledger is deleted after a full unwind" + ); + } +} + /// The bun.lockb auto-migration leg: a fake `bun` shim prepended to PATH writes -/// a canned text bun.lock and deletes bun.lockb, exercising the migration -/// branch of `run_redirect` without a real bun. The migration removal is -/// recorded in the ledger, and the freshly-written bun.lock is then redirected. +/// a canned text bun.lock and deletes bun.lockb (the bun ≥ 1.2 shape), +/// exercising the migration branch of `run_redirect` without a real bun. The +/// migration removal is recorded in the ledger WITH the pre-migration bytes +/// (standard base64 in `original`), and the freshly-written bun.lock is then +/// redirected. /// -/// unix-only: the shim is a `#!/bin/sh` script (Windows would need a .cmd -/// twin and `;` PATH joining). The migration path itself is OS-agnostic -/// (`Command::new("bun")` resolves bun.exe on Windows) and gets real-bun -/// coverage in the toolchain-gated e2e_redirect_bun_build capstone. +/// unix-only: this shim is a `#!/bin/sh` script; the `bun.cmd` twins further +/// down (`#[cfg(windows)]`) cover the same arms through the PATHEXT-aware +/// resolver. Real-bun coverage of the migration lives in the +/// bun-compatibility matrix (`.github/workflows/bun-compatibility.yml`, the +/// bun 1.1.45 hosted cells and the legacy-lockb shape) — NOT in +/// e2e_redirect_bun_build, whose fixture is a text lock from the start. #[cfg(unix)] #[tokio::test] #[serial] @@ -1049,13 +1187,734 @@ async fn scan_redirect_migrates_bun_lockb_then_redirects() { lock.contains(HOSTED_URL) && lock.contains(PATCHED_SHA512), "the migrated bun.lock must be redirected; got:\n{lock}" ); - // The migration removal is recorded (action "removed") for revert. - let ledger = - std::fs::read_to_string(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(); + // The migration removal is recorded (action "removed") for revert, and + // carries the pre-migration bytes so `rollback` can put the binary lock + // back — standard base64 of the placeholder. + let ledger = read_ledger(tmp.path()); + let migration = &ledger["edits"][0]; + assert_eq!(migration["path"], "bun.lockb", "{ledger:#}"); + assert_eq!( + migration["kind"], "redirect_bun_lockb_migrated", + "{ledger:#}" + ); + assert_eq!(migration["action"], "removed", "{ledger:#}"); + assert_eq!( + migration["original"], + serde_json::Value::String(standard_base64(b"BUN-BINARY-PLACEHOLDER")), + "the ledger must carry the pre-migration bytes: {ledger:#}" + ); + assert_eq!( + ledger["edits"][1]["kind"], "redirect_bun_lock_package", + "{ledger:#}" + ); +} + +// ───────────── bun.lockb migration truthfulness (shim-driven, subprocess) ───────────── +// +// Real Bun behaviour the shims below emulate (measured against 1.1.38 … 1.4.2): +// • ≤ 1.1.38 fail on the flags (non-zero) — `redirect_bun_lockb_unsupported`; +// • 1.1.39 accepts them, exits 0 and writes NO bun.lock — +// `redirect_bun_lockb_manual_migration`; +// • 1.1.43–1.1.45 write bun.lock and KEEP bun.lockb — the CLI removes it so +// the ledger's `removed` is true; +// • ≥ 1.2 write bun.lock and delete bun.lockb. +// Every run goes through the built binary (child-only PATH) so the `--json` +// envelope and the `rollback` warnings can be read back without touching the +// parent's environment. + +/// Placeholder binary lock: deliberately NOT valid UTF-8 (a real bun.lockb is +/// binary) so the base64 round-trip is exercised on bytes a JSON string could +/// never carry raw. +const LOCKB_BYTES: &[u8] = b"\x00BUN-BINARY\xff\xfe\x00LOCK"; + +fn standard_base64(bytes: &[u8]) -> String { + use base64::Engine as _; + base64::engine::general_purpose::STANDARD.encode(bytes) +} + +/// The bun text lock the shims "write": the registry 4-tuple for NAME@VERSION +/// in bun's emitted single-line grammar (lockfileVersion 1). +fn canned_bun_lock() -> String { + format!( + "{{\n \"lockfileVersion\": 1,\n \"packages\": {{\n \ + \"{NAME}\": [\"{NAME}@{VERSION}\", \"\", {{}}, \"sha512-UPSTREAMupstream==\"],\n \ + }}\n}}\n" + ) +} + +/// package.json + installed copy + the placeholder bun.lockb (never parsed). +fn write_bun_lockb_project(root: &Path, lockb: &[u8]) { + std::fs::write( + root.join("package.json"), + format!( + r#"{{ "name": "consumer", "version": "0.0.0", "dependencies": {{ "{NAME}": "^{VERSION}" }} }}"# + ), + ) + .unwrap(); + let pkg = root.join("node_modules").join(NAME); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + format!(r#"{{ "name": "{NAME}", "version": "{VERSION}" }}"#), + ) + .unwrap(); + std::fs::write(root.join("bun.lockb"), lockb).unwrap(); +} + +/// A fake `bun` (`#!/bin/sh` script) in `/fakebin`; returns that dir. +#[cfg(unix)] +fn install_bun_shim(root: &Path, body: &str) -> std::path::PathBuf { + use std::os::unix::fs::PermissionsExt; + let bin_dir = root.join("fakebin"); + std::fs::create_dir_all(&bin_dir).unwrap(); + let shim = bin_dir.join("bun"); + std::fs::write(&shim, body).unwrap(); + std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755)).unwrap(); + bin_dir +} + +#[cfg(unix)] +#[tokio::test] +async fn symlinked_bun_lockb_refuses_before_migration_including_dry_run() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + for dry_run in [false, true] { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_bun_lockb_project(root, LOCKB_BYTES); + std::fs::rename(root.join("bun.lockb"), root.join("shared.lockb")).unwrap(); + std::os::unix::fs::symlink("shared.lockb", root.join("bun.lockb")).unwrap(); + let bin = install_bun_shim( + root, + &format!( + "#!/bin/sh\ntouch bun-was-spawned\ncat > bun.lock <<'LOCK'\n{}LOCK\n", + canned_bun_lock(), + ), + ); + let mut cmd = scrubbed_cli(); + cmd.args([ + "scan", + "--mode", + "hosted", + "--json", + "--yes", + "--cwd", + root.to_str().unwrap(), + "--api-url", + &server.uri(), + "--org", + ORG, + "--api-token", + "fake", + ]) + .env("PATH", path_with_first(&bin)); + if dry_run { + cmd.arg("--dry-run"); + } + let output = cmd.output().unwrap(); + let env: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(output.status.code(), Some(1), "{env:#}"); + assert_eq!( + env["errorCode"], "redirect_symlinked_file_unsupported", + "{env:#}" + ); + assert_eq!( + std::fs::read_link(root.join("bun.lockb")).unwrap(), + std::path::Path::new("shared.lockb") + ); + assert_eq!( + std::fs::read(root.join("shared.lockb")).unwrap(), + LOCKB_BYTES + ); + assert!(!root.join("bun-was-spawned").exists()); + assert!(!root.join("bun.lock").exists()); + assert!(!root.join(".socket/vendor/redirect-state.json").exists()); + } +} + +/// A fake `bun.cmd` batch shim in `/fakebin` (the npm-global `bun` +/// layout: no bun.exe anywhere on PATH); returns that dir. `body` is joined +/// with CRLF as cmd.exe expects. +#[cfg(windows)] +fn install_bun_cmd_shim(root: &Path, lines: &[&str]) -> std::path::PathBuf { + let bin_dir = root.join("fakebin"); + std::fs::create_dir_all(&bin_dir).unwrap(); + let mut body = String::from("@echo off\r\n"); + for line in lines { + body.push_str(line); + body.push_str("\r\n"); + } + std::fs::write(bin_dir.join("bun.cmd"), body).unwrap(); + bin_dir +} + +/// A child-only PATH with `bin_dir` first, joined with the OS separator. +fn path_with_first(bin_dir: &Path) -> std::ffi::OsString { + let mut entries = vec![bin_dir.to_path_buf()]; + entries.extend(std::env::split_paths( + &std::env::var_os("PATH").unwrap_or_default(), + )); + std::env::join_paths(entries).expect("PATH entries join") +} + +/// `scan --redirect --json --yes` as a subprocess with the given child PATH; +/// returns (exit code, parsed envelope, stderr). Asserts stdout IS JSON so a +/// leaking shim (bun chatter on stdout) fails loudly. +fn scan_redirect_json_with_path( + cwd: &Path, + api_url: &str, + path: &std::ffi::OsStr, +) -> (Option, serde_json::Value, String) { + let out = scrubbed_cli() + .args([ + "scan", + "--redirect", + "--json", + "--yes", + "--cwd", + cwd.to_str().unwrap(), + "--api-url", + api_url, + "--org", + ORG, + "--api-token", + "fake", + ]) + .env("PATH", path) + .output() + .expect("run socket-patch"); + let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); + let env_json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap_or_else(|e| { + panic!( + "--json stdout must be a pure JSON envelope: {e}\nstdout:\n{}\nstderr:\n{stderr}", + String::from_utf8_lossy(&out.stdout) + ) + }); + (out.status.code(), env_json, stderr) +} + +/// `rollback --json --yes --offline` as a subprocess; returns (exit code, +/// parsed envelope). +fn rollback_json(cwd: &Path) -> (Option, serde_json::Value) { + let out = scrubbed_cli() + .args([ + "rollback", + "--json", + "--yes", + "--offline", + "--cwd", + cwd.to_str().unwrap(), + ]) + .output() + .expect("run socket-patch rollback"); + let env_json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap_or_else(|e| { + panic!( + "rollback --json stdout must be JSON: {e}\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ) + }); + (out.status.code(), env_json) +} + +/// The `code`s of a rollback envelope's top-level `warnings`. +fn rollback_warning_codes(env: &serde_json::Value) -> Vec { + env["warnings"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|w| w["code"].as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() +} + +/// The `detail` of the first redirect warning carrying `code`. +fn redirect_warning_detail(env: &serde_json::Value, code: &str) -> String { + env["redirect"]["warnings"] + .as_array() + .into_iter() + .flatten() + .find(|w| w["code"] == code) + .and_then(|w| w["detail"].as_str()) + .unwrap_or_else(|| panic!("expected a `{code}` redirect warning: {env:#}")) + .to_string() +} + +fn read_ledger(root: &Path) -> serde_json::Value { + let text = std::fs::read_to_string(root.join(".socket/vendor/redirect-state.json")).unwrap(); + serde_json::from_str(&text).expect("the ledger is JSON") +} + +/// Shared oracle for the "migration landed, then rollback" round trip +/// (unix `#!/bin/sh` shim and Windows `bun.cmd` shim twins call it). +fn assert_migration_round_trip(tmp: &Path, scan: (Option, serde_json::Value, String)) { + let (code, env, stderr) = scan; + assert_eq!( + code, + Some(0), + "scan must succeed: {env:#}\nstderr:\n{stderr}" + ); + assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + let codes = warning_codes(&env); assert!( - ledger.contains("redirect_bun_lockb_migrated") && ledger.contains("\"removed\""), - "the ledger must record the bun.lockb removal: {ledger}" + !codes.iter().any(|c| c.starts_with("redirect_bun_lockb_")), + "a landed migration carries no lockb warning: {codes:?}" + ); + assert!( + !codes.contains(&"redirect_npm_no_lockfile".to_string()), + "a bun project never gets the npm no-lockfile noise: {codes:?}" + ); + assert!( + !tmp.join("bun.lockb").exists(), + "the CLI must remove the bun.lockb the shim kept, so the project is text-only" + ); + let lock = std::fs::read_to_string(tmp.join("bun.lock")).unwrap(); + assert!( + lock.contains(HOSTED_URL) && lock.contains(PATCHED_SHA512), + "the migrated bun.lock must be redirected; got:\n{lock}" + ); + let ledger = read_ledger(tmp); + let migration = &ledger["edits"][0]; + assert_eq!(migration["path"], "bun.lockb", "{ledger:#}"); + assert_eq!( + migration["kind"], "redirect_bun_lockb_migrated", + "{ledger:#}" + ); + assert_eq!(migration["action"], "removed", "{ledger:#}"); + assert_eq!( + migration["original"], + serde_json::Value::String(standard_base64(LOCKB_BYTES)), + "the ledger carries the pre-migration bytes as standard base64: {ledger:#}" + ); + + // rollback: bun.lock un-redirected (kept), bun.lockb byte-identical. + let (code, env) = rollback_json(tmp); + assert_eq!(code, Some(0), "rollback must succeed: {env:#}"); + assert_eq!(env["status"], "success", "{env:#}"); + let codes = rollback_warning_codes(&env); + assert!( + codes.contains(&"redirect_bun_lockb_restored".to_string()), + "rollback must say it restored the binary lock: {codes:?}" + ); + assert!( + !codes.contains(&"redirect_bun_lockb_unrestorable".to_string()), + "the false unrestorable warning must be gone: {codes:?}" + ); + assert_eq!( + std::fs::read(tmp.join("bun.lockb")).unwrap(), + LOCKB_BYTES, + "bun.lockb must come back byte-identical to the pre-migration placeholder" + ); + let lock = std::fs::read_to_string(tmp.join("bun.lock")).unwrap(); + assert!( + lock.contains(&format!("\"{NAME}@{VERSION}\"")) && !lock.contains(HOSTED_URL), + "the text lock is un-redirected and LEFT IN PLACE (never deleted on rollback):\n{lock}" + ); + assert!( + !tmp.join(".socket/vendor/redirect-state.json").exists(), + "everything unwound: the ledger is gone" + ); +} + +/// bun 1.1.43–1.1.45 shape: the shim writes bun.lock but does NOT delete +/// bun.lockb. The CLI removes it itself (ledger `removed` is true, `original` +/// carries the bytes), and `rollback` restores bun.lockb byte-identical while +/// leaving the un-redirected bun.lock in place — with `redirect_bun_lockb_restored` +/// and NOT the old false `redirect_bun_lockb_unrestorable`. +#[cfg(unix)] +#[tokio::test] +#[serial] +async fn migration_that_keeps_bun_lockb_is_normalized_and_rollback_restores_it() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + write_bun_lockb_project(tmp.path(), LOCKB_BYTES); + let bin_dir = install_bun_shim( + tmp.path(), + &format!( + "#!/bin/sh\n\ + # bun 1.1.45: writes bun.lock, KEEPS bun.lockb\n\ + echo \"Saved bun.lock (2 packages)\"\n\ + cat > bun.lock <<'LOCK'\n{}LOCK\n\ + exit 0\n", + canned_bun_lock() + ), + ); + let scan = scan_redirect_json_with_path(tmp.path(), &server.uri(), &path_with_first(&bin_dir)); + assert_migration_round_trip(tmp.path(), scan); +} + +/// bun 1.1.39 shape: the flags are accepted, exit 0, and NO bun.lock is +/// written. That is neither "bun failed" nor "bun unavailable": the run must +/// emit `redirect_bun_lockb_manual_migration` naming the working manual +/// command, redirect nothing, leave bun.lockb untouched, write no bun.lock or +/// ledger — and must NOT add the npm "no package-lock.json" noise. +#[cfg(unix)] +#[tokio::test] +#[serial] +async fn exit_zero_without_bun_lock_reports_manual_migration() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + write_bun_lockb_project(tmp.path(), LOCKB_BYTES); + let bin_dir = install_bun_shim( + tmp.path(), + "#!/bin/sh\n\ + echo \"bun install v1.1.39 (aa123456)\"\n\ + echo \"Checked 1 install across 2 packages (no changes)\"\n\ + exit 0\n", + ); + let (code, env, stderr) = + scan_redirect_json_with_path(tmp.path(), &server.uri(), &path_with_first(&bin_dir)); + assert_eq!( + code, + Some(0), + "a refused migration is a warning, not an error: {env:#}\n{stderr}" + ); + assert_eq!(env["redirect"]["redirected"], 0, "{env:#}"); + let codes = warning_codes(&env); + assert_eq!( + codes + .iter() + .filter(|c| *c == "redirect_bun_lockb_manual_migration") + .count(), + 1, + "exactly one manual-migration warning: {codes:?}" + ); + assert!( + !codes.contains(&"redirect_bun_lockb_unsupported".to_string()), + "exit 0 is not 'failed or unavailable': {codes:?}" + ); + assert!( + !codes.contains(&"redirect_npm_no_lockfile".to_string()), + "a bun.lockb project never gets the npm no-lockfile noise: {codes:?}" + ); + let detail = redirect_warning_detail(&env, "redirect_bun_lockb_manual_migration"); + assert!( + detail.contains("wrote no text bun.lock") + && detail.contains("`bun install --save-text-lockfile`") + && detail.contains("1.1.38"), + "the detail must explain the no-op and name the manual command: {detail}" + ); + assert_eq!( + std::fs::read(tmp.path().join("bun.lockb")).unwrap(), + LOCKB_BYTES, + "bun.lockb must be untouched" + ); + assert!( + !tmp.path().join("bun.lock").exists(), + "no text lock may appear" + ); + assert!( + !tmp.path() + .join(".socket/vendor/redirect-state.json") + .exists(), + "nothing was redirected: no ledger" + ); +} + +/// A failing bun (non-zero exit): `redirect_bun_lockb_unsupported` exactly +/// once, with bun's own stderr/stdout tail in the detail (it used to be +/// discarded), bun.lockb untouched, no bun.lock, no npm no-lockfile noise. +#[cfg(unix)] +#[tokio::test] +#[serial] +async fn failed_migration_detail_carries_bun_output_tail() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + write_bun_lockb_project(tmp.path(), LOCKB_BYTES); + let bin_dir = install_bun_shim( + tmp.path(), + "#!/bin/sh\n\ + echo \"bun install v1.1.38 (bb654321)\"\n\ + echo \"error: unknown option --save-text-lockfile\" >&2\n\ + exit 1\n", + ); + let (code, env, _stderr) = + scan_redirect_json_with_path(tmp.path(), &server.uri(), &path_with_first(&bin_dir)); + assert_eq!(code, Some(0), "{env:#}"); + assert_eq!(env["redirect"]["redirected"], 0, "{env:#}"); + let codes = warning_codes(&env); + assert_eq!( + codes + .iter() + .filter(|c| *c == "redirect_bun_lockb_unsupported") + .count(), + 1, + "exactly one unsupported warning (no duplicate from the core gate): {codes:?}" + ); + assert!( + !codes.contains(&"redirect_npm_no_lockfile".to_string()), + "{codes:?}" + ); + let detail = redirect_warning_detail(&env, "redirect_bun_lockb_unsupported"); + assert!( + detail.contains("error: unknown option --save-text-lockfile") + && detail.contains("bun install v1.1.38 (bb654321)") + && detail.contains("exit status: 1") + && detail.contains("cannot pin a binary lockfile"), + "the detail must carry bun's output tail and exit status: {detail}" + ); + assert_eq!( + std::fs::read(tmp.path().join("bun.lockb")).unwrap(), + LOCKB_BYTES + ); + assert!(!tmp.path().join("bun.lock").exists()); +} + +/// A bun.lockb above the ledger's byte cap (8 MiB raw) is migrated but +/// recorded WITHOUT `original`; `rollback` then emits +/// `redirect_bun_lockb_unrestorable` — and only because the file is actually +/// absent (bun ≥ 1.2 shape: the shim deletes it). +#[cfg(unix)] +#[tokio::test] +#[serial] +async fn oversize_bun_lockb_is_recorded_without_bytes_and_rollback_says_unrestorable() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + // One byte over `LOCKB_ORIGINAL_CAP` (hosted.rs): 8 MiB + 1. + let oversize = vec![0xABu8; 8 * 1024 * 1024 + 1]; + write_bun_lockb_project(tmp.path(), &oversize); + let bin_dir = install_bun_shim( + tmp.path(), + &format!( + "#!/bin/sh\n\ + cat > bun.lock <<'LOCK'\n{}LOCK\n\ + rm -f bun.lockb\n\ + exit 0\n", + canned_bun_lock() + ), + ); + let (code, env, _stderr) = + scan_redirect_json_with_path(tmp.path(), &server.uri(), &path_with_first(&bin_dir)); + assert_eq!(code, Some(0), "{env:#}"); + assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + let ledger = read_ledger(tmp.path()); + let migration = &ledger["edits"][0]; + assert_eq!( + migration["kind"], "redirect_bun_lockb_migrated", + "{ledger:#}" + ); + assert_eq!(migration["action"], "removed", "{ledger:#}"); + assert!( + migration.get("original").is_none(), + "an oversize lock is recorded without its bytes: {migration:#}" + ); + + let (code, env) = rollback_json(tmp.path()); + assert_eq!(code, Some(0), "{env:#}"); + let codes = rollback_warning_codes(&env); + assert!( + codes.contains(&"redirect_bun_lockb_unrestorable".to_string()), + "no bytes + file absent → the honest unrestorable warning: {codes:?}" + ); + assert!( + !codes.contains(&"redirect_bun_lockb_restored".to_string()), + "{codes:?}" + ); + assert!(!tmp.path().join("bun.lockb").exists()); + let lock = std::fs::read_to_string(tmp.path().join("bun.lock")).unwrap(); + assert!( + lock.contains(&format!("\"{NAME}@{VERSION}\"")) && !lock.contains(HOSTED_URL), + "the text lock is un-redirected and kept:\n{lock}" + ); +} + +/// The resolver ignores relative PATH entries: with `.` on PATH and an +/// executable `bun` PLANTED in the scanned project, the planted file must +/// never run (the child's cwd IS the project). With no absolute entry +/// holding a bun, the run degrades to `redirect_bun_lockb_unsupported` +/// "bun not found on PATH". +#[cfg(unix)] +#[tokio::test] +#[serial] +async fn relative_path_entry_never_runs_a_repo_planted_bun() { + use std::os::unix::fs::PermissionsExt; + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + write_bun_lockb_project(tmp.path(), LOCKB_BYTES); + // The planted attacker script: proves execution via a marker file and + // would otherwise "migrate" the lock. + let planted = tmp.path().join("bun"); + std::fs::write( + &planted, + format!( + "#!/bin/sh\n\ + echo PLANTED > planted.marker\n\ + cat > bun.lock <<'LOCK'\n{}LOCK\n\ + rm -f bun.lockb\n\ + exit 0\n", + canned_bun_lock() + ), + ) + .unwrap(); + std::fs::set_permissions(&planted, std::fs::Permissions::from_mode(0o755)).unwrap(); + // PATH = `.` + an EMPTY absolute dir: the only bun reachable is the + // planted one, and only through the relative entry. + let empty_bin = tmp.path().join("emptybin"); + std::fs::create_dir_all(&empty_bin).unwrap(); + let path = std::env::join_paths([std::path::PathBuf::from("."), empty_bin]).unwrap(); + + let (code, env, _stderr) = scan_redirect_json_with_path(tmp.path(), &server.uri(), &path); + assert_eq!(code, Some(0), "{env:#}"); + assert!( + !tmp.path().join("planted.marker").exists(), + "the repo-planted bun must NEVER execute" + ); + assert_eq!( + std::fs::read(tmp.path().join("bun.lockb")).unwrap(), + LOCKB_BYTES, + "bun.lockb untouched" + ); + assert!(!tmp.path().join("bun.lock").exists()); + let detail = redirect_warning_detail(&env, "redirect_bun_lockb_unsupported"); + assert!( + detail.contains("bun not found on PATH"), + "with no absolute-entry bun the run says so: {detail}" + ); +} + +// ───────────── Windows twins: `bun.cmd` batch shims (npm-global bun layout) ───────────── +// +// `npm i -g bun` on Windows leaves `bun`, `bun.cmd` and `bun.ps1` shims on +// PATH and NO bun.exe; Rust's `Command::new("bun")` appends only `.exe` and +// reports NotFound. The PATHEXT-aware resolver finds `bun.cmd` and spawns the +// resolved path directly — `std` (≥ 1.77.2) runs a `.cmd` through cmd.exe +// with an outer quote pair, so a shim path with spaces and parentheses works +// too. These twins can only run on a Windows host. + +/// Windows twin of `migration_that_keeps_bun_lockb_is_normalized_and_rollback_restores_it`. +/// The shim copies a canned text lock into place (no `echo` quoting games) +/// and keeps bun.lockb. +#[cfg(windows)] +#[tokio::test] +#[serial] +async fn migration_via_bun_cmd_shim_is_normalized_and_rollback_restores_it() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + write_bun_lockb_project(tmp.path(), LOCKB_BYTES); + let bin_dir = install_bun_cmd_shim( + tmp.path(), + &[ + // %~dp0 = the shim's own directory (trailing backslash). + "copy /Y \"%~dp0bun.lock.canned\" \"bun.lock\" >nul", + "echo Saved bun.lock (2 packages)", + "exit /b 0", + ], + ); + std::fs::write(bin_dir.join("bun.lock.canned"), canned_bun_lock()).unwrap(); + let scan = scan_redirect_json_with_path(tmp.path(), &server.uri(), &path_with_first(&bin_dir)); + assert_migration_round_trip(tmp.path(), scan); +} + +/// Windows twin of `exit_zero_without_bun_lock_reports_manual_migration`. +#[cfg(windows)] +#[tokio::test] +#[serial] +async fn exit_zero_without_bun_lock_reports_manual_migration_via_bun_cmd() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + write_bun_lockb_project(tmp.path(), LOCKB_BYTES); + let bin_dir = install_bun_cmd_shim( + tmp.path(), + &[ + "echo Checked 1 install across 2 packages (no changes)", + "exit /b 0", + ], + ); + let (code, env, stderr) = + scan_redirect_json_with_path(tmp.path(), &server.uri(), &path_with_first(&bin_dir)); + assert_eq!(code, Some(0), "{env:#}\n{stderr}"); + assert_eq!(env["redirect"]["redirected"], 0, "{env:#}"); + let codes = warning_codes(&env); + assert!( + codes.contains(&"redirect_bun_lockb_manual_migration".to_string()), + "{codes:?}" + ); + assert!( + !codes.contains(&"redirect_bun_lockb_unsupported".to_string()), + "{codes:?}" + ); + assert!( + !codes.contains(&"redirect_npm_no_lockfile".to_string()), + "{codes:?}" + ); + assert_eq!( + std::fs::read(tmp.path().join("bun.lockb")).unwrap(), + LOCKB_BYTES + ); + assert!(!tmp.path().join("bun.lock").exists()); +} + +/// Windows twin of `failed_migration_detail_carries_bun_output_tail`. +#[cfg(windows)] +#[tokio::test] +#[serial] +async fn failed_migration_via_bun_cmd_carries_output_tail() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + write_bun_lockb_project(tmp.path(), LOCKB_BYTES); + let bin_dir = install_bun_cmd_shim( + tmp.path(), + &[ + "echo error: unknown option --save-text-lockfile 1>&2", + "exit /b 1", + ], + ); + let (code, env, _stderr) = + scan_redirect_json_with_path(tmp.path(), &server.uri(), &path_with_first(&bin_dir)); + assert_eq!(code, Some(0), "{env:#}"); + let codes = warning_codes(&env); + assert_eq!( + codes + .iter() + .filter(|c| *c == "redirect_bun_lockb_unsupported") + .count(), + 1, + "{codes:?}" + ); + assert!( + !codes.contains(&"redirect_npm_no_lockfile".to_string()), + "{codes:?}" + ); + let detail = redirect_warning_detail(&env, "redirect_bun_lockb_unsupported"); + assert!( + detail.contains("error: unknown option --save-text-lockfile") + && detail.contains("cannot pin a binary lockfile"), + "{detail}" + ); + assert_eq!( + std::fs::read(tmp.path().join("bun.lockb")).unwrap(), + LOCKB_BYTES ); + assert!(!tmp.path().join("bun.lock").exists()); } /// The lockb migration must be UNDONE when the rewrite lands nothing in the diff --git a/crates/socket-patch-cli/tests/in_process_vendor_bun.rs b/crates/socket-patch-cli/tests/in_process_vendor_bun.rs new file mode 100644 index 00000000..550b042d --- /dev/null +++ b/crates/socket-patch-cli/tests/in_process_vendor_bun.rs @@ -0,0 +1,1650 @@ +//! Hermetic subprocess tests for the Bun VENDORED-mode refusals and their +//! positive twins: `scan --mode vendored` (manifest-tracked and +//! `--detached`), `get --mode vendored`, `get --mode +//! vendored`, their `--dry-run` previews, `--silent`, and the agent +//! `--save-only` exemption — driven through the built binary against a +//! wiremock patch API, on lockfiles written in the grammar REAL bun +//! releases emit (captured in the PR #245 compatibility matrix and from a +//! bun 1.1.45 `--save-text-lockfile` run): +//! +//! * lockfileVersion 1 (bun 1.2.x–1.3.x) / 2 (bun 1.4.x) workspace locks: +//! 1-tuple `"consumer": ["consumer@workspace:packages/consumer"]`, a +//! blank line between entries, trailing commas; +//! * lockfileVersion 0 (bun 1.1.39–1.1.45 `--save-text-lockfile`): no +//! `configVersion`, the root's workspace dep spelled as a bare path, and +//! the 2-tuple `["consumer@workspace:packages/consumer", { "dependencies": +//! { … } }]`; +//! * a `bun.lockb` with no text lock (bun ≤ 1.1.38, or an un-migrated repo); +//! * a malformed lock (`lockfileVersion` 3, non-canonical `"packages" : {` +//! header, unterminated entry) and — on Unix — a FIFO squatting +//! `bun.lock`. +//! +//! Every refusal test pins the whole observable contract: exit code; the +//! exact envelope shape (uuid path: `status:"error"` with `error{code, +//! message}` and a `failed` record carrying `errorCode` AND `error`; scan +//! and purl paths: `partial_failure` with the same record); ZERO +//! `/patches/view/` fetches for the refused patch (request-log oracle); a +//! byte-identical `bun.lock`; no `.socket/vendor/`; and — where a manifest +//! existed — a seeded record for another purl surviving semantically (serde +//! `Value` equality: the download phase re-serializes the manifest +//! pretty-printed by contract). +//! +//! No `#[serial]`: the child gets a scrubbed env copy (`common::run_with_env`). + +use std::path::Path; +#[cfg(unix)] +use std::process::{Command, Stdio}; +#[cfg(unix)] +use std::time::{Duration, Instant}; + +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +#[path = "common/mod.rs"] +mod common; + +const ORG: &str = "test-org"; +const UUID: &str = "11111111-1111-4111-8111-111111111111"; +const PURL: &str = "pkg:npm/left-pad@1.3.0"; +const ENCODED: &str = "pkg%3Anpm%2Fleft-pad%401.3.0"; +/// A second, unrelated manifest record (never installed here) used to prove +/// a pre-existing manifest survives a refused run. +const OTHER_UUID: &str = "22222222-2222-4222-8222-222222222222"; +const OTHER_PURL: &str = "pkg:npm/other-pkg@2.0.0"; +const BEFORE: &[u8] = b"before\n"; +const AFTER: &[u8] = b"after\n"; +/// The real registry integrity of left-pad@1.3.0 (spike BN3 fixture). +const LEFT_PAD_SHA512: &str = + "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="; +const WS_CODE: &str = "vendor_bun_workspace_unsupported"; +const LOCKB_CODE: &str = "vendor_bun_lockb_unsupported"; +const VERSION_CODE: &str = "vendor_lockfile_version_unsupported"; +const MISSING_CODE: &str = "vendor_lockfile_missing"; + +// --------------------------------------------------------------------------- +// Fixtures: real bun grammar +// --------------------------------------------------------------------------- + +/// The registry 4-tuple bun writes for left-pad@1.3.0 (identical across +/// lockfileVersion 0/1/2). +fn registry_line() -> String { + format!(" \"left-pad\": [\"left-pad@1.3.0\", \"\", {{}}, \"{LEFT_PAD_SHA512}\"],\n") +} + +/// bun 1.3.14 (lockfileVersion 1) / bun 1.4.2 (lockfileVersion 2) workspace +/// lock, byte-for-byte the matrix capture grammar (the two versions differ +/// only in the version integer). +const WS_V1V2_TEMPLATE: &str = r#"{ + "lockfileVersion": {VERSION}, + "configVersion": 1, + "workspaces": { + "": { + "name": "bun-fixture", + "dependencies": { + "consumer": "workspace:*", + }, + }, + "packages/consumer": { + "name": "consumer", + "version": "1.0.0", + "dependencies": { + "left-pad": "1.3.0", + }, + }, + }, + "packages": { + "consumer": ["consumer@workspace:packages/consumer"], + +{REGISTRY} } +} +"#; + +/// bun 1.1.45 `--save-text-lockfile` workspace lock: lockfileVersion 0, no +/// `configVersion`, root workspace dep as a bare path, 2-tuple workspace +/// entry carrying the member's deps object. +const WS_V0_TEMPLATE: &str = r#"{ + "lockfileVersion": 0, + "workspaces": { + "": { + "name": "bun-fixture", + "dependencies": { + "consumer": "packages/consumer", + }, + }, + "packages/consumer": { + "name": "consumer", + "version": "1.0.0", + "dependencies": { + "left-pad": "1.3.0", + }, + }, + }, + "packages": { + "consumer": ["consumer@workspace:packages/consumer", { "dependencies": { "left-pad": "1.3.0" } }], + +{REGISTRY} } +} +"#; + +/// bun 1.1.45 `--save-text-lockfile` single-package lock (matrix +/// `1.1.45-text-*` captures): lockfileVersion 0, no `configVersion`. +const DIRECT_V0_TEMPLATE: &str = r#"{ + "lockfileVersion": 0, + "workspaces": { + "": { + "name": "bun-fixture", + "dependencies": { + "left-pad": "1.3.0", + }, + }, + }, + "packages": { +{REGISTRY} } +} +"#; + +/// bun 1.3.x single-package lock (spike BN3 grammar). +const DIRECT_V1_TEMPLATE: &str = r#"{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "bun-fixture", + "dependencies": { + "left-pad": "1.3.0", + }, + }, + }, + "packages": { +{REGISTRY} } +} +"#; + +/// A future/hand-edited lock the version gate refuses: lockfileVersion 3, +/// a non-canonical `"packages" : {` header and an unterminated entry. +const MALFORMED_V3_LOCK: &str = "{\n \"lockfileVersion\": 3,\n \"packages\" : {\n \"left-pad\": [\"left-pad@1.3.0\", \"\",\n }\n}\n"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum LockShape { + V0Workspace, + V1Workspace, + V2Workspace, + V0Direct, + V1Direct, + LockbOnly, + MalformedV3, +} + +impl LockShape { + fn is_workspace(self) -> bool { + matches!( + self, + LockShape::V0Workspace | LockShape::V1Workspace | LockShape::V2Workspace + ) + } + + /// The text lock for this shape (`None` = binary `bun.lockb` only). + fn lock_text(self) -> Option { + let registry = registry_line(); + let text = match self { + LockShape::V1Workspace => WS_V1V2_TEMPLATE.replace("{VERSION}", "1"), + LockShape::V2Workspace => WS_V1V2_TEMPLATE.replace("{VERSION}", "2"), + LockShape::V0Workspace => WS_V0_TEMPLATE.to_string(), + LockShape::V0Direct => DIRECT_V0_TEMPLATE.to_string(), + LockShape::V1Direct => DIRECT_V1_TEMPLATE.to_string(), + LockShape::MalformedV3 => return Some(MALFORMED_V3_LOCK.to_string()), + LockShape::LockbOnly => return None, + }; + Some(text.replace("{REGISTRY}", ®istry)) + } +} + +/// A bun project with left-pad@1.3.0 INSTALLED (hoisted `node_modules/`, +/// which every bun release through 1.2.x lays out and the crawler +/// resolves) and lock-resolved in the requested grammar. Workspace shapes +/// declare left-pad from `packages/consumer` (the member-declared case the +/// vendored gate exists for). +fn write_bun_project(root: &Path, shape: LockShape) { + let root_pkg = if shape.is_workspace() { + r#"{"name":"bun-fixture","version":"1.0.0","private":true,"workspaces":["packages/*"],"dependencies":{"consumer":"workspace:*"}}"# + } else { + r#"{"name":"bun-fixture","version":"1.0.0","private":true,"dependencies":{"left-pad":"1.3.0"}}"# + }; + std::fs::write(root.join("package.json"), root_pkg).unwrap(); + if shape.is_workspace() { + let consumer = root.join("packages/consumer"); + std::fs::create_dir_all(&consumer).unwrap(); + std::fs::write( + consumer.join("package.json"), + r#"{"name":"consumer","version":"1.0.0","dependencies":{"left-pad":"1.3.0"}}"#, + ) + .unwrap(); + } + write_installed_left_pad(root); + match shape.lock_text() { + Some(text) => std::fs::write(root.join("bun.lock"), text).unwrap(), + None => std::fs::write(root.join("bun.lockb"), b"\x00bun-lockb\x00").unwrap(), + } +} + +fn write_installed_left_pad(dir: &Path) { + let pkg = dir.join("node_modules/left-pad"); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + br#"{"name":"left-pad","version":"1.3.0"}"#, + ) + .unwrap(); + std::fs::write(pkg.join("index.js"), BEFORE).unwrap(); +} + +/// A schema-valid manifest holding ONE record for [`OTHER_PURL`], written +/// COMPACT (single line) so a byte-level re-serialization is detectable +/// while the semantic oracle (`Value` equality) still passes. Returns the +/// seeded record. +fn seed_other_manifest_record(root: &Path) -> serde_json::Value { + let socket = root.join(".socket"); + std::fs::create_dir_all(&socket).unwrap(); + let record = serde_json::json!({ + "uuid": OTHER_UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": common::git_sha256(BEFORE), + "afterHash": common::git_sha256(AFTER), + } + }, + "vulnerabilities": {}, + "description": "seeded record", + "license": "MIT", + "tier": "free", + }); + let manifest = serde_json::json!({ "patches": { OTHER_PURL: record } }); + std::fs::write( + socket.join("manifest.json"), + serde_json::to_string(&manifest).unwrap(), + ) + .unwrap(); + record +} + +// --------------------------------------------------------------------------- +// Mock API +// --------------------------------------------------------------------------- + +fn view_body(uuid: &str, purl: &str) -> serde_json::Value { + use base64::Engine as _; + let blob_content = base64::engine::general_purpose::STANDARD.encode(AFTER); + serde_json::json!({ + "uuid": uuid, + "purl": purl, + "publishedAt": "2026-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": common::git_sha256(BEFORE), + "afterHash": common::git_sha256(AFTER), + "blobContent": blob_content, + } + }, + "vulnerabilities": { + "GHSA-aaaa-bbbb-cccc": { + "cves": ["CVE-2026-0001"], + "summary": "bun fixture", + "severity": "high", + "description": "d" + } + }, + "description": "bun fixture", + "license": "MIT", + "tier": "free", + }) +} + +/// Discovery (batch), per-package search and the full view for [`UUID`] / +/// [`PURL`] — the same recipe as `scan_vendor_e2e.rs`. +async fn mount_patch_api(mock: &MockServer) { + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [{ + "purl": PURL, + "patches": [{ + "uuid": UUID, + "purl": PURL, + "tier": "free", + "cveIds": ["CVE-2026-0001"], + "ghsaIds": [], + "severity": "high", + "title": "bun fixture" + }] + }], + "canAccessPaidPatches": false, + }))) + .mount(mock) + .await; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/by-package/{ENCODED}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": [{ + "uuid": UUID, + "purl": PURL, + "publishedAt": "2026-01-01T00:00:00Z", + "description": "bun fixture", + "license": "MIT", + "tier": "free", + "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(mock) + .await; + mount_view(mock, UUID, PURL).await; +} + +async fn mount_view(mock: &MockServer, uuid: &str, purl: &str) { + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{uuid}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(view_body(uuid, purl))) + .mount(mock) + .await; +} + +/// `/patches/view/{uuid}` fetches the mock saw. +async fn view_requests_for(mock: &MockServer, uuid: &str) -> usize { + mock.received_requests() + .await + .unwrap_or_default() + .iter() + .filter(|r| r.url.path().ends_with(&format!("/patches/view/{uuid}"))) + .count() +} + +// --------------------------------------------------------------------------- +// Runners +// --------------------------------------------------------------------------- + +fn run(root: &Path, argv: &[&str]) -> (i32, String, String) { + common::run_with_env(root, argv, &[("SOCKET_TELEMETRY_DISABLED", "1")]) +} + +fn with_api<'a>(argv: &[&'a str], uri: &'a str) -> Vec<&'a str> { + let mut v = argv.to_vec(); + v.extend_from_slice(&[ + "--api-url", + uri, + "--api-token", + "fake-token", + "--org", + ORG, + "--yes", + ]); + v +} + +fn scan_vendored(root: &Path, uri: &str, extra: &[&str]) -> (i32, String, String) { + let mut argv = vec!["scan", "--mode", "vendored", "--vendor-source", "build"]; + argv.extend_from_slice(extra); + run(root, &with_api(&argv, uri)) +} + +fn get_vendored(root: &Path, uri: &str, ident: &str, extra: &[&str]) -> (i32, String, String) { + let mut argv = vec![ + "get", + ident, + "--mode", + "vendored", + "--vendor-source", + "build", + ]; + argv.extend_from_slice(extra); + run(root, &with_api(&argv, uri)) +} + +/// Parse stdout as exactly ONE JSON document (`from_str` rejects trailing +/// data, so a second envelope or a stray human line fails loudly). +fn parse_single_json_doc(stdout: &str) -> serde_json::Value { + let trimmed = stdout.trim(); + assert!(!trimmed.is_empty(), "expected a JSON envelope on stdout"); + serde_json::from_str(trimmed).unwrap_or_else(|e| { + panic!("stdout must be exactly one JSON document: {e}\nstdout:\n{stdout}") + }) +} + +fn lock_bytes(root: &Path) -> Vec { + std::fs::read(root.join("bun.lock")).unwrap() +} + +fn manifest_value(root: &Path) -> Option { + let body = std::fs::read_to_string(root.join(".socket/manifest.json")).ok()?; + Some(serde_json::from_str(&body).unwrap_or_else(|e| panic!("manifest not JSON: {e}\n{body}"))) +} + +/// The refused `failed` record every entry point must emit for [`PURL`]. +fn assert_refused_record(record: &serde_json::Value, code: &str, ctx: &serde_json::Value) { + assert_eq!(record["purl"], PURL, "{ctx}"); + assert_eq!(record["uuid"], UUID, "{ctx}"); + assert_eq!(record["action"], "failed", "{ctx}"); + assert_eq!(record["errorCode"], code, "{ctx}"); + assert!( + record["error"].as_str().is_some_and(|d| !d.is_empty()), + "a refused record must carry the engine's detail text: {ctx}" + ); +} + +/// The on-disk invariants of EVERY refusal: lock bytes untouched, nothing +/// vendored, no record for [`PURL`] in the manifest (if one exists). +fn assert_refusal_left_tree_alone(root: &Path, lock_before: &[u8]) { + if root.join("bun.lock").exists() { + assert_eq!( + lock_bytes(root), + lock_before, + "bun.lock must be byte-identical" + ); + } + assert!( + !root.join(".socket/vendor").exists(), + "a refused run must not create .socket/vendor/" + ); + if let Some(m) = manifest_value(root) { + assert!( + m["patches"].get(PURL).is_none(), + "the refused purl must not be recorded: {m}" + ); + } +} + +// --------------------------------------------------------------------------- +// scan --mode vendored: the download-phase refusal, per lock shape +// --------------------------------------------------------------------------- + +/// Drive `scan --mode vendored --json` on `shape` and pin the refusal +/// contract for `code`: exit 1, `partial_failure`, the download-phase +/// record, zero downloads, zero view fetches, engine untouched. +async fn assert_scan_refuses(shape: LockShape, code: &str) { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_bun_project(tmp.path(), shape); + let lock_before = if tmp.path().join("bun.lock").exists() { + lock_bytes(tmp.path()) + } else { + Vec::new() + }; + + let (exit, stdout, stderr) = scan_vendored(tmp.path(), &mock.uri(), &["--json"]); + assert_eq!(exit, 1, "{shape:?}: stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + assert_eq!(v["status"], "partial_failure", "{shape:?}: {v}"); + let dl = &v["download"]; + assert_eq!(dl["found"], 1, "{v}"); + assert_eq!(dl["downloaded"], 0, "{v}"); + assert_eq!(dl["skipped"], 0, "{v}"); + assert_eq!(dl["failed"], 1, "{v}"); + assert_refused_record(&dl["patches"][0], code, &v); + assert_eq!( + v["vendor"]["summary"]["applied"], 0, + "nothing may be vendored: {v}" + ); + assert_eq!( + view_requests_for(&mock, UUID).await, + 0, + "{shape:?}: a refused patch must never be fetched" + ); + assert_refusal_left_tree_alone(tmp.path(), &lock_before); + // Today's documented contract (CLI_CONTRACT.md `scan --vendor`: "the + // download phase writes only `.socket/manifest.json`"): the manifest + // exists and is EMPTY — no record was claimed for the refused purl. + assert_eq!( + manifest_value(tmp.path()), + Some(serde_json::json!({ "patches": {} })), + "{shape:?}" + ); +} + +#[tokio::test] +async fn scan_vendored_refuses_v1_workspace_lock_before_download() { + assert_scan_refuses(LockShape::V1Workspace, WS_CODE).await; +} + +#[tokio::test] +async fn scan_vendored_refuses_v0_workspace_two_tuple_lock_before_download() { + assert_scan_refuses(LockShape::V0Workspace, WS_CODE).await; +} + +#[tokio::test] +async fn scan_vendored_refuses_bun_lockb_only_before_download() { + assert_scan_refuses(LockShape::LockbOnly, LOCKB_CODE).await; +} + +#[tokio::test] +async fn scan_vendored_refuses_malformed_v3_lock_before_download() { + assert_scan_refuses(LockShape::MalformedV3, VERSION_CODE).await; +} + +/// A refused scan on a project that ALREADY tracks another patch: that +/// record survives semantically (the download phase re-serializes the +/// manifest pretty-printed — documented, so the oracle is `Value` +/// equality, not bytes), and the refused purl is still not recorded. +#[tokio::test] +async fn scan_vendored_refusal_preserves_seeded_manifest_record() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + // The vendor step stages every manifest record's content in memory + // from the view endpoint, so the seeded record needs a view too. + mount_view(&mock, OTHER_UUID, OTHER_PURL).await; + let tmp = tempfile::tempdir().unwrap(); + write_bun_project(tmp.path(), LockShape::V1Workspace); + let seeded = seed_other_manifest_record(tmp.path()); + let lock_before = lock_bytes(tmp.path()); + + let (exit, stdout, stderr) = scan_vendored(tmp.path(), &mock.uri(), &["--json"]); + assert_eq!(exit, 1, "stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + assert_refused_record(&v["download"]["patches"][0], WS_CODE, &v); + assert_eq!(view_requests_for(&mock, UUID).await, 0); + assert_refusal_left_tree_alone(tmp.path(), &lock_before); + + let manifest = manifest_value(tmp.path()).expect("manifest survives"); + let patches = manifest["patches"].as_object().unwrap(); + assert_eq!( + patches.keys().collect::>(), + vec![OTHER_PURL], + "exactly the seeded record remains: {manifest}" + ); + assert_eq!( + patches[OTHER_PURL], seeded, + "the seeded record must survive field for field: {manifest}" + ); +} + +// --------------------------------------------------------------------------- +// scan --mode vendored --detached: the same refusal, BEFORE any fetch +// --------------------------------------------------------------------------- + +/// The detached download phase used to skip the preflight: the patch view +/// was fetched (`download.downloaded: 1`) and the refusal only surfaced +/// from the vendor engine afterwards (degrading to `package_not_installed` +/// for alias installs). Now it refuses exactly like the manifest-tracked +/// phase — pre-fetch, with the vendor code — and, being detached, writes +/// no manifest at all. +#[tokio::test] +async fn scan_vendored_detached_refuses_v1_workspace_before_fetch() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_bun_project(tmp.path(), LockShape::V1Workspace); + let lock_before = lock_bytes(tmp.path()); + + let (exit, stdout, stderr) = scan_vendored(tmp.path(), &mock.uri(), &["--detached", "--json"]); + assert_eq!(exit, 1, "stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + assert_eq!(v["status"], "partial_failure", "{v}"); + let dl = &v["download"]; + assert_eq!(dl["detached"], true, "{v}"); + assert_eq!( + dl["downloaded"], 0, + "detached must refuse BEFORE fetching: {v}" + ); + assert_eq!(dl["failed"], 1, "{v}"); + assert_refused_record(&dl["patches"][0], WS_CODE, &v); + assert_eq!(view_requests_for(&mock, UUID).await, 0); + assert!( + !tmp.path().join(".socket/manifest.json").exists(), + "detached mode never writes a manifest" + ); + assert_refusal_left_tree_alone(tmp.path(), &lock_before); +} + +/// The lockb twin of the detached refusal: the shape that used to +/// misreport `package_not_installed` after a needless fetch. +#[tokio::test] +async fn scan_vendored_detached_refuses_bun_lockb_before_fetch() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_bun_project(tmp.path(), LockShape::LockbOnly); + + let (exit, stdout, stderr) = scan_vendored(tmp.path(), &mock.uri(), &["--detached", "--json"]); + assert_eq!(exit, 1, "stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + assert_eq!(v["download"]["downloaded"], 0, "{v}"); + assert_refused_record(&v["download"]["patches"][0], LOCKB_CODE, &v); + assert!( + !v["vendor"]["events"] + .as_array() + .unwrap_or(&vec![]) + .iter() + .any(|e| e["errorCode"] == "package_not_installed"), + "the refusal must not degrade to package_not_installed: {v}" + ); + assert_eq!(view_requests_for(&mock, UUID).await, 0); + assert!(!tmp.path().join(".socket/manifest.json").exists()); + assert!(!tmp.path().join(".socket/vendor").exists()); +} + +// --------------------------------------------------------------------------- +// get --mode vendored: the pre-record refusal envelope +// --------------------------------------------------------------------------- + +/// `get --mode vendored --json` on a refused Bun project exits 1 +/// with EXACTLY this envelope (contract: uuid-path pre-record refusal): +/// `status:"error"`, `error{code,message}`, counts, and a `failed` record +/// carrying both `errorCode` and `error`. The uuid lookup itself is the +/// only network traffic (one view fetch — that IS the identifier +/// resolution), and NOTHING is written: no `.socket/` at all. +#[tokio::test] +async fn get_uuid_vendored_refusal_envelope_is_exact_and_writes_nothing() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_bun_project(tmp.path(), LockShape::V1Workspace); + let lock_before = lock_bytes(tmp.path()); + + let (exit, stdout, stderr) = get_vendored(tmp.path(), &mock.uri(), UUID, &["--json"]); + assert_eq!(exit, 1, "stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + let detail = v["error"]["message"] + .as_str() + .unwrap_or_else(|| panic!("error.message must be a string: {v}")) + .to_string(); + assert!(!detail.is_empty(), "{v}"); + let expected = serde_json::json!({ + "status": "error", + "found": 1, + "downloaded": 0, + "skipped": 0, + "failed": 1, + "error": { "code": WS_CODE, "message": detail }, + "patches": [{ + "purl": PURL, + "uuid": UUID, + "action": "failed", + "errorCode": WS_CODE, + "error": detail, + }], + }); + assert_eq!( + v, + expected, + "uuid-path refusal envelope drifted.\nexpected:\n{}\ngot:\n{}", + serde_json::to_string_pretty(&expected).unwrap(), + serde_json::to_string_pretty(&v).unwrap(), + ); + assert_eq!( + view_requests_for(&mock, UUID).await, + 1, + "the uuid lookup is the only fetch" + ); + assert!( + !tmp.path().join(".socket").exists(), + "the uuid path refuses before creating .socket/" + ); + assert_eq!(lock_bytes(tmp.path()), lock_before); +} + +/// Human mode prints the code-tagged `Error (…)` line to stderr, nothing +/// on stdout, exit 1 — and writes nothing. +#[tokio::test] +async fn get_uuid_vendored_refusal_human_names_code_on_stderr() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_bun_project(tmp.path(), LockShape::LockbOnly); + + let (exit, stdout, stderr) = get_vendored(tmp.path(), &mock.uri(), UUID, &[]); + assert_eq!(exit, 1, "stdout={stdout}\nstderr={stderr}"); + assert!( + stderr.contains(&format!("Error ({LOCKB_CODE}):")), + "stderr must carry the code-tagged error line:\n{stderr}" + ); + assert!( + !stdout.contains(LOCKB_CODE) && !stdout.contains("Patch record saved"), + "the refusal must not be reported as a save on stdout:\n{stdout}" + ); + assert!(!tmp.path().join(".socket").exists()); +} + +// --------------------------------------------------------------------------- +// get --mode vendored: the search-path refusal +// --------------------------------------------------------------------------- + +/// The search path shares `scan`'s download phase: `partial_failure`, the +/// same `failed` record (with `errorCode` + `error`), zero fetches, the +/// vendor step still runs over the (empty) manifest, `applied` dropped. +#[tokio::test] +async fn get_purl_vendored_refuses_v1_workspace_before_fetch() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_bun_project(tmp.path(), LockShape::V1Workspace); + let lock_before = lock_bytes(tmp.path()); + + let (exit, stdout, stderr) = get_vendored(tmp.path(), &mock.uri(), PURL, &["--json"]); + assert_eq!(exit, 1, "stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + assert_eq!(v["status"], "partial_failure", "{v}"); + assert_eq!(v["found"], 1, "{v}"); + assert_eq!(v["downloaded"], 0, "{v}"); + assert_eq!(v["skipped"], 0, "{v}"); + assert_eq!(v["failed"], 1, "{v}"); + assert!( + v.get("applied").is_none(), + "vendored mode drops `applied`: {v}" + ); + assert_refused_record(&v["patches"][0], WS_CODE, &v); + assert_eq!(v["vendor"]["summary"]["applied"], 0, "{v}"); + assert_eq!(view_requests_for(&mock, UUID).await, 0); + assert_refusal_left_tree_alone(tmp.path(), &lock_before); + assert_eq!( + manifest_value(tmp.path()), + Some(serde_json::json!({ "patches": {} })), + "the search path shares scan's manifest-writing download phase" + ); +} + +// --------------------------------------------------------------------------- +// --silent: errors only, so the refusal stays visible (code-tagged) +// --------------------------------------------------------------------------- + +/// `--silent` mutes informational chatter, never errors: each refusing +/// entry point exits 1 with an EMPTY stdout and the stable code (plus the +/// purl on the per-patch paths) on stderr. Regression guard: the refusal +/// lines were gated on `!silent` and a `--silent` run exited 1 mutely. +#[tokio::test] +async fn silent_refusals_stay_visible_on_stderr_with_empty_stdout() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + + for (label, argv) in [ + ( + "get ", + vec!["get", PURL, "--mode", "vendored", "--silent"], + ), + ( + "get ", + vec!["get", UUID, "--mode", "vendored", "--silent"], + ), + ("scan", vec!["scan", "--mode", "vendored", "--silent"]), + ] { + let tmp = tempfile::tempdir().unwrap(); + write_bun_project(tmp.path(), LockShape::V1Workspace); + let mut argv = argv; + argv.extend_from_slice(&["--vendor-source", "build"]); + let (exit, stdout, stderr) = run(tmp.path(), &with_api(&argv, &mock.uri())); + assert_eq!(exit, 1, "{label}: stdout={stdout}\nstderr={stderr}"); + assert!( + stdout.trim().is_empty(), + "{label}: --silent must print nothing to stdout:\n{stdout}" + ); + assert!( + stderr.contains(WS_CODE), + "{label}: --silent must still name the refusal code on stderr:\n{stderr}" + ); + if label != "get " { + assert!( + stderr.contains(PURL), + "{label}: the per-patch error line must name the purl:\n{stderr}" + ); + } + } +} + +// --------------------------------------------------------------------------- +// --dry-run: the preview names the refusal (additive `would_refuse`) +// --------------------------------------------------------------------------- + +/// The vendored dry-run preview is a ledger classification by contract +/// (exit 0, `status:"success"`, nothing written); on a Bun project the +/// wet run is known to refuse, its npm records become the additive +/// `would_refuse` (+`errorCode`/`error`) instead of advertising +/// `would_vendor`. All three entry points; nothing touched on disk. +#[tokio::test] +async fn dry_run_previews_report_would_refuse_on_refused_bun_project() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + + for (label, argv) in [ + ( + "scan", + vec!["scan", "--mode", "vendored", "--dry-run", "--json"], + ), + ( + "scan --detached", + vec![ + "scan", + "--mode", + "vendored", + "--detached", + "--dry-run", + "--json", + ], + ), + ( + "get ", + vec!["get", UUID, "--mode", "vendored", "--dry-run", "--json"], + ), + ( + "get ", + vec!["get", PURL, "--mode", "vendored", "--dry-run", "--json"], + ), + ] { + let tmp = tempfile::tempdir().unwrap(); + write_bun_project(tmp.path(), LockShape::V1Workspace); + let lock_before = lock_bytes(tmp.path()); + let (exit, stdout, stderr) = run(tmp.path(), &with_api(&argv, &mock.uri())); + assert_eq!( + exit, 0, + "{label}: a preview never flips the exit: {stdout}\n{stderr}" + ); + let v = parse_single_json_doc(&stdout); + assert_eq!(v["status"], "success", "{label}: {v}"); + let preview = &v["vendor"]; + assert_eq!(preview["dryRun"], true, "{label}: {v}"); + let rec = &preview["patches"][0]; + assert_eq!(rec["purl"], PURL, "{label}: {v}"); + assert_eq!(rec["uuid"], UUID, "{label}: {v}"); + assert_eq!(rec["action"], "would_refuse", "{label}: {v}"); + assert_eq!(rec["errorCode"], WS_CODE, "{label}: {v}"); + assert!( + rec["error"].as_str().is_some_and(|d| !d.is_empty()), + "{label}: {v}" + ); + assert!( + !tmp.path().join(".socket").exists(), + "{label}: a dry run writes nothing" + ); + assert_eq!(lock_bytes(tmp.path()), lock_before, "{label}"); + } +} + +/// The human dry-run keeps its count line and additionally names what the +/// wet run would refuse — under `--silent` it prints nothing at all. +#[tokio::test] +async fn dry_run_human_names_would_refuse_records() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_bun_project(tmp.path(), LockShape::LockbOnly); + + let (exit, stdout, stderr) = get_vendored(tmp.path(), &mock.uri(), PURL, &["--dry-run"]); + assert_eq!(exit, 0, "stdout={stdout}\nstderr={stderr}"); + assert!( + stdout.contains("[dry-run] Would download and vendor 1 patch(es)."), + "{stdout}" + ); + assert!( + stdout.contains(&format!("[would-refuse] {PURL} ({LOCKB_CODE}):")), + "the human preview must name the refusal:\n{stdout}" + ); + assert!(!tmp.path().join(".socket").exists()); + + let (exit, stdout, _) = get_vendored(tmp.path(), &mock.uri(), PURL, &["--dry-run", "--silent"]); + assert_eq!(exit, 0); + assert!( + stdout.trim().is_empty(), + "silent dry run prints nothing:\n{stdout}" + ); +} + +// --------------------------------------------------------------------------- +// Agent --save-only: record-only intent is NOT preflighted +// --------------------------------------------------------------------------- + +/// The preflight is scoped to the vendored posture; an agent-mode +/// `get --save-only` on the same refused workspace project records the +/// patch and persists its blob exactly as on any other project (the +/// fresh-clone record→vendor workflow must keep working). +#[tokio::test] +async fn get_save_only_agent_bypasses_bun_preflight() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_bun_project(tmp.path(), LockShape::V1Workspace); + + let argv = vec!["get", PURL, "--save-only", "--json"]; + let (exit, stdout, stderr) = run(tmp.path(), &with_api(&argv, &mock.uri())); + assert_eq!(exit, 0, "stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + assert_eq!(v["status"], "success", "{v}"); + assert_eq!(v["patches"][0]["action"], "added", "{v}"); + assert!(v["patches"][0].get("errorCode").is_none(), "{v}"); + assert_eq!(view_requests_for(&mock, UUID).await, 1); + let manifest = manifest_value(tmp.path()).expect("manifest written"); + assert_eq!(manifest["patches"][PURL]["uuid"], UUID, "{manifest}"); + assert!( + tmp.path() + .join(".socket/blobs") + .join(common::git_sha256(AFTER)) + .is_file(), + "the agent download persists the after-blob" + ); +} + +// --------------------------------------------------------------------------- +// Positive controls: supported Bun shapes still vendor (and revert) +// --------------------------------------------------------------------------- + +/// A lockfileVersion-2 workspace lock (bun ≥ 1.4) is the supported +/// workspace shape: `scan --mode vendored` vendors left-pad — the registry +/// 4-tuple becomes the local-tarball 3-tuple, the workspace entry survives +/// byte-identically, the ledger records the bun flavor, the artifact is +/// committed — and the run exits 0. +#[tokio::test] +async fn scan_vendored_v2_workspace_lock_vendors() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_bun_project(tmp.path(), LockShape::V2Workspace); + + let (exit, stdout, stderr) = scan_vendored(tmp.path(), &mock.uri(), &["--json"]); + assert_eq!(exit, 0, "stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + assert_eq!(v["status"], "success", "{v}"); + assert_eq!(v["download"]["downloaded"], 1, "{v}"); + assert_eq!(v["download"]["patches"][0]["action"], "added", "{v}"); + assert_eq!(v["vendor"]["summary"]["applied"], 1, "{v}"); + + let lock = String::from_utf8(lock_bytes(tmp.path())).unwrap(); + let tgz_rel = format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz"); + assert!( + lock.contains(&format!( + " \"left-pad\": [\"left-pad@{tgz_rel}\", {{}}, \"sha512-" + )), + "the registry tuple must become the local-tarball 3-tuple:\n{lock}" + ); + assert!( + lock.contains(" \"consumer\": [\"consumer@workspace:packages/consumer\"],\n"), + "the workspace entry must survive untouched:\n{lock}" + ); + assert!(lock.starts_with("{\n \"lockfileVersion\": 2,\n"), "{lock}"); + assert!(tmp.path().join(&tgz_rel).is_file(), "missing {tgz_rel}"); + let state: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(tmp.path().join(".socket/vendor/state.json")).unwrap(), + ) + .unwrap(); + assert_eq!(state["entries"][PURL]["uuid"], UUID, "{state}"); + assert_eq!(state["entries"][PURL]["flavor"], "bun", "{state}"); +} + +/// A lockfileVersion-0 single-package lock (bun 1.1.39–1.1.45 opt-in text +/// lock) is supported: `get --mode vendored --vendor-source build` +/// vendors it, and `rollback` restores the lock byte-for-byte and removes +/// the vendored tree. +#[tokio::test] +async fn get_uuid_vendored_v0_direct_lock_vendors_and_rollback_restores_bytes() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_bun_project(tmp.path(), LockShape::V0Direct); + let lock_before = lock_bytes(tmp.path()); + + let (exit, stdout, stderr) = get_vendored(tmp.path(), &mock.uri(), UUID, &["--json"]); + assert_eq!(exit, 0, "stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + assert_eq!(v["status"], "success", "{v}"); + assert_eq!(v["patches"][0]["action"], "added", "{v}"); + assert_eq!(v["vendor"]["summary"]["applied"], 1, "{v}"); + let tgz_rel = format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz"); + let lock = String::from_utf8(lock_bytes(tmp.path())).unwrap(); + assert!(lock.contains(&format!("\"left-pad@{tgz_rel}\"")), "{lock}"); + assert!(lock.starts_with("{\n \"lockfileVersion\": 0,\n"), "{lock}"); + assert!(tmp.path().join(&tgz_rel).is_file()); + + let (exit, stdout, stderr) = run(tmp.path(), &with_api(&["rollback", "--json"], &mock.uri())); + assert_eq!(exit, 0, "rollback: stdout={stdout}\nstderr={stderr}"); + assert_eq!( + lock_bytes(tmp.path()), + lock_before, + "rollback must restore the v0 lock byte-for-byte" + ); + assert!( + !tmp.path().join(".socket/vendor/npm").exists(), + "rollback removes the vendored artifact tree" + ); +} + +// --------------------------------------------------------------------------- +// Already-vendored exemption (upgrade path of pre-gate projects) +// --------------------------------------------------------------------------- + +/// Vendor a supported v1 single-package project, then turn it into a +/// workspace the way a real repo evolves (add `packages/consumer`, the +/// `workspaces` field and — as `bun install` would — the 1-tuple workspace +/// entry, keeping the vendored tuple intact and the version at 1). +fn vendor_then_add_workspace(root: &Path, mock_uri: &str) -> Vec { + write_bun_project(root, LockShape::V1Direct); + let (exit, stdout, stderr) = scan_vendored(root, mock_uri, &["--json"]); + assert_eq!(exit, 0, "setup vendoring: stdout={stdout}\nstderr={stderr}"); + let lock = String::from_utf8(lock_bytes(root)).unwrap(); + assert!(lock.contains(".socket/vendor/npm/"), "setup: {lock}"); + let with_workspace = lock.replace( + " \"packages\": {\n", + " \"packages\": {\n \"consumer\": [\"consumer@workspace:packages/consumer\"],\n\n", + ); + assert_ne!(with_workspace, lock, "the workspace splice must land"); + std::fs::write(root.join("bun.lock"), &with_workspace).unwrap(); + let consumer = root.join("packages/consumer"); + std::fs::create_dir_all(&consumer).unwrap(); + std::fs::write( + consumer.join("package.json"), + r#"{"name":"consumer","version":"1.0.0","dependencies":{"other-pkg":"2.0.0"}}"#, + ) + .unwrap(); + std::fs::write( + root.join("package.json"), + r#"{"name":"bun-fixture","version":"1.0.0","private":true,"workspaces":["packages/*"],"dependencies":{"left-pad":"1.3.0","consumer":"workspace:*"}}"#, + ) + .unwrap(); + with_workspace.into_bytes() +} + +#[tokio::test] +async fn preserved_ledger_does_not_bypass_bun_refusal_after_rollback() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_bun_project(root, LockShape::V1Direct); + let (exit, stdout, stderr) = scan_vendored(root, &mock.uri(), &["--json", "--detached"]); + assert_eq!(exit, 0, "{stdout}\n{stderr}"); + assert!(!root.join(".socket/manifest.json").exists()); + let (exit, stdout, stderr) = run(root, &["rollback", "--preserve-state", "--yes", "--json"]); + assert_eq!(exit, 0, "{stdout}\n{stderr}"); + let registry = String::from_utf8(lock_bytes(root)).unwrap(); + assert!(!registry.contains(".socket/vendor/npm/")); + let lock = registry.replace( + " \"packages\": {\n", + " \"packages\": {\n \"consumer\": [\"consumer@workspace:packages/consumer\"],\n\n", + ); + std::fs::write(root.join("bun.lock"), &lock).unwrap(); + let state = std::fs::read(root.join(".socket/vendor/state.json")).unwrap(); + let artifact_path = root.join(format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz")); + let artifact = std::fs::read(&artifact_path).unwrap(); + + let (exit, stdout, stderr) = scan_vendored(root, &mock.uri(), &["--json", "--dry-run"]); + assert_eq!(exit, 0, "{stdout}\n{stderr}"); + let preview = parse_single_json_doc(&stdout); + assert_eq!( + preview["vendor"]["patches"][0]["action"], "would_refuse", + "{preview}" + ); + assert_eq!( + preview["vendor"]["patches"][0]["errorCode"], WS_CODE, + "{preview}" + ); + + let views_before = view_requests_for(&mock, UUID).await; + let (exit, stdout, stderr) = get_vendored(root, &mock.uri(), UUID, &["--json"]); + assert_eq!(exit, 1, "{stdout}\n{stderr}"); + let env = parse_single_json_doc(&stdout); + assert_eq!(env["status"], "error", "{env}"); + assert_eq!(env["downloaded"], 0, "{env}"); + assert_eq!(env["error"]["code"], WS_CODE, "{env}"); + assert_eq!( + view_requests_for(&mock, UUID).await, + views_before + 1, + "only the UUID lookup may fetch" + ); + assert!(!root.join(".socket/manifest.json").exists()); + assert_eq!(String::from_utf8(lock_bytes(root)).unwrap(), lock); + assert_eq!( + std::fs::read(root.join(".socket/vendor/state.json")).unwrap(), + state + ); + assert_eq!(std::fs::read(artifact_path).unwrap(), artifact); +} + +/// The download phase must NOT refuse a purl the ledger already wires at +/// the selected uuid: the re-run classifies it `skipped` (already in the +/// manifest) exactly as on a non-Bun project, instead of `failed`. Pinned +/// independently of the vendor step below so the CLI half of the +/// exemption is guarded even while the engine half lands separately. +#[tokio::test] +async fn already_vendored_v1_workspace_rerun_download_phase_is_skipped_not_refused() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + let lock_before = vendor_then_add_workspace(tmp.path(), &mock.uri()); + + let (exit, stdout, stderr) = scan_vendored(tmp.path(), &mock.uri(), &["--json"]); + let v = parse_single_json_doc(&stdout); + let rec = &v["download"]["patches"][0]; + assert_eq!( + rec["action"], "skipped", + "an in-sync vendored purl must not be refused by the preflight (exit {exit}): {v}\n{stderr}" + ); + assert!(rec.get("errorCode").is_none(), "{v}"); + assert_eq!(v["download"]["failed"], 0, "{v}"); + assert_eq!( + lock_bytes(tmp.path()), + lock_before, + "an in-sync re-run leaves the lock alone" + ); +} + +/// The full in-sync re-run on the upgraded workspace project: exit 0, the +/// download phase `skipped` (the CLI exempts already-vendored purls from the +/// Bun preflight), the vendor step a `skipped`/`already_vendored` event — +/// `vendor_bun` classifies the in-sync tuple BEFORE applying the workspace +/// gate, so a pre-1.4 workspace project vendored earlier keeps working. +#[tokio::test] +async fn already_vendored_v1_workspace_rerun_is_already_vendored_exit_zero() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + let lock_before = vendor_then_add_workspace(tmp.path(), &mock.uri()); + + let (exit, stdout, stderr) = scan_vendored(tmp.path(), &mock.uri(), &["--json"]); + assert_eq!(exit, 0, "stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + assert_eq!(v["status"], "success", "{v}"); + assert_eq!(v["download"]["patches"][0]["action"], "skipped", "{v}"); + let events = v["vendor"]["events"].as_array().unwrap(); + assert!( + events.iter().any(|e| e["purl"] == PURL + && e["action"] == "skipped" + && e["errorCode"] == "already_vendored"), + "{v}" + ); + assert_eq!(lock_bytes(tmp.path()), lock_before); +} + +/// A SUPERSEDING patch uuid on the upgraded workspace project: the ledger +/// holds the OLD uuid, so a ledger-only exemption refused the update at +/// download (`vendor_bun_workspace_unsupported`, exit 1) with a re-lock +/// remedy a Bun 1.2/1.3 team cannot follow — while the engine would have +/// re-vendored the already-local tuple in place. The lock-derived +/// exemption sees every instance is ours and lets the run through: the +/// record is `updated`, the engine re-pins the tuple at the new uuid, the +/// lock stays at lockfileVersion 1 with its workspace entry intact. +#[tokio::test] +async fn superseding_uuid_on_already_vendored_v1_workspace_is_revendored_not_refused() { + const SUPERSEDING_UUID: &str = "33333333-3333-4333-8333-333333333333"; + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + vendor_then_add_workspace(tmp.path(), &mock.uri()); + mount_view(&mock, SUPERSEDING_UUID, PURL).await; + + let (exit, stdout, stderr) = + get_vendored(tmp.path(), &mock.uri(), SUPERSEDING_UUID, &["--json"]); + assert_eq!(exit, 0, "stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + assert_eq!(v["status"], "success", "{v}"); + assert_eq!(v["patches"][0]["action"], "updated", "{v}"); + assert_eq!(v["patches"][0]["oldUuid"], UUID, "{v}"); + assert!(v["patches"][0].get("errorCode").is_none(), "{v}"); + assert_eq!(v["vendor"]["summary"]["applied"], 1, "{v}"); + assert_eq!(v["vendor"]["summary"]["failed"], 0, "{v}"); + assert!( + !stdout.contains(WS_CODE), + "no arm may raise the workspace refusal for an already-vendored purl: {v}" + ); + let lock = String::from_utf8(lock_bytes(tmp.path())).unwrap(); + assert!( + lock.contains(&format!( + "\"left-pad@.socket/vendor/npm/{SUPERSEDING_UUID}/left-pad-1.3.0.tgz\"" + )), + "the tuple must point at the superseding uuid:\n{lock}" + ); + assert!( + !lock.contains(UUID), + "the old uuid path must be gone:\n{lock}" + ); + assert!( + lock.contains(" \"consumer\": [\"consumer@workspace:packages/consumer\"],\n"), + "the workspace entry survives:\n{lock}" + ); + assert!(lock.starts_with("{\n \"lockfileVersion\": 1,\n"), "{lock}"); + let state: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(tmp.path().join(".socket/vendor/state.json")).unwrap(), + ) + .unwrap(); + assert_eq!(state["entries"][PURL]["uuid"], SUPERSEDING_UUID, "{state}"); + assert!(tmp + .path() + .join(format!( + ".socket/vendor/npm/{SUPERSEDING_UUID}/left-pad-1.3.0.tgz" + )) + .is_file()); + assert!( + !tmp.path() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists(), + "the stale uuid dir is swept" + ); +} + +/// The same upgraded project with its vendor ledger LOST (`state.json` +/// deleted — the shape `repair` reconstructs from): the ledger exemption +/// has nothing to match, but the lock still says every instance is ours, +/// so the download phase must NOT refuse the in-sync re-run — it +/// classifies `skipped` (already in the manifest) and hands the ledgerless +/// wiring to the engine, whose verdict (not the preflight's) decides the +/// run. Nothing here may raise the workspace code. +#[tokio::test] +async fn wiped_ledger_on_already_vendored_v1_workspace_is_not_refused_at_preflight() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + let lock_before = vendor_then_add_workspace(tmp.path(), &mock.uri()); + std::fs::remove_file(tmp.path().join(".socket/vendor/state.json")).unwrap(); + + let (exit, stdout, stderr) = scan_vendored(tmp.path(), &mock.uri(), &["--json"]); + let v = parse_single_json_doc(&stdout); + let rec = &v["download"]["patches"][0]; + assert_eq!( + rec["action"], "skipped", + "an in-sync purl must not be refused for a lost ledger (exit {exit}): {v}\n{stderr}" + ); + assert!(rec.get("errorCode").is_none(), "{v}"); + assert_eq!(v["download"]["failed"], 0, "{v}"); + assert!( + !stdout.contains(WS_CODE), + "no arm may raise the workspace refusal: {v}" + ); + assert_eq!( + lock_bytes(tmp.path()), + lock_before, + "the wired lock is left alone" + ); +} + +// --------------------------------------------------------------------------- +// Corrupt vendor ledger beside a refused Bun lock: name the ledger +// --------------------------------------------------------------------------- + +/// `get --mode vendored` returns before the vendor step, so the +/// preflight's refusal is the ONLY diagnosis the run emits; the dry-run +/// preview and the detached download phase share the same ledger-blind +/// spot. All three must report `vendor_state_unreadable` (the code every +/// other vendor-adjacent command uses for this file) with the io/parse +/// detail, not the Bun re-lock remedy — fail-closed still: nothing exempt, +/// nothing written, the corrupt ledger left in place for the operator. +#[tokio::test] +async fn corrupt_vendor_ledger_on_refused_bun_lock_reports_vendor_state_unreadable() { + const LEDGER_CODE: &str = "vendor_state_unreadable"; + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_bun_project(tmp.path(), LockShape::V1Workspace); + let vendor_dir = tmp.path().join(".socket/vendor"); + std::fs::create_dir_all(&vendor_dir).unwrap(); + std::fs::write(vendor_dir.join("state.json"), b"{ not json").unwrap(); + let lock_before = lock_bytes(tmp.path()); + + // uuid path, JSON: the pre-record refusal envelope carries the ledger code. + let (exit, stdout, stderr) = get_vendored(tmp.path(), &mock.uri(), UUID, &["--json"]); + assert_eq!(exit, 1, "stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + assert_eq!(v["status"], "error", "{v}"); + assert_eq!(v["error"]["code"], LEDGER_CODE, "{v}"); + assert!( + v["error"]["message"] + .as_str() + .is_some_and(|m| m.contains("state.json")), + "the detail names the ledger file: {v}" + ); + assert_eq!(v["patches"][0]["errorCode"], LEDGER_CODE, "{v}"); + assert!( + !stdout.contains(WS_CODE), + "the Bun lock remedy must not shadow the ledger corruption: {v}" + ); + assert!(!tmp.path().join(".socket/manifest.json").exists()); + + // uuid path, human: the code-tagged Error line. + let (exit, stdout, stderr) = get_vendored(tmp.path(), &mock.uri(), UUID, &[]); + assert_eq!(exit, 1, "stdout={stdout}\nstderr={stderr}"); + assert!( + stderr.contains(&format!("Error ({LEDGER_CODE}):")), + "stderr must carry the ledger code:\n{stderr}" + ); + + // Dry-run preview: `would_refuse` with the ledger code. + let (exit, stdout, stderr) = + get_vendored(tmp.path(), &mock.uri(), UUID, &["--dry-run", "--json"]); + assert_eq!(exit, 0, "stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + let rec = &v["vendor"]["patches"][0]; + assert_eq!(rec["action"], "would_refuse", "{v}"); + assert_eq!(rec["errorCode"], LEDGER_CODE, "{v}"); + + // Detached download phase: the same code before any fetch. + let (exit, stdout, stderr) = scan_vendored(tmp.path(), &mock.uri(), &["--detached", "--json"]); + assert_eq!(exit, 1, "stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + let rec = &v["download"]["patches"][0]; + assert_eq!(rec["action"], "failed", "{v}"); + assert_eq!(rec["errorCode"], LEDGER_CODE, "{v}"); + assert_eq!(v["download"]["downloaded"], 0, "{v}"); + + assert_eq!( + lock_bytes(tmp.path()), + lock_before, + "refused runs never touch the lock" + ); + assert_eq!( + std::fs::read(vendor_dir.join("state.json")).unwrap(), + b"{ not json", + "the corrupt ledger is left for the operator, never overwritten" + ); + assert!(!vendor_dir.join("npm").exists()); +} + +// --------------------------------------------------------------------------- +// Digest-less re-saves (Bun 1.1.39–1.3.9) +// --------------------------------------------------------------------------- +// Every text-lock release below 1.3.10 re-saves our local-tarball 3-tuple +// WITHOUT its sha512 on any later lock re-save (`bun add`, `bun install` +// after a manifest change) — measured on real 1.1.45, 1.2.23 and 1.3.9. The +// 2-tuple `["left-pad@.socket/vendor/npm//left-pad-1.3.0.tgz", {}]` +// is still our wiring: the re-run must stay `already_vendored` (and heal +// the digest), `repair` must rebuild through it, and `rollback` must +// restore the registry line — not `vendor_lock_entry_not_found` / +// `vendor_lock_entry_drifted` + `vendor_artifact_kept`. + +/// The packages-entry line keyed `key` (verbatim, no line terminator). +fn bun_packages_line(lock: &str, key: &str) -> String { + let prefix = format!(" \"{key}\": ["); + lock.split('\n') + .find(|l| l.starts_with(&prefix)) + .unwrap_or_else(|| panic!("no `{key}` packages entry in:\n{lock}")) + .trim_end_matches('\r') + .to_string() +} + +/// The line as Bun < 1.3.10 re-saves it: trailing `"sha512-…"` dropped. +fn drop_bun_digest(line: &str) -> String { + let cut = line + .rfind(", \"sha512-") + .unwrap_or_else(|| panic!("no sha512 element in {line}")); + let tail = if line.ends_with("],") { "]," } else { "]" }; + format!("{}{tail}", &line[..cut]) +} + +/// Vendor the V1 direct project, then re-spell its wired line digest-less. +/// Returns (pristine lock, wired lock, wired line). +fn vendor_then_drop_digest(root: &Path, mock_uri: &str) -> (Vec, String, String) { + write_bun_project(root, LockShape::V1Direct); + let pristine = lock_bytes(root); + let (exit, stdout, stderr) = scan_vendored(root, mock_uri, &["--json"]); + assert_eq!(exit, 0, "setup vendoring: stdout={stdout}\nstderr={stderr}"); + let wired = String::from_utf8(lock_bytes(root)).unwrap(); + let wired_line = bun_packages_line(&wired, "left-pad"); + assert!( + wired_line.contains(&format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz")) + && wired_line.contains("\"sha512-"), + "setup: {wired_line}" + ); + let digestless = drop_bun_digest(&wired_line); + std::fs::write( + root.join("bun.lock"), + wired.replace(&wired_line, &digestless), + ) + .unwrap(); + (pristine, wired, wired_line) +} + +#[tokio::test] +async fn digestless_vendored_tuple_rerun_is_already_vendored_and_heals_the_digest() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + let (_, wired, _) = vendor_then_drop_digest(tmp.path(), &mock.uri()); + + let (exit, stdout, stderr) = scan_vendored(tmp.path(), &mock.uri(), &["--json"]); + assert_eq!(exit, 0, "stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + assert_eq!(v["status"], "success", "{v}"); + assert_eq!( + v["download"]["patches"][0]["action"], "skipped", + "the ledger still wires the purl: {v}" + ); + assert_eq!(v["download"]["failed"], 0, "{v}"); + let vendor = &v["vendor"]; + assert_eq!(vendor["summary"]["applied"], 0, "{v}"); + assert_eq!(vendor["summary"]["skipped"], 1, "{v}"); + assert_eq!(vendor["summary"]["failed"], 0, "{v}"); + let events = vendor["events"].as_array().unwrap(); + assert!( + events.iter().any(|e| e["purl"] == PURL + && e["action"] == "skipped" + && e["errorCode"] == "already_vendored"), + "{v}" + ); + assert!( + events + .iter() + .all(|e| e["errorCode"] != "vendor_lock_entry_not_found" && e["action"] != "failed"), + "{v}" + ); + assert_eq!( + String::from_utf8(lock_bytes(tmp.path())).unwrap(), + wired, + "the in-sync re-run heals the digest back to the 3-tuple, byte-identical" + ); +} + +#[tokio::test] +async fn digestless_vendored_tuple_rollback_restores_the_registry_line() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + let (pristine, _, _) = vendor_then_drop_digest(tmp.path(), &mock.uri()); + + let (exit, stdout, stderr) = run(tmp.path(), &with_api(&["rollback", "--json"], &mock.uri())); + assert_eq!(exit, 0, "rollback: stdout={stdout}\nstderr={stderr}"); + assert!( + !stdout.contains("vendor_lock_entry_drifted") && !stdout.contains("vendor_artifact_kept"), + "the digest-less spelling of our own tuple is not drift: {stdout}" + ); + assert_eq!( + lock_bytes(tmp.path()), + pristine, + "rollback must restore the registry lock byte-for-byte" + ); + assert!( + !tmp.path().join(".socket/vendor/npm").exists(), + "rollback removes the vendored artifact tree" + ); +} + +#[tokio::test] +async fn repair_rebuilds_a_deleted_artifact_through_a_digestless_lock() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + let (_, wired, _) = vendor_then_drop_digest(tmp.path(), &mock.uri()); + let tgz = tmp + .path() + .join(format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz")); + std::fs::remove_file(&tgz).unwrap(); + + // `scan --mode vendored` keeps no local blob in this harness, so the + // rebuild fetches the patch content from the mock API (no `--offline`). + let (exit, stdout, stderr) = run(tmp.path(), &with_api(&["repair", "--json"], &mock.uri())); + assert_eq!(exit, 0, "repair: stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + assert_eq!(v["status"], "success", "{v}"); + assert_eq!(v["summary"]["rebuilt"], 1, "{v}"); + assert!( + v["events"] + .as_array() + .unwrap() + .iter() + .any(|e| e["action"] == "rebuilt" && e["purl"] == PURL), + "{v}" + ); + assert!(tgz.is_file(), "the artifact must be rebuilt"); + assert_eq!( + String::from_utf8(lock_bytes(tmp.path())).unwrap(), + wired, + "the rebuild re-pins the digest into the healed 3-tuple" + ); +} + +// --------------------------------------------------------------------------- +// Workspace-member --cwd: today's behaviour, pinned +// --------------------------------------------------------------------------- + +/// `scan --mode vendored --cwd `: the member directory +/// holds no bun.lock, so the Bun preflight passes (it cannot see a Bun +/// project), the download phase RECORDS the patch in +/// `/.socket/manifest.json`, and the vendor engine then refuses +/// `vendor_lockfile_missing` (the flavor router finds no lockfile at cwd). +/// Pre-existing, flavor-agnostic behaviour (`--cwd` is the lockfile root +/// by contract) — pinned here so any change to it is deliberate. The root +/// tree is never touched. +#[tokio::test] +async fn scan_vendored_from_workspace_member_cwd_records_then_engine_refuses_lockfile_missing() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_bun_project(tmp.path(), LockShape::V2Workspace); + // The member must have an installed copy for the crawler to find. + let member = tmp.path().join("packages/consumer"); + write_installed_left_pad(&member); + let lock_before = lock_bytes(tmp.path()); + + let member_str = member.to_str().unwrap().to_string(); + let (exit, stdout, stderr) = + scan_vendored(tmp.path(), &mock.uri(), &["--json", "--cwd", &member_str]); + assert_eq!(exit, 1, "stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + assert_eq!(v["status"], "partial_failure", "{v}"); + assert_eq!(v["download"]["downloaded"], 1, "{v}"); + assert_eq!(v["download"]["patches"][0]["action"], "added", "{v}"); + let events = v["vendor"]["events"].as_array().unwrap(); + assert!( + events + .iter() + .any(|e| e["purl"] == PURL && e["errorCode"] == MISSING_CODE), + "the engine refuses from the member dir: {v}" + ); + let member_manifest = manifest_value(&member).expect("member manifest written"); + assert_eq!( + member_manifest["patches"][PURL]["uuid"], UUID, + "{member_manifest}" + ); + assert_eq!(lock_bytes(tmp.path()), lock_before, "root lock untouched"); + assert!(!tmp.path().join(".socket").exists(), "no root .socket/"); +} + +// --------------------------------------------------------------------------- +// FIFO bun.lock (Unix): refused fast, never wedged +// --------------------------------------------------------------------------- + +/// Spawn the binary with the same env scrub as `common::run_with_env`, but +/// kill it if it outlives `deadline` — a wedged child must fail the test, +/// not hang the suite. +#[cfg(unix)] +fn run_with_deadline(root: &Path, argv: &[&str], deadline: Duration) -> (i32, String, String) { + let out_path = root.join("child.stdout"); + let err_path = root.join("child.stderr"); + let mut cmd = Command::new(common::binary()); + cmd.args(argv).current_dir(root); + for (key, _) in std::env::vars_os() { + let name = key.to_string_lossy(); + if name.starts_with("SOCKET_") + && !name.contains("TELEMETRY") + && name != "SOCKET_NO_CONFIG" + && name != "SOCKET_NO_UPDATE_CHECK" + { + cmd.env_remove(&key); + } + } + cmd.env("SOCKET_NO_CONFIG", "1") + .env("SOCKET_NO_UPDATE_CHECK", "1") + .env("SOCKET_TELEMETRY_DISABLED", "1") + .stdin(Stdio::null()) + .stdout(std::fs::File::create(&out_path).unwrap()) + .stderr(std::fs::File::create(&err_path).unwrap()); + let mut child = cmd.spawn().expect("spawn socket-patch"); + let started = Instant::now(); + let status = loop { + if let Some(status) = child.try_wait().expect("try_wait") { + break status; + } + if started.elapsed() > deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!( + "socket-patch {argv:?} did not finish within {deadline:?} — wedged on the FIFO?\nstderr:\n{}", + std::fs::read_to_string(&err_path).unwrap_or_default() + ); + } + std::thread::sleep(Duration::from_millis(25)); + }; + ( + status.code().unwrap_or(-1), + std::fs::read_to_string(&out_path).unwrap_or_default(), + std::fs::read_to_string(&err_path).unwrap_or_default(), + ) +} + +#[cfg(unix)] +fn mkfifo(path: &Path) { + assert!( + Command::new("mkfifo").arg(path).status().unwrap().success(), + "mkfifo {}", + path.display() + ); +} + +/// A FIFO squatting `bun.lock` (a planted special file): the preflight's +/// guarded open refuses `vendor_lockfile_missing` immediately on both the +/// scan and the uuid path — no `open(2)` waiting forever for a writer. +#[cfg(unix)] +#[tokio::test] +async fn fifo_bun_lock_is_refused_without_blocking() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let uri = mock.uri(); + + // scan: discovery's lock inventory and the preflight both open the FIFO + // through the guarded path. + let tmp = tempfile::tempdir().unwrap(); + write_bun_project(tmp.path(), LockShape::V1Direct); + std::fs::remove_file(tmp.path().join("bun.lock")).unwrap(); + mkfifo(&tmp.path().join("bun.lock")); + let argv = with_api( + &[ + "scan", + "--mode", + "vendored", + "--vendor-source", + "build", + "--json", + ], + &uri, + ); + let (exit, stdout, stderr) = run_with_deadline(tmp.path(), &argv, Duration::from_secs(20)); + assert_eq!(exit, 1, "scan: stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + assert_refused_record(&v["download"]["patches"][0], MISSING_CODE, &v); + assert_eq!(view_requests_for(&mock, UUID).await, 0); + + // get : the FIFO is the only Bun artefact the preflight reads. + let tmp = tempfile::tempdir().unwrap(); + write_bun_project(tmp.path(), LockShape::V1Direct); + std::fs::remove_file(tmp.path().join("bun.lock")).unwrap(); + mkfifo(&tmp.path().join("bun.lock")); + let argv = with_api( + &[ + "get", + UUID, + "--mode", + "vendored", + "--vendor-source", + "build", + "--json", + ], + &uri, + ); + let (exit, stdout, stderr) = run_with_deadline(tmp.path(), &argv, Duration::from_secs(20)); + assert_eq!(exit, 1, "get: stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + assert_eq!(v["status"], "error", "{v}"); + assert_eq!(v["error"]["code"], MISSING_CODE, "{v}"); + assert!(!tmp.path().join(".socket").exists()); +} diff --git a/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs b/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs new file mode 100644 index 00000000..1af76791 --- /dev/null +++ b/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs @@ -0,0 +1,1179 @@ +//! Hermetic bun mode-takeover contract tests through the built binary. +//! +//! Twin of the yarn legs in `mode_migration_npm.rs` and the pnpm +//! `hosted_to_vendor_conversion` module in `in_process_vendor.rs`, for the +//! one npm-family lock flavor whose hosted→vendored takeover used to be a +//! hard refusal (`redirect_revert_failed`, "cannot replay yet"): the bun +//! hosted rewrite REPLACES the registry 4-tuple's `name@version` spec with +//! a URL 3-tuple, so without the per-purl pre-revert the bun vendor backend +//! cannot even find the entry. The API is wiremock; no `bun` binary is +//! needed (the lock grammar is the real bun 1.4.2 lockfileVersion-2 shape +//! from the compatibility matrix captures, and the packages tuple grammar +//! is identical on versions 0/1/2). +//! +//! Scenarios: +//! 1. `scan --mode hosted` → `scan --mode vendored`: the takeover reverts +//! the hosted line, drops the redirect-ledger record, vendors from the +//! pristine registry line (the vendor ledger's `original` is the +//! REGISTRY tuple, never the hosted URL), and `vendor --revert` +//! restores the pristine bytes. +//! 2. `vendor --dry-run` over the live hosted redirect previews the +//! takeover (`vendor_would_revert_redirect`) with no false +//! `vendor_lock_entry_not_found` follow-up and no writes; the wet +//! `vendor` then completes it. +//! 3. Two hosted records: a SCOPED `rollback ` (per-purl path, the +//! whole-ledger replay is not eligible) unwinds only the targeted line +//! and record; the sibling stays hosted. +//! 4. Same ledger, `remove `. +//! 5. A hosted-wired lockfileVersion-1 WORKSPACE lock (hosted accepts it, +//! the vendored backend refuses it): `vendor` — dry and wet — refuses +//! `vendor_bun_workspace_unsupported` BEFORE the takeover reverts +//! anything, so the hosted wiring survives byte-for-byte; the v2 twin +//! still takes over. +//! +//! Every child process gets the ambient `SOCKET_*` vars scrubbed and +//! telemetry hard-disabled; each test runs in its own tempdir. + +use std::path::Path; +use std::process::Command; + +use base64::Engine as _; +use serde_json::{json, Value}; +use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; +use wiremock::matchers::{method, path, path_regex}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const ORG: &str = "test-org"; +const NAME: &str = "left-pad"; +const VERSION: &str = "1.3.0"; +const PURL: &str = "pkg:npm/left-pad@1.3.0"; +/// Canonical-grammar patch uuid (the vendor path layer validates the uuid +/// path level fail-closed). +const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; +const HOSTED_URL: &str = "http://patch.test/patch/npm/left-pad/1.3.0/55555555-5555-4555-8555-555555555555/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz"; +const PATCHED_SHA512: &str = "sha512-PATCHEDpatchedPATCHEDpatched0123456789=="; +const ORIG_INDEX: &[u8] = b"module.exports = () => 'orig';\n"; +const PATCHED_INDEX: &[u8] = b"module.exports = () => 'patched';\n"; + +/// The second hosted record of the scoped-unwind scenarios. +const OTHER_NAME: &str = "other"; +const OTHER_PURL: &str = "pkg:npm/other@1.0.0"; +const OTHER_UUID: &str = "0a1b2c3d-4e5f-4a7b-8c9d-0e1f2a3b4c5d"; +const OTHER_HOSTED_URL: &str = "http://patch.test/patch/npm/other/1.0.0/55555555-5555-4555-8555-555555555555/0a1b2c3d-4e5f-4a7b-8c9d-0e1f2a3b4c5d/other-1.0.0.tgz"; + +/// The registry 4-tuple lines exactly as bun 1.4.2 emits them (matrix +/// capture grammar; the `""` registry field is the default registry). +const LEFT_PAD_REGISTRY_LINE: &str = r#" "left-pad": ["left-pad@1.3.0", "", {}, "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="],"#; +const OTHER_REGISTRY_LINE: &str = + r#" "other": ["other@1.0.0", "", {}, "sha512-otherOTHERother0123456789=="],"#; + +/// Pristine lockfileVersion-2 bun.lock for a root-only project depending on +/// `left-pad@1.3.0` (real bun 1.4.2 shape: `configVersion`, the root +/// workspace block with trailing commas, one packages entry per line). +fn pristine_lock() -> String { + format!( + "{{\n \"lockfileVersion\": 2,\n \"configVersion\": 1,\n \"workspaces\": {{\n \"\": {{\n \"name\": \"bun-takeover-fixture\",\n \"dependencies\": {{\n \"left-pad\": \"1.3.0\",\n }},\n }},\n }},\n \"packages\": {{\n{LEFT_PAD_REGISTRY_LINE}\n }}\n}}\n" + ) +} + +/// Pristine lock with TWO registry entries (`left-pad@1.3.0`, `other@1.0.0`). +fn pristine_lock_two() -> String { + format!( + "{{\n \"lockfileVersion\": 2,\n \"configVersion\": 1,\n \"workspaces\": {{\n \"\": {{\n \"name\": \"bun-takeover-fixture\",\n \"dependencies\": {{\n \"left-pad\": \"1.3.0\",\n \"other\": \"1.0.0\",\n }},\n }},\n }},\n \"packages\": {{\n{LEFT_PAD_REGISTRY_LINE}\n\n{OTHER_REGISTRY_LINE}\n }}\n}}\n" + ) +} + +/// The URL 3-tuple line the hosted rewriter writes for `key`. +fn hosted_line(key: &str, name: &str, url: &str, sha512: &str) -> String { + format!(" \"{key}\": [\"{name}@{url}\", {{}}, \"{sha512}\"],") +} + +/// The packages-entry line keyed `key` (verbatim, no line terminator). +fn lock_line(lock: &str, key: &str) -> String { + let prefix = format!(" \"{key}\": ["); + lock.split('\n') + .find(|l| l.starts_with(&prefix)) + .unwrap_or_else(|| panic!("no `{key}` entry in:\n{lock}")) + .trim_end_matches('\r') + .to_string() +} + +fn read(root: &Path, rel: &str) -> String { + std::fs::read_to_string(root.join(rel)).unwrap_or_else(|e| panic!("read {rel}: {e}")) +} + +// ───────────────────────────── fixture ───────────────────────────── + +/// package.json + the installed (unpatched) copy under node_modules + the +/// given bun.lock. +fn write_bun_project(root: &Path, lock: &str, deps: &[(&str, &str)]) { + let dep_map: serde_json::Map = deps + .iter() + .map(|(n, v)| (n.to_string(), Value::String(v.to_string()))) + .collect(); + std::fs::write( + root.join("package.json"), + serde_json::to_vec_pretty(&json!({ + "name": "bun-takeover-fixture", + "version": "1.0.0", + "private": true, + "dependencies": Value::Object(dep_map), + })) + .unwrap(), + ) + .unwrap(); + for (name, version) in deps { + let pkg = root.join("node_modules").join(name); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + format!(r#"{{"name":"{name}","version":"{version}"}}"#), + ) + .unwrap(); + std::fs::write(pkg.join("index.js"), ORIG_INDEX).unwrap(); + } + std::fs::write(root.join("bun.lock"), lock).unwrap(); +} + +fn patch_record(uuid: &str) -> Value { + json!({ + "uuid": uuid, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": compute_git_sha256_from_bytes(ORIG_INDEX), + "afterHash": compute_git_sha256_from_bytes(PATCHED_INDEX), + } + }, + "vulnerabilities": {}, + "description": "bun takeover fixture", + "license": "MIT", + "tier": "free" + }) +} + +/// `.socket/manifest.json` + the after-hash blob, so `vendor --offline` +/// runs fully offline (hosted mode writes no manifest — its ledger is its +/// store). +fn seed_manifest_and_blob(root: &Path) { + let socket = root.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + let mut bytes = + serde_json::to_vec_pretty(&json!({ "patches": { PURL: patch_record(UUID) } })).unwrap(); + bytes.push(b'\n'); + std::fs::write(socket.join("manifest.json"), &bytes).unwrap(); + std::fs::write( + socket + .join("blobs") + .join(compute_git_sha256_from_bytes(PATCHED_INDEX)), + PATCHED_INDEX, + ) + .unwrap(); +} + +/// The full hosted-mode API mock set for the one patch over `PURL` +/// (discovery + by-package + grant + view). The view carries the patched +/// file's `blobContent`, so `scan --mode vendored`'s download phase can +/// stage the blob it vendors from. +async fn mock_api(server: &MockServer) { + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "packages": [{ + "purl": PURL, + "patches": [{ + "uuid": UUID, "purl": PURL, "tier": "free", + "cveIds": [], "ghsaIds": [], "severity": "high", + "title": "bun takeover fixture" + }] + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "patches": [{ + "uuid": UUID, "purl": PURL, + "publishedAt": "2024-01-01T00:00:00Z", + "description": "x", "license": "MIT", "tier": "free", + "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "results": { + UUID: { + "status": "granted", + "url": HOSTED_URL, + "purl": PURL, + "artifacts": [{ + "kind": "tarball", + "url": HOSTED_URL, + "integrity": { "sha512": PATCHED_SHA512 } + }], + "registryOverride": null + } + } + }))) + .mount(server) + .await; + let mut view = patch_record(UUID); + view["purl"] = json!(PURL); + view["publishedAt"] = json!("2024-01-01T00:00:00Z"); + view["files"]["package/index.js"]["blobContent"] = + json!(base64::engine::general_purpose::STANDARD.encode(PATCHED_INDEX)); + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(view)) + .mount(server) + .await; +} + +// ───────────────────────── subprocess runner ───────────────────────── + +/// Run the built `socket-patch` binary with every ambient `SOCKET_*` var +/// scrubbed (except the hermetic `SOCKET_NO_CONFIG`) and telemetry +/// hard-disabled. Returns `(exit_code, stdout, stderr)`. +fn run_cli(cwd: &Path, args: &[&str]) -> (i32, String, String) { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_socket-patch")); + cmd.args(args).current_dir(cwd); + for (key, _) in std::env::vars() { + if key.starts_with("SOCKET_") && key != "SOCKET_NO_CONFIG" { + cmd.env_remove(key); + } + } + cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); + let out = cmd.output().expect("spawn socket-patch binary"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +/// `--json` invocation returning the parsed envelope. +fn run_json(cwd: &Path, args: &[&str]) -> (i32, Value) { + let (code, stdout, stderr) = run_cli(cwd, args); + // The child's stderr rides the harness's captured output so a failing + // assertion downstream shows the CLI's own diagnostics. + if !stderr.trim().is_empty() { + println!("[{}] stderr:\n{stderr}", args.first().unwrap_or(&"?")); + } + let env: Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!("{args:?} must emit a JSON envelope: {e}\nstdout:\n{stdout}\nstderr:\n{stderr}") + }); + (code, env) +} + +fn scan_mode(cwd: &Path, api_url: &str, mode: &str, extra: &[&str]) -> (i32, Value) { + let mut args = vec![ + "scan", + "--mode", + mode, + "--json", + "--yes", + "--api-url", + api_url, + "--api-token", + "fake", + "--org", + ORG, + "--cwd", + cwd.to_str().unwrap(), + ]; + args.extend_from_slice(extra); + run_json(cwd, &args) +} + +fn vendor_cli(cwd: &Path, extra: &[&str]) -> (i32, Value) { + let mut args = vec![ + "vendor", + "--json", + "--offline", + "--cwd", + cwd.to_str().unwrap(), + ]; + args.extend_from_slice(extra); + run_json(cwd, &args) +} + +fn events(envelope: &Value) -> &Vec { + envelope["events"].as_array().expect("events array") +} + +/// The first event matching `action` (+ `errorCode` when given; a plain +/// `applied` carries `errorCode: null`). +fn find_event<'a>(envelope: &'a Value, action: &str, error_code: Option<&str>) -> &'a Value { + events(envelope) + .iter() + .find(|e| e["action"] == action && error_code.is_none_or(|c| e["errorCode"] == c)) + .unwrap_or_else(|| panic!("expected a `{action}`/`{error_code:?}` event in:\n{envelope:#}")) +} + +fn assert_no_event_code(envelope: &Value, error_code: &str) { + assert!( + events(envelope) + .iter() + .all(|e| e["errorCode"] != error_code), + "unexpected `{error_code}` event in:\n{envelope:#}" + ); +} + +/// The vendored local-tarball 3-tuple bun must end up with. +fn vendored_rel_tgz() -> String { + format!(".socket/vendor/npm/{UUID}/{NAME}-{VERSION}.tgz") +} + +/// Assertions shared by the takeover scenarios once the wet vendored run +/// has happened: the redirect ledger no longer claims the purl, bun.lock +/// carries the local tuple and no hosted residue, and the vendor ledger's +/// recorded `original` is the PRISTINE registry line. +fn assert_pure_vendored(root: &Path) { + match std::fs::read_to_string(root.join(".socket/vendor/redirect-state.json")) { + Ok(text) => { + let ledger: Value = serde_json::from_str(&text).unwrap(); + assert!( + ledger["records"].get(PURL).is_none(), + "the superseded redirect record must be dropped: {ledger:#}" + ); + assert!( + ledger["edits"] + .as_array() + .is_none_or(|edits| edits.iter().all(|e| e["key"] != NAME)), + "the superseded bun.lock edit must be dropped: {ledger:#}" + ); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + // An emptied ledger is deleted — the expected outcome here. + } + Err(e) => panic!("unreadable redirect ledger: {e}"), + } + + let lock = read(root, "bun.lock"); + assert!( + !lock.contains(HOSTED_URL) && !lock.contains("patch.test"), + "the hosted URL must be gone from bun.lock:\n{lock}" + ); + let line = lock_line(&lock, NAME); + assert!( + line.contains(&format!("\"{NAME}@{}\"", vendored_rel_tgz())), + "bun.lock must carry the local vendored 3-tuple:\n{line}" + ); + assert!( + root.join(vendored_rel_tgz()).is_file(), + "the committed artifact must exist" + ); + + let state: Value = serde_json::from_str(&read(root, ".socket/vendor/state.json")).unwrap(); + let wiring = state["entries"][PURL]["wiring"] + .as_array() + .unwrap_or_else(|| panic!("wiring array: {state:#}")); + let lock_wiring = wiring + .iter() + .find(|w| w["kind"] == "bun_lock_package") + .unwrap_or_else(|| panic!("bun_lock_package wiring record: {state:#}")); + assert_eq!( + lock_wiring["original"], + json!(LEFT_PAD_REGISTRY_LINE), + "the vendor ledger must record the PRISTINE registry line as its original \ + (never the grant-tokenized hosted URL line): {state:#}" + ); +} + +// ───────────────────────────────────────────────────────────────────── +// 1. scan --mode hosted → scan --mode vendored → vendor --revert +// ───────────────────────────────────────────────────────────────────── + +#[tokio::test(flavor = "multi_thread")] +async fn bun_hosted_then_scan_vendored_takeover_round_trips_to_registry() { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_bun_project(root, &pristine_lock(), &[(NAME, VERSION)]); + let pristine = std::fs::read(root.join("bun.lock")).unwrap(); + + // A: hosted redirect — registry 4-tuple → URL 3-tuple, ledger claims + // the purl with one `redirect_bun_lock_package` edit whose original + // is the registry line. + let (code, env) = scan_mode(root, &server.uri(), "hosted", &[]); + assert_eq!(code, 0, "scan --mode hosted must succeed: {env:#}"); + assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + let hosted_lock = read(root, "bun.lock"); + assert_eq!( + lock_line(&hosted_lock, NAME), + hosted_line(NAME, NAME, HOSTED_URL, PATCHED_SHA512), + "hosted URL 3-tuple written:\n{hosted_lock}" + ); + let ledger: Value = + serde_json::from_str(&read(root, ".socket/vendor/redirect-state.json")).unwrap(); + assert!( + ledger["records"].get(PURL).is_some(), + "hosted run must record the purl: {ledger:#}\nhosted envelope: {env:#}" + ); + let edit = &ledger["edits"][0]; + assert_eq!(edit["kind"], "redirect_bun_lock_package", "{ledger:#}"); + assert_eq!( + edit["original"], + json!(LEFT_PAD_REGISTRY_LINE), + "{ledger:#}" + ); + + // The scan-side vendored preview is ledger-only by contract + // (`would_vendor` / `already_vendored` / `would_revendor`, CLI_CONTRACT + // "scan --vendor"): it must at least not fail and not write anything. + let (code, preview) = scan_mode(root, &server.uri(), "vendored", &["--dry-run"]); + assert_eq!(code, 0, "vendored preview must succeed: {preview:#}"); + assert_eq!( + read(root, "bun.lock"), + hosted_lock, + "a dry run must not touch bun.lock" + ); + assert!( + !root.join(".socket/vendor/state.json").exists(), + "a dry run must not create the vendor ledger" + ); + + // B: vendored scan over the LIVE hosted redirect — the takeover. Used + // to exit 1 with `redirect_revert_failed` ("cannot replay yet"). + let (code, env) = scan_mode(root, &server.uri(), "vendored", &[]); + assert_eq!( + code, 0, + "scan --mode vendored over the hosted bun project must succeed: {env:#}" + ); + assert_eq!(env["status"], "success", "{env:#}"); + let vendor = &env["vendor"]; + assert_eq!(vendor["summary"]["applied"], 1, "{env:#}"); + assert_eq!(vendor["summary"]["failed"], 0, "{env:#}"); + find_event(vendor, "skipped", Some("vendor_takeover_reverted_redirect")); + find_event(vendor, "applied", None); + assert_no_event_code(vendor, "redirect_revert_failed"); + assert_pure_vendored(root); + let manifest: Value = serde_json::from_str(&read(root, ".socket/manifest.json")).unwrap(); + assert_eq!(manifest["patches"][PURL]["uuid"], UUID, "{manifest:#}"); + + // C: a re-run is an in-sync no-op with no second takeover. + let (code, env) = scan_mode(root, &server.uri(), "vendored", &[]); + assert_eq!(code, 0, "{env:#}"); + find_event(&env["vendor"], "skipped", Some("already_vendored")); + assert_no_event_code(&env["vendor"], "vendor_takeover_reverted_redirect"); + + // D: `vendor --revert` restores the REGISTRY lock byte-exactly — the + // pre-redirect resolution the takeover carried forward, not the + // hosted splice. + let (code, env) = vendor_cli(root, &["--revert"]); + assert_eq!(code, 0, "revert must succeed: {env:#}"); + assert_eq!( + std::fs::read(root.join("bun.lock")).unwrap(), + pristine, + "bun.lock must be restored byte-identical to the pristine registry lock; got:\n{}", + read(root, "bun.lock") + ); + assert!( + !root.join(".socket/vendor").exists(), + ".socket/vendor must be fully pruned after the revert" + ); +} + +// ───────────────────────────────────────────────────────────────────── +// 1b. Digest-less re-saves (Bun 1.1.39–1.3.9) across the conversions +// ───────────────────────────────────────────────────────────────────── +// Every text-lock release below 1.3.10 re-saves a URL or local-tarball +// 3-tuple WITHOUT its sha512 on any later lock re-save (`bun add`, `bun +// install` after a manifest change; measured on real 1.1.45, 1.2.23 and +// 1.3.9). The 2-tuple is still the recorded wiring (same key, spec and +// meta): the per-purl claim must not refuse it as drift, or the +// hosted→vendored takeover, `rollback ` and `remove ` all fail +// `redirect_revert_failed`; the vendored revert must claim it by path, or +// the vendored→hosted takeover fails `redirect_vendored_revert_failed`. + +/// The line as Bun < 1.3.10 re-saves it: trailing `"sha512-…"` dropped. +fn drop_bun_digest(line: &str) -> String { + let cut = line + .rfind(", \"sha512-") + .unwrap_or_else(|| panic!("no sha512 element in {line}")); + let tail = if line.ends_with("],") { "]," } else { "]" }; + format!("{}{tail}", &line[..cut]) +} + +/// Re-spell the `key` packages line of the live bun.lock digest-less. +fn drop_digest_in_lock(root: &Path, key: &str) -> String { + let lock = read(root, "bun.lock"); + let line = lock_line(&lock, key); + let digestless = drop_bun_digest(&line); + assert!( + !digestless.contains("sha512") && digestless.ends_with("],"), + "{digestless}" + ); + std::fs::write(root.join("bun.lock"), lock.replace(&line, &digestless)).unwrap(); + digestless +} + +fn redirect_warning_codes(env: &Value) -> Vec { + env["redirect"]["warnings"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|w| w["code"].as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() +} + +#[tokio::test(flavor = "multi_thread")] +async fn bun_digestless_hosted_line_is_taken_over_by_scan_vendored_and_reverts_to_registry() { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_bun_project(root, &pristine_lock(), &[(NAME, VERSION)]); + let pristine = std::fs::read(root.join("bun.lock")).unwrap(); + + let (code, env) = scan_mode(root, &server.uri(), "hosted", &[]); + assert_eq!(code, 0, "{env:#}"); + assert_eq!( + lock_line(&read(root, "bun.lock"), NAME), + hosted_line(NAME, NAME, HOSTED_URL, PATCHED_SHA512) + ); + let digestless = drop_digest_in_lock(root, NAME); + assert!(digestless.contains(HOSTED_URL), "{digestless}"); + + // The takeover over the digest-less hosted line: used to refuse + // `redirect_revert_failed` ("has drifted from the recorded hosted + // redirect") and vendor nothing. + let (code, env) = scan_mode(root, &server.uri(), "vendored", &[]); + assert_eq!(code, 0, "takeover over a digest-less hosted line: {env:#}"); + assert_eq!(env["status"], "success", "{env:#}"); + let vendor = &env["vendor"]; + assert_eq!(vendor["summary"]["applied"], 1, "{env:#}"); + assert_eq!(vendor["summary"]["failed"], 0, "{env:#}"); + find_event(vendor, "skipped", Some("vendor_takeover_reverted_redirect")); + find_event(vendor, "applied", None); + assert_no_event_code(vendor, "redirect_revert_failed"); + assert_pure_vendored(root); + + // And the vendored wiring, re-saved digest-less again, still reverts + // to the pristine registry lock. + drop_digest_in_lock(root, NAME); + let (code, env) = vendor_cli(root, &["--revert"]); + assert_eq!(code, 0, "revert over a digest-less vendored line: {env:#}"); + assert_eq!(env["status"], "success", "{env:#}"); + assert_eq!( + std::fs::read(root.join("bun.lock")).unwrap(), + pristine, + "bun.lock restored byte-identical to the pristine registry lock; got:\n{}", + read(root, "bun.lock") + ); + assert!( + !root.join(".socket/vendor").exists(), + ".socket/vendor pruned" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn bun_digestless_vendored_line_is_taken_over_by_scan_hosted_and_rolls_back_to_registry() { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_bun_project(root, &pristine_lock(), &[(NAME, VERSION)]); + let pristine = std::fs::read(root.join("bun.lock")).unwrap(); + + let (code, env) = scan_mode(root, &server.uri(), "vendored", &[]); + assert_eq!(code, 0, "{env:#}"); + assert_eq!(env["vendor"]["summary"]["applied"], 1, "{env:#}"); + let digestless = drop_digest_in_lock(root, NAME); + assert!( + digestless.contains(&vendored_rel_tgz()), + "the vendored spec survives the re-save: {digestless}" + ); + + // vendored → hosted over the digest-less local tuple: the vendored + // revert claims the line by its `.socket/vendor/npm//` path. + let (code, env) = scan_mode(root, &server.uri(), "hosted", &[]); + assert_eq!( + code, 0, + "takeover over a digest-less vendored line: {env:#}" + ); + assert_eq!(env["status"], "success", "{env:#}"); + assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + let codes = redirect_warning_codes(&env); + assert!( + codes + .iter() + .any(|c| c == "redirect_takeover_reverted_vendored"), + "the takeover must be announced: {codes:?}\n{env:#}" + ); + assert!( + !codes.iter().any(|c| c == "redirect_vendored_revert_failed"), + "the vendored revert must not be refused: {env:#}" + ); + let lock = read(root, "bun.lock"); + assert_eq!( + lock_line(&lock, NAME), + hosted_line(NAME, NAME, HOSTED_URL, PATCHED_SHA512), + "hosted URL 3-tuple written:\n{lock}" + ); + assert!( + !lock.contains(".socket/vendor/npm/"), + "the local tuple must be gone:\n{lock}" + ); + assert!( + !root.join(vendored_rel_tgz()).exists(), + "the vendored artifact must be removed by the takeover" + ); + let ledger: Value = + serde_json::from_str(&read(root, ".socket/vendor/redirect-state.json")).unwrap(); + assert_eq!( + ledger["edits"][0]["original"], + json!(LEFT_PAD_REGISTRY_LINE), + "the hosted ledger records the PRISTINE registry line as its original: {ledger:#}" + ); + + // Unscoped rollback of the hosted wiring lands on the pristine lock. + let (code, env) = run_json( + root, + &[ + "rollback", + "--yes", + "--json", + "--cwd", + root.to_str().unwrap(), + ], + ); + assert_eq!(code, 0, "{env:#}"); + assert_eq!(env["status"], "success", "{env:#}"); + assert_eq!( + std::fs::read(root.join("bun.lock")).unwrap(), + pristine, + "pristine lock restored; got:\n{}", + read(root, "bun.lock") + ); +} + +#[test] +fn bun_scoped_rollback_and_remove_of_a_digestless_hosted_record_unwind_only_that_purl() { + for verb in ["rollback", "remove"] { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let pristine = write_two_record_hosted_project(root); + let digestless = drop_digest_in_lock(root, NAME); + assert!(digestless.contains(HOSTED_URL), "{digestless}"); + + let (code, env) = run_json( + root, + &[ + verb, + PURL, + "--yes", + "--json", + "--cwd", + root.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "scoped {verb} over a digest-less hosted line must succeed: {env:#}" + ); + if verb == "rollback" { + assert_eq!(env["hosted"]["reverted"], json!([PURL]), "{env:#}"); + assert_eq!(env["hosted"]["failed"], json!([]), "{env:#}"); + } else { + assert!(env["error"].is_null(), "{env:#}"); + } + assert_only_left_pad_unwound(root, &pristine); + } +} + +// ───────────────────────────────────────────────────────────────────── +// 2. vendor --dry-run over the live hosted redirect, then the wet vendor +// ───────────────────────────────────────────────────────────────────── + +#[tokio::test(flavor = "multi_thread")] +async fn bun_vendor_dry_run_previews_the_takeover_then_wet_vendor_completes_it() { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_bun_project(root, &pristine_lock(), &[(NAME, VERSION)]); + + let (code, env) = scan_mode(root, &server.uri(), "hosted", &[]); + assert_eq!(code, 0, "{env:#}"); + let hosted_lock = std::fs::read(root.join("bun.lock")).unwrap(); + let ledger_path = root.join(".socket/vendor/redirect-state.json"); + let hosted_ledger = std::fs::read(&ledger_path).unwrap(); + + // The manifest record `vendor` acts on (offline: the staged blob). + seed_manifest_and_blob(root); + + // Dry run: the takeover is PROBED (write-free) and previewed; the + // backend preview does not run against the still-hosted lock, so no + // false `vendor_lock_entry_not_found` and no refusal. Nothing written. + let (code, env) = vendor_cli(root, &["--dry-run"]); + assert_eq!(code, 0, "vendor --dry-run must succeed: {env:#}"); + let advisory = find_event(&env, "skipped", Some("vendor_would_revert_redirect")); + assert_eq!(advisory["purl"], PURL, "{env:#}"); + assert_no_event_code(&env, "vendor_lock_entry_not_found"); + assert_no_event_code(&env, "redirect_revert_failed"); + assert_eq!( + env["summary"]["failed"], 0, + "the preview must not report a failure: {env:#}" + ); + assert_eq!( + std::fs::read(root.join("bun.lock")).unwrap(), + hosted_lock, + "a dry run must not touch bun.lock" + ); + assert_eq!( + std::fs::read(&ledger_path).unwrap(), + hosted_ledger, + "a dry run must not touch the redirect ledger" + ); + assert!( + !root.join(".socket/vendor/state.json").exists(), + "a dry run must not create the vendor ledger" + ); + + // Wet `vendor`: the takeover the preview promised. + let (code, env) = vendor_cli(root, &[]); + assert_eq!(code, 0, "vendor must succeed: {env:#}"); + assert_eq!(env["summary"]["applied"], 1, "{env:#}"); + find_event(&env, "skipped", Some("vendor_takeover_reverted_redirect")); + find_event(&env, "applied", None); + assert_pure_vendored(root); +} + +// ───────────────────────────────────────────────────────────────────── +// 3./4. scoped rollback / remove of ONE of two hosted bun records +// ───────────────────────────────────────────────────────────────────── + +/// A hosted-live bun project with TWO redirect records, written exactly as +/// the hosted flow leaves them (ledger edits = verbatim lines, lock = the +/// URL 3-tuples). Two records make a scoped unwind of one purl ineligible +/// for the whole-ledger replay, so it takes the per-purl revert — the path +/// that used to refuse for bun. +fn write_two_record_hosted_project(root: &Path) -> String { + let pristine = pristine_lock_two(); + write_bun_project(root, &pristine, &[(NAME, VERSION), (OTHER_NAME, "1.0.0")]); + let left_pad_hosted = hosted_line(NAME, NAME, HOSTED_URL, PATCHED_SHA512); + let other_hosted = hosted_line( + OTHER_NAME, + OTHER_NAME, + OTHER_HOSTED_URL, + "sha512-otherPATCHED==", + ); + let hosted = pristine + .replace(LEFT_PAD_REGISTRY_LINE, &left_pad_hosted) + .replace(OTHER_REGISTRY_LINE, &other_hosted); + assert_ne!(hosted, pristine); + std::fs::write(root.join("bun.lock"), &hosted).unwrap(); + let ledger = json!({ + "version": 1, + "mode": "hosted", + "edits": [ + { + "path": "bun.lock", + "kind": "redirect_bun_lock_package", + "action": "rewritten", + "key": NAME, + "original": LEFT_PAD_REGISTRY_LINE, + "new": left_pad_hosted, + }, + { + "path": "bun.lock", + "kind": "redirect_bun_lock_package", + "action": "rewritten", + "key": OTHER_NAME, + "original": OTHER_REGISTRY_LINE, + "new": other_hosted, + } + ], + "records": { + PURL: patch_record(UUID), + OTHER_PURL: patch_record(OTHER_UUID), + } + }); + std::fs::create_dir_all(root.join(".socket/vendor")).unwrap(); + std::fs::write( + root.join(".socket/vendor/redirect-state.json"), + serde_json::to_vec_pretty(&ledger).unwrap(), + ) + .unwrap(); + pristine +} + +/// After unwinding ONLY `left-pad`: its line is the registry tuple, `other` +/// is still hosted, and the ledger keeps exactly `other`'s record + edit. +fn assert_only_left_pad_unwound(root: &Path, pristine: &str) { + let lock = read(root, "bun.lock"); + assert_eq!( + lock_line(&lock, NAME), + lock_line(pristine, NAME), + "left-pad back to its registry tuple:\n{lock}" + ); + assert!( + lock_line(&lock, OTHER_NAME).contains(OTHER_HOSTED_URL), + "other must stay hosted:\n{lock}" + ); + let ledger: Value = + serde_json::from_str(&read(root, ".socket/vendor/redirect-state.json")).unwrap(); + assert!( + ledger["records"].get(PURL).is_none() && ledger["records"].get(OTHER_PURL).is_some(), + "{ledger:#}" + ); + let edits = ledger["edits"].as_array().unwrap(); + assert_eq!(edits.len(), 1, "{ledger:#}"); + assert_eq!(edits[0]["key"], OTHER_NAME, "{ledger:#}"); +} + +#[test] +fn bun_scoped_rollback_of_one_of_two_hosted_records_unwinds_only_that_purl() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let pristine = write_two_record_hosted_project(root); + + // Scoped rollback: per-purl path (two records ⇒ the replay is not + // eligible). Used to exit 1 with hosted.failed = ["cannot replay yet"]. + let (code, env) = run_json( + root, + &[ + "rollback", + PURL, + "--yes", + "--json", + "--cwd", + root.to_str().unwrap(), + ], + ); + assert_eq!(code, 0, "scoped rollback must succeed: {env:#}"); + assert_eq!(env["status"], "success", "{env:#}"); + assert_eq!(env["hosted"]["reverted"], json!([PURL]), "{env:#}"); + assert_eq!(env["hosted"]["failed"], json!([]), "{env:#}"); + assert_only_left_pad_unwound(root, &pristine); + + // The last record out: covers every record ⇒ whole-ledger replay; + // pristine lock, ledger deleted. + let (code, env) = run_json( + root, + &[ + "rollback", + OTHER_PURL, + "--yes", + "--json", + "--cwd", + root.to_str().unwrap(), + ], + ); + assert_eq!(code, 0, "{env:#}"); + assert_eq!(read(root, "bun.lock"), pristine, "pristine lock restored"); + assert!( + !root.join(".socket/vendor/redirect-state.json").exists(), + "emptied ledger deleted" + ); +} + +#[test] +fn bun_scoped_remove_of_one_of_two_hosted_records_unwinds_only_that_purl() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let pristine = write_two_record_hosted_project(root); + + // `remove ` takes the same per-purl hosted leg; used to exit 1 + // with `hosted_revert_failed`. + let (code, env) = run_json( + root, + &[ + "remove", + PURL, + "--yes", + "--json", + "--cwd", + root.to_str().unwrap(), + ], + ); + assert_eq!(code, 0, "scoped remove must succeed: {env:#}"); + assert!( + env["error"].is_null(), + "no top-level error expected: {env:#}" + ); + assert_only_left_pad_unwound(root, &pristine); +} + +// ───────────────────────────────────────────────────────────────────── +// 5. hosted-wired pre-v2 WORKSPACE lock: `vendor` refuses BEFORE un-hosting +// ───────────────────────────────────────────────────────────────────── +// Hosted mode accepts a lockfileVersion-1 workspace lock (a URL tuple has +// no path to resolve); the vendored backend refuses every pre-v2 workspace +// lock (`vendor_bun_workspace_unsupported`). The plain `vendor` command +// used to run the takeover FIRST — revert the hosted line, persist the +// redirect-ledger drop — and only then hear the engine's refusal, leaving +// the project unpatched in BOTH modes while the refusal's remedy pointed +// at the hosted mode it had just destroyed; its dry run promised the +// takeover (`vendor_would_revert_redirect`, status success) outright. The +// Bun preflight now runs inside the engine loop before the takeover block. + +const WS_CODE: &str = "vendor_bun_workspace_unsupported"; + +#[tokio::test] +async fn bun_hosted_refusal_preserves_vendored_v0_workspace() { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let direct = pristine_lock() + .replace("\"lockfileVersion\": 2", "\"lockfileVersion\": 0") + .replace(" \"configVersion\": 1,\n", ""); + write_bun_project(root, &direct, &[(NAME, VERSION)]); + seed_manifest_and_blob(root); + let (code, env) = vendor_cli(root, &["--vendor-source", "build"]); + assert_eq!(code, 0, "{env:#}"); + + // Bun 1.1.45 preserves the local tuple when a direct project grows an + // unrelated workspace. Its old lock remains consumable and patched. + let lock = read(root, "bun.lock").replace( + " \"packages\": {\n", + " \"packages\": {\n \"consumer\": [\"consumer@workspace:packages/consumer\", {}],\n\n", + ); + std::fs::write(root.join("bun.lock"), &lock).unwrap(); + let state = std::fs::read(root.join(".socket/vendor/state.json")).unwrap(); + let artifact = std::fs::read(root.join(vendored_rel_tgz())).unwrap(); + let manifest = std::fs::read(root.join(".socket/manifest.json")).unwrap(); + + for extra in [&["--dry-run"][..], &[][..]] { + let (code, env) = scan_mode(root, &server.uri(), "hosted", extra); + assert_eq!(code, 0, "{env:#}"); + assert_eq!(env["redirect"]["redirected"], 0, "{env:#}"); + let warnings = env["redirect"]["warnings"].as_array().unwrap(); + assert!( + warnings + .iter() + .any(|w| w["code"] == "redirect_bun_workspace_unsupported"), + "{env:#}" + ); + assert!( + warnings + .iter() + .all(|w| w["code"] != "redirect_would_revert_vendored" + && w["code"] != "redirect_takeover_reverted_vendored"), + "{env:#}" + ); + assert_eq!(read(root, "bun.lock"), lock); + assert_eq!( + std::fs::read(root.join(".socket/vendor/state.json")).unwrap(), + state + ); + assert_eq!( + std::fs::read(root.join(vendored_rel_tgz())).unwrap(), + artifact + ); + assert_eq!( + std::fs::read(root.join(".socket/manifest.json")).unwrap(), + manifest + ); + assert!(!root.join(".socket/vendor/redirect-state.json").exists()); + } +} + +#[test] +fn bun_vendor_silent_refusal_keeps_error_diagnosis() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let lock = write_hosted_workspace_project(root, 1); + let ledger = std::fs::read(root.join(".socket/vendor/redirect-state.json")).unwrap(); + for dry_run in [true, false] { + let mut args = vec![ + "vendor", + "--offline", + "--silent", + "--cwd", + root.to_str().unwrap(), + ]; + if dry_run { + args.push("--dry-run"); + } + let (code, stdout, stderr) = run_cli(root, &args); + assert_eq!(code, 1); + assert!(stdout.is_empty(), "{stdout}"); + assert!( + stderr.contains("Cannot vendor") && stderr.contains("lockfileVersion-1"), + "{stderr}" + ); + assert_hosted_wiring_intact(root, &lock, &ledger); + } +} + +/// Pristine `lockfileVersion` workspace lock — root + a `packages/consumer` +/// member declaring left-pad — in the real bun 1.3.14 (v1) / 1.4.2 (v2) +/// grammar: `configVersion`, the member's 1-tuple `workspace:` entry, a +/// blank line between entries, trailing commas. +fn pristine_workspace_lock(version: u64) -> String { + format!( + "{{\n \"lockfileVersion\": {version},\n \"configVersion\": 1,\n \"workspaces\": {{\n \"\": {{\n \"name\": \"bun-takeover-fixture\",\n \"dependencies\": {{\n \"consumer\": \"workspace:*\",\n }},\n }},\n \"packages/consumer\": {{\n \"name\": \"consumer\",\n \"version\": \"1.0.0\",\n \"dependencies\": {{\n \"left-pad\": \"1.3.0\",\n }},\n }},\n }},\n \"packages\": {{\n \"consumer\": [\"consumer@workspace:packages/consumer\"],\n\n{LEFT_PAD_REGISTRY_LINE}\n }}\n}}\n" + ) +} + +/// A hosted-live WORKSPACE bun project with ONE redirect record, written +/// exactly as `scan --mode hosted` leaves it, plus the manifest record and +/// blob a default-mode `get`/`scan` adds — the shape the plain `vendor` +/// command acts on (a hosted-only project is a `noManifest` no-op). +/// Returns the hosted lock text. +fn write_hosted_workspace_project(root: &Path, version: u64) -> String { + let pristine = pristine_workspace_lock(version); + write_bun_project(root, &pristine, &[(NAME, VERSION)]); + std::fs::write( + root.join("package.json"), + r#"{"name":"bun-takeover-fixture","version":"1.0.0","private":true,"workspaces":["packages/*"],"dependencies":{"consumer":"workspace:*"}}"#, + ) + .unwrap(); + let consumer = root.join("packages/consumer"); + std::fs::create_dir_all(&consumer).unwrap(); + std::fs::write( + consumer.join("package.json"), + r#"{"name":"consumer","version":"1.0.0","dependencies":{"left-pad":"1.3.0"}}"#, + ) + .unwrap(); + let left_pad_hosted = hosted_line(NAME, NAME, HOSTED_URL, PATCHED_SHA512); + let hosted = pristine.replace(LEFT_PAD_REGISTRY_LINE, &left_pad_hosted); + assert_ne!(hosted, pristine, "the hosted splice must hit"); + std::fs::write(root.join("bun.lock"), &hosted).unwrap(); + let ledger = json!({ + "version": 1, + "mode": "hosted", + "edits": [{ + "path": "bun.lock", + "kind": "redirect_bun_lock_package", + "action": "rewritten", + "key": NAME, + "original": LEFT_PAD_REGISTRY_LINE, + "new": left_pad_hosted, + }], + "records": { PURL: patch_record(UUID) }, + }); + std::fs::create_dir_all(root.join(".socket/vendor")).unwrap(); + std::fs::write( + root.join(".socket/vendor/redirect-state.json"), + serde_json::to_vec_pretty(&ledger).unwrap(), + ) + .unwrap(); + seed_manifest_and_blob(root); + hosted +} + +/// Every byte of the hosted wiring must survive a refused run: the lock, +/// the redirect ledger (record + edit), and no vendor ledger or artifact. +fn assert_hosted_wiring_intact(root: &Path, hosted_lock: &str, hosted_ledger: &[u8]) { + assert_eq!( + read(root, "bun.lock"), + hosted_lock, + "bun.lock must stay byte-identical to the hosted lock" + ); + let ledger_path = root.join(".socket/vendor/redirect-state.json"); + assert_eq!( + std::fs::read(&ledger_path).unwrap(), + hosted_ledger, + "the redirect ledger must stay byte-identical" + ); + let ledger: Value = + serde_json::from_str(&read(root, ".socket/vendor/redirect-state.json")).unwrap(); + assert!(ledger["records"].get(PURL).is_some(), "{ledger:#}"); + let edits = ledger["edits"].as_array().unwrap(); + assert_eq!(edits.len(), 1, "{ledger:#}"); + assert_eq!(edits[0]["key"], NAME, "{ledger:#}"); + assert!( + !root.join(".socket/vendor/state.json").exists(), + "a refused run must not create the vendor ledger" + ); + assert!( + !root.join(".socket/vendor/npm").exists(), + "a refused run must not stage or pack an artifact" + ); +} + +#[test] +fn bun_vendor_over_hosted_v1_workspace_lock_refuses_before_unhosting() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let hosted_lock = write_hosted_workspace_project(root, 1); + let hosted_ledger = std::fs::read(root.join(".socket/vendor/redirect-state.json")).unwrap(); + + // Dry run: previews the REFUSAL, not the takeover, with the wet run's + // exit code — and writes nothing. + let (code, env) = vendor_cli(root, &["--dry-run"]); + assert_eq!( + code, 1, + "the preview must exit like the wet run it predicts: {env:#}" + ); + assert_eq!(env["status"], "partialFailure", "{env:#}"); + assert_eq!(env["dryRun"], true, "{env:#}"); + let failed = find_event(&env, "failed", Some(WS_CODE)); + assert_eq!(failed["purl"], PURL, "{env:#}"); + assert_eq!(env["summary"]["failed"], 1, "{env:#}"); + assert_no_event_code(&env, "vendor_would_revert_redirect"); + assert_no_event_code(&env, "vendor_takeover_reverted_redirect"); + assert_no_event_code(&env, "redirect_revert_failed"); + assert_hosted_wiring_intact(root, &hosted_lock, &hosted_ledger); + + // Wet run: the same refusal, BEFORE any revert — hosted wiring intact. + // Used to: `skipped vendor_takeover_reverted_redirect` then `failed + // vendor_bun_workspace_unsupported`, registry tuple back in the lock, + // redirect-state.json deleted, `.socket/vendor/` empty. + let (code, env) = vendor_cli(root, &[]); + assert_eq!(code, 1, "the wet run refuses: {env:#}"); + assert_eq!(env["status"], "partialFailure", "{env:#}"); + let failed = find_event(&env, "failed", Some(WS_CODE)); + assert_eq!(failed["purl"], PURL, "{env:#}"); + assert!( + failed["error"] + .as_str() + .is_some_and(|d| d.contains("lockfileVersion-1 lock") && d.contains("--mode hosted")), + "the detail names the version and the hosted alternative this run left in place: {env:#}" + ); + assert_eq!(env["summary"]["applied"], 0, "{env:#}"); + assert_eq!(env["summary"]["failed"], 1, "{env:#}"); + assert_no_event_code(&env, "vendor_takeover_reverted_redirect"); + assert_no_event_code(&env, "vendor_would_revert_redirect"); + assert_no_event_code(&env, "redirect_revert_failed"); + assert_hosted_wiring_intact(root, &hosted_lock, &hosted_ledger); + + // The manifest record survives too (the recovery path — a networked + // `scan --mode hosted` — needs nothing this run could have dropped). + let manifest: Value = serde_json::from_str(&read(root, ".socket/manifest.json")).unwrap(); + assert_eq!(manifest["patches"][PURL]["uuid"], UUID, "{manifest:#}"); +} + +/// The lockfileVersion-2 twin: hosted mode and the vendored backend both +/// accept it, so the takeover still completes — the preflight must not +/// over-refuse the supported workspace shape. +#[test] +fn bun_vendor_over_hosted_v2_workspace_lock_still_takes_over() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_hosted_workspace_project(root, 2); + + let (code, env) = vendor_cli(root, &["--dry-run"]); + assert_eq!(code, 0, "{env:#}"); + find_event(&env, "skipped", Some("vendor_would_revert_redirect")); + assert_no_event_code(&env, WS_CODE); + + let (code, env) = vendor_cli(root, &[]); + assert_eq!(code, 0, "the v2 takeover must succeed: {env:#}"); + assert_eq!(env["summary"]["applied"], 1, "{env:#}"); + find_event(&env, "skipped", Some("vendor_takeover_reverted_redirect")); + find_event(&env, "applied", None); + assert_no_event_code(&env, WS_CODE); + assert_pure_vendored(root); + let lock = read(root, "bun.lock"); + assert!( + lock.contains(" \"consumer\": [\"consumer@workspace:packages/consumer\"],\n"), + "the workspace entry survives the takeover:\n{lock}" + ); + assert!(lock.starts_with("{\n \"lockfileVersion\": 2,\n"), "{lock}"); +} diff --git a/crates/socket-patch-cli/tests/mode_migration_bun.rs b/crates/socket-patch-cli/tests/mode_migration_bun.rs new file mode 100644 index 00000000..e2730ae8 --- /dev/null +++ b/crates/socket-patch-cli/tests/mode_migration_bun.rs @@ -0,0 +1,1793 @@ +//! Real-bun mode-migration e2e: hosted ⇄ vendored takeovers on a bun +//! (text `bun.lock`) project must leave the project FULLY in the new mode, +//! preview exactly what the wet run does, and unwind — scoped or whole — +//! back to the pristine registry lock. +//! +//! Twin of `mode_migration_npm.rs` (yarn classic + berry) and +//! `mode_migration_cargo.rs` for the one npm-family lock flavor whose +//! hosted rewrite REPLACES the entry's `name@version` spec: the registry +//! 4-tuple `["left-pad@1.3.0", "", {}, "sha512-…"]` becomes the URL 3-tuple +//! `["left-pad@https://…/left-pad-1.3.0.tgz", {}, "sha512-…"]`, so the +//! per-purl revert cannot key the edit by `name@version` the way the +//! yarn/pnpm/package-lock reverts do. Until the per-purl bun claim landed, +//! `vendor` / `scan --mode vendored` over a live hosted bun redirect was a +//! hard `redirect_revert_failed` refusal (with a circular remedy), a +//! scoped `rollback ` / `remove ` on a project holding two +//! hosted records failed the same way, and `vendor --dry-run` promised a +//! takeover the wet run refused. The hermetic (no-bun) contract twin is +//! `in_process_vendor_bun_takeover.rs`; THIS suite proves the same +//! contract against REAL bun, ending every terminal state with the proof +//! that matters — a fresh checkout's `bun install --frozen-lockfile` from +//! an EMPTY cache materializes the bytes the lock claims. +//! +//! Fixture: `package.json` with two real registry deps, `left-pad@1.3.0` +//! (the patched target) and `is-number@7.0.0` (dependency-free; the +//! untouched bystander in the single-patch legs, the second hosted record +//! in the scoped-unwind leg), installed by REAL `bun install +//! --ignore-scripts` (network for the fixture install only; a private +//! `BUN_INSTALL` + `BUN_INSTALL_CACHE_DIR` per project). The text lock is +//! the default from bun 1.2.0 (lockfileVersion 1; 2 from 1.4.0); on +//! 1.1.39–1.1.x the fixture passes the `--save-text-lockfile` opt-in +//! (lockfileVersion 0). The version bun wrote is ASSERTED against that +//! era table, so a lock-era CI leg proves the era it claims. Registry +//! 4-tuples are one emitted grammar across 0/1/2, so everything after the +//! fixture guard is version-independent. Patched tarballs are built from +//! the installed bytes (marker comment prepended to `index.js`) exactly +//! like the yarn twin; the patch API is wiremock (the bun rewriter needs +//! `artifacts[kind=tarball].integrity.sha512` from the grant, and the +//! `view/{uuid}` route carries `blobContent` so `scan --mode vendored` can +//! stage the patched content). +//! +//! Scenarios: +//! 1. vendored → hosted (`vendor --offline`, then `scan --mode hosted`): +//! `redirect_takeover_reverted_vendored`, vendored ledger entry + +//! committed artifact gone, bun.lock = the hosted URL 3-tuple with no +//! `.socket/vendor/` residue, the redirect ledger's `original` is the +//! PRISTINE registry line (originals chain intact across migrations), +//! fresh frozen install → marker bytes; `rollback` → pristine bytes, +//! no vendor artifacts or ledgers, fresh install → original bytes. +//! 2. hosted → vendored, BOTH drivers on copies of one hosted project: +//! `vendor --offline` (staged manifest) and `scan --mode vendored`: +//! `vendor_takeover_reverted_redirect`, redirect ledger record + edit +//! dropped (file removed when emptied), bun.lock carries the local +//! `.socket/vendor/npm//` 3-tuple and not the hosted URL, the +//! vendor ledger's `original` is the pristine registry line, fresh +//! frozen install → marker bytes; `vendor --revert` → pristine bytes. +//! 3. dry-run parity: over a live hosted redirect `vendor --dry-run` +//! previews the takeover (`vendor_would_revert_redirect`, no false +//! `vendor_lock_entry_not_found`, no refusal) and `scan --mode +//! vendored --dry-run` classifies `would_vendor` (never `would_refuse`); +//! over a live vendored state `scan --mode hosted --dry-run` previews +//! `redirect_would_revert_vendored`; none of the previews writes a +//! byte (bun.lock, both ledgers, every file under `.socket/`), and the +//! wet runs then land exactly the takeovers previewed. +//! 4. two hosted records in ONE scan; scoped `rollback ` and, on +//! a fresh copy, `remove ` (per-purl path — the whole-ledger +//! replay is not eligible) unwind ONLY a's line/record/edit; a fresh +//! frozen install lands a's ORIGINAL bytes and b's MARKER bytes; the +//! unscoped `rollback` that follows restores the pristine lock. +//! 5. unscoped `rollback` from each mixed state — after (1) and after +//! (2) — restores bun.lock byte-exactly, leaves no `.socket/vendor/` +//! artifacts or ledgers, and a fresh install reproduces the original +//! bytes. +//! +//! Gates (identical to `e2e_redirect_bun_build.rs` / `e2e_vendor_bun_build.rs`): +//! without `SOCKET_PATCH_BUN_E2E_REQUIRED` (set AND non-empty — CI passes +//! an empty string for non-bun legs) a missing `bun`, a failed fixture +//! install or a bun without a text lockfile is a `println` SKIP and every +//! assertion after that is HARD. With it, those skips become hard +//! failures, and `SOCKET_PATCH_BUN_E2E_VERSION` (when set, non-empty) must +//! equal `bun --version`, so a CI leg cannot pass by running the wrong bun +//! or no bun at all. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; + +use serde_json::{json, Value}; +use sha2::{Digest, Sha512}; +use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; +use wiremock::matchers::{method, path, path_regex}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +#[path = "common/cache_env.rs"] +mod cache_env; + +const SUITE: &str = "mode_migration_bun"; +const ORG: &str = "test-org"; +const TOKEN: &str = "33333333-3333-4333-8333-333333333333"; +const MARKER: &str = "/* SOCKET-PATCHED */\n"; +const GHSA: &str = "GHSA-migr-bun-test"; + +/// A fixture dependency and the two patch identities the legs use for it. +struct Dep { + name: &'static str, + version: &'static str, + purl: &'static str, + /// Vendored patch uuid: the manifest record `vendor --offline` acts on + /// (the `.socket/vendor/npm//` path level of a manifest-driven + /// vendor). + uuid_v: &'static str, + /// Hosted patch uuid: what the API mocks discover and grant (embedded in + /// the hosted artifact URL), and therefore also the uuid a + /// `scan --mode vendored` download records and vendors under. + uuid_h: &'static str, +} + +/// The patched target of every leg. +const DEP_A: Dep = Dep { + name: "left-pad", + version: "1.3.0", + purl: "pkg:npm/left-pad@1.3.0", + uuid_v: "3c4d5e6f-7a8b-4c1d-8e2f-0123456789ab", + uuid_h: "8d9e0f1a-2b3c-4d4e-8f5a-6b7c8d9e0f1a", +}; +/// The bystander (single-patch legs) / second hosted record (scoped leg). +const DEP_B: Dep = Dep { + name: "is-number", + version: "7.0.0", + purl: "pkg:npm/is-number@7.0.0", + uuid_v: "4d5e6f7a-8b9c-4d2e-9f3a-123456789abc", + uuid_h: "9e0f1a2b-3c4d-4e5f-9a6b-7c8d9e0f1a2b", +}; + +impl Dep { + /// `-.tgz` — the artifact leaf on the hosted URL and + /// under the vendored dir. + fn tgz_leaf(&self) -> String { + format!("{}-{}.tgz", self.name, self.version) + } + /// The hosted tarball path on the mock patch server. + fn hosted_path(&self) -> String { + format!( + "/patch/npm/{}/{}/{TOKEN}/{}/{}", + self.name, + self.version, + self.uuid_h, + self.tgz_leaf() + ) + } + fn hosted_url(&self, server_uri: &str) -> String { + format!("{server_uri}{}", self.hosted_path()) + } + /// The lock-relative local tarball path the vendored rewrite writes. + fn vendored_rel(&self, uuid: &str) -> String { + format!(".socket/vendor/npm/{uuid}/{}", self.tgz_leaf()) + } + fn installed_dir(&self, proj: &Path) -> PathBuf { + proj.join("node_modules").join(self.name) + } + /// The URL 3-tuple line the hosted rewriter writes for this dep (bun's + /// `{}` meta for a dependency-free package; 4-space indent, trailing + /// comma — the packages-entry grammar on lockfileVersion 0/1/2). + fn hosted_line(&self, hosted_url: &str, sri: &str) -> String { + format!( + " \"{}\": [\"{}@{hosted_url}\", {{}}, \"{sri}\"],", + self.name, self.name + ) + } +} + +/// `(major, minor, patch)` of the bun on PATH. +type BunVersion = (u64, u64, u64); + +/// First bun with a text lockfile (`--save-text-lockfile` opt-in, +/// lockfileVersion 0). Older bun writes only the binary `bun.lockb`. +const TEXT_LOCK_FROM: BunVersion = (1, 1, 39); +/// Text lock becomes the default and bumps to lockfileVersion 1. +const LOCK_V1_FROM: BunVersion = (1, 2, 0); +/// lockfileVersion 2. +const LOCK_V2_FROM: BunVersion = (1, 4, 0); + +// ── toolchain gate (shared semantics with the two bun capstones) ────────── + +fn binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_socket-patch")) +} + +/// The REQUIRED gate: set AND non-empty. CI's e2e matrix passes +/// `SOCKET_PATCH_BUN_E2E_REQUIRED: ${{ matrix.bun != '' && '1' || '' }}`, +/// so an empty value is the non-bun legs' "unset" — an `is_some()` gate +/// would turn every non-bun leg red. +fn bun_required() -> bool { + std::env::var_os("SOCKET_PATCH_BUN_E2E_REQUIRED").is_some_and(|v| !v.is_empty()) +} + +/// The exact bun the matrix leg pinned, when it pinned one. +fn pinned_bun_version() -> Option { + std::env::var("SOCKET_PATCH_BUN_E2E_VERSION") + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) +} + +/// `1.4.2` → `(1, 4, 2)`; a canary suffix (`1.4.3-canary.12+abc`) is cut at +/// the first `-`/`+`. `None` for anything that is not three integers. +fn parse_bun_version(raw: &str) -> Option { + let core = raw.trim().split(['-', '+']).next()?; + let mut parts = core.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next()?.parse().ok()?; + let patch = parts.next()?.parse().ok()?; + if parts.next().is_some() { + return None; + } + Some((major, minor, patch)) +} + +/// `bun --version` through the cache sandbox: `Some(trimmed stdout)` when +/// bun ran and exited 0, `None` when it is not on PATH (or cannot start). +fn bun_version_output() -> Option { + let mut probe = Command::new("bun"); + probe.arg("--version"); + scrub_env(&mut probe); + cache_env::isolate(&mut probe); + let out = probe.stderr(Stdio::null()).output().ok()?; + out.status + .success() + .then(|| String::from_utf8_lossy(&out.stdout).trim().to_string()) +} + +/// The lockfileVersion the era table says this bun writes for a FRESH +/// install: 1.1.39–1.1.x opt-in text lock → 0, 1.2–1.3 → 1, ≥ 1.4 → 2. +fn expected_lock_version(v: BunVersion) -> u64 { + if v >= LOCK_V2_FROM { + 2 + } else if v >= LOCK_V1_FROM { + 1 + } else { + 0 + } +} + +/// The fixture's `bun install` argv: lifecycle scripts never run (hygiene — +/// neither dep has any, but the fixture is a REAL registry install), and +/// `--save-text-lockfile` is passed only where the text lock is still an +/// opt-in (< 1.2.0), so newer bun is exercised exactly as users run it. +fn fixture_install_args(v: BunVersion) -> Vec<&'static str> { + let mut args = vec!["install", "--ignore-scripts"]; + if v < LOCK_V1_FROM { + args.push("--save-text-lockfile"); + } + args +} + +/// `"lockfileVersion": ` from the lock head — the same head scan as +/// `socket_patch_core::vendor::bun_lock_text::lock_version` (pub(crate) +/// there, so mirrored here). +fn lock_version(text: &str) -> Option { + text.lines() + .take(5) + .find_map(|line| line.trim().strip_prefix("\"lockfileVersion\":")) + .and_then(|rest| rest.trim().trim_end_matches(',').parse().ok()) +} + +/// The toolchain preflight every leg runs first: bun present, pinned +/// version honored, text lockfile available. `None` = this leg is skipped +/// (already reported with a println) — but under the REQUIRED gate every +/// one of those is a hard failure instead, because a CI leg that silently +/// skips is exactly the vacuous pass the bun suites had for months. +fn bun_toolchain(tag: &str) -> Option<(String, BunVersion)> { + let Some(raw) = bun_version_output() else { + assert!( + !bun_required(), + "SOCKET_PATCH_BUN_E2E_REQUIRED is set but `bun --version` did not run — \ + the matrix leg must install bun before running this suite" + ); + println!("SKIP {SUITE} ({tag}): `bun` not installed"); + return None; + }; + if let Some(pin) = pinned_bun_version() { + assert_eq!( + raw, pin, + "SOCKET_PATCH_BUN_E2E_VERSION pins bun {pin} but PATH resolves bun {raw}: the \ + matrix must run the pinned version" + ); + } + let Some(version) = parse_bun_version(&raw) else { + assert!( + !bun_required(), + "required bun toolchain reports an unparsable version {raw:?}" + ); + println!("SKIP {SUITE} ({tag}): unparsable `bun --version` output {raw:?}"); + return None; + }; + if version < TEXT_LOCK_FROM { + assert!( + !bun_required(), + "bun {raw} has no text lockfile (the `--save-text-lockfile` opt-in exists from \ + 1.1.39); a REQUIRED leg must not be scheduled on it" + ); + println!("SKIP {SUITE} ({tag}): bun {raw} predates the text bun.lock (1.1.39)"); + return None; + } + Some((raw, version)) +} + +// ── process helpers ──────────────────────────────────────────────────────── + +/// Remove ambient `SOCKET_*` (except the hermetic `SOCKET_NO_CONFIG`), +/// every `BUN_*` var (the harness passes bun's install/cache dirs +/// explicitly per project), `npm_config_*` (bun reads npm's registry +/// config; an ambient mirror or auth token would change what the fixture +/// install resolves against) and `VIRTUAL_ENV` — the scrub the three bun +/// suites share, so none can drift back to a `SOCKET_*`-only scrub. +fn scrub_env(cmd: &mut Command) { + cache_env::scrub_ambient_bun_env(cmd); +} + +/// Run `bun ` in `cwd` with a PRIVATE `BUN_INSTALL` + cache under +/// `bun_home` (created here; a brand-new dir per call site is what makes +/// the fresh-checkout legs' "empty cache" premise true), the shared cache +/// sandbox for everything else bun keeps outside those dirs, and the +/// ambient env scrubbed. Scrub BEFORE seeding: `Command`'s last env call +/// for a name wins. +fn bun(cwd: &Path, args: &[&str], bun_home: &Path) -> Output { + let cache = bun_home.join("cache"); + let install = bun_home.join("install"); + std::fs::create_dir_all(&cache).unwrap(); + std::fs::create_dir_all(&install).unwrap(); + let mut cmd = Command::new("bun"); + cmd.args(args).current_dir(cwd); + scrub_env(&mut cmd); + cache_env::isolate(&mut cmd); + cmd.env("BUN_INSTALL", &install) + .env("BUN_INSTALL_CACHE_DIR", &cache); + cmd.output().expect("failed to run bun") +} + +/// The real binary with `--no-telemetry` appended: nothing in this suite +/// should ever post a telemetry event, mocked API or not. +fn run_socket(cwd: &Path, args: &[&str]) -> (i32, String, String) { + let mut cmd = Command::new(binary()); + cmd.args(args).arg("--no-telemetry").current_dir(cwd); + scrub_env(&mut cmd); + let out = cmd.output().expect("failed to run socket-patch binary"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +/// Parse a `--json` envelope, or fail with the raw output attached. +fn envelope(stdout: &str, stderr: &str) -> Value { + serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!("--json output is not a JSON envelope: {e}\nstdout:\n{stdout}\nstderr:\n{stderr}") + }) +} + +fn hosted_scan(proj: &Path, api: &str, extra: &[&str]) -> (i32, String, String) { + let mut args = vec![ + "scan", + "--mode", + "hosted", + "--json", + "--yes", + "--cwd", + proj.to_str().unwrap(), + "--api-url", + api, + "--org", + ORG, + "--api-token", + "fake", + ]; + args.extend_from_slice(extra); + run_socket(proj, &args) +} + +/// `scan --mode vendored` builds the artifact locally (`--vendor-source +/// build`): no vendoring-service round trip, so the only network is the +/// wiremock patch API. +fn vendored_scan(proj: &Path, api: &str, extra: &[&str]) -> (i32, String, String) { + let mut args = vec![ + "scan", + "--mode", + "vendored", + "--json", + "--yes", + "--cwd", + proj.to_str().unwrap(), + "--api-url", + api, + "--org", + ORG, + "--api-token", + "fake", + "--vendor-source", + "build", + ]; + args.extend_from_slice(extra); + run_socket(proj, &args) +} + +/// `vendor --json --offline` (+ extra) over the staged manifest. +fn vendor_cmd(proj: &Path, extra: &[&str]) -> (i32, String, String) { + let mut args = vec![ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ]; + args.extend_from_slice(extra); + run_socket(proj, &args) +} + +/// `rollback [targets…] --yes --json`. +fn rollback_cmd(proj: &Path, targets: &[&str]) -> (i32, String, String) { + let mut args = vec!["rollback"]; + args.extend_from_slice(targets); + args.extend_from_slice(&["--yes", "--json", "--cwd", proj.to_str().unwrap()]); + run_socket(proj, &args) +} + +// ── bytes / files ────────────────────────────────────────────────────────── + +fn sri(bytes: &[u8]) -> String { + use base64::Engine as _; + format!( + "sha512-{}", + base64::engine::general_purpose::STANDARD.encode(Sha512::digest(bytes)) + ) +} + +fn b64(bytes: &[u8]) -> String { + use base64::Engine as _; + base64::engine::general_purpose::STANDARD.encode(bytes) +} + +fn read(proj: &Path, rel: &str) -> String { + std::fs::read_to_string(proj.join(rel)).unwrap_or_default() +} + +fn read_json(proj: &Path, rel: &str) -> Value { + let text = std::fs::read_to_string(proj.join(rel)) + .unwrap_or_else(|e| panic!("read {rel} under {}: {e}", proj.display())); + serde_json::from_str(&text).unwrap_or_else(|e| panic!("{rel} is not JSON: {e}\n{text}")) +} + +fn copy_dir_recursive(src: &Path, dst: &Path) { + std::fs::create_dir_all(dst).unwrap(); + for entry in std::fs::read_dir(src).unwrap() { + let entry = entry.unwrap(); + let to = dst.join(entry.file_name()); + if entry.file_type().unwrap().is_dir() { + copy_dir_recursive(&entry.path(), &to); + } else { + std::fs::copy(entry.path(), &to).unwrap(); + } + } +} + +/// Every regular file under `root` (relative `/`-joined path → bytes), +/// `node_modules` excluded — the write-free oracle for the dry-run legs. +/// `.socket/apply.lock` is excluded too: it is the apply lock's flock +/// target, created by every run that takes the lock (dry runs included, +/// since they read state a concurrent wet run could be mutating) and left +/// in place by design — a lock file, not project state. +fn snapshot(root: &Path) -> BTreeMap> { + fn walk(root: &Path, dir: &Path, out: &mut BTreeMap>) { + for entry in std::fs::read_dir(dir).unwrap() { + let p = entry.unwrap().path(); + if p.is_dir() { + if p.file_name().is_some_and(|n| n == "node_modules") { + continue; + } + walk(root, &p, out); + } else { + let rel = p + .strip_prefix(root) + .unwrap() + .components() + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .collect::>() + .join("/"); + if rel == ".socket/apply.lock" { + continue; + } + out.insert(rel, std::fs::read(&p).unwrap()); + } + } + } + let mut out = BTreeMap::new(); + walk(root, root, &mut out); + out +} + +fn assert_unchanged(before: &BTreeMap>, proj: &Path, what: &str) { + let after = snapshot(proj); + let added: Vec<&String> = after.keys().filter(|k| !before.contains_key(*k)).collect(); + let removed: Vec<&String> = before.keys().filter(|k| !after.contains_key(*k)).collect(); + let changed: Vec<&String> = before + .iter() + .filter(|(k, v)| after.get(*k).is_some_and(|a| a != *v)) + .map(|(k, _)| k) + .collect(); + assert!( + added.is_empty() && removed.is_empty() && changed.is_empty(), + "{what} must not write a byte: added {added:?}, removed {removed:?}, changed {changed:?}" + ); +} + +/// A gzipped npm tarball (`package/` prefix) built from the ACTUALLY +/// installed package with `index.js` swapped for `replaced_index`. Built +/// with the tar crate — no system `tar`, so Windows runners need nothing — +/// and file modes travel as installed (0o644/0o755 where the host has no +/// mode). +fn make_tgz_from_installed(pkg_dir: &Path, replaced_index: &[u8]) -> Vec { + let pkg_dir = pkg_dir + .canonicalize() + .expect("installed package dir must resolve"); + let mut files: Vec = Vec::new(); + let mut stack = vec![pkg_dir.clone()]; + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(&dir).unwrap() { + let p = entry.unwrap().path(); + if p.is_dir() { + stack.push(p); + } else { + files.push(p); + } + } + } + files.sort(); + let mut builder = tar::Builder::new(flate2::write::GzEncoder::new( + Vec::new(), + flate2::Compression::default(), + )); + for p in &files { + let rel = p.strip_prefix(&pkg_dir).unwrap(); + let name = rel + .components() + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .collect::>() + .join("/"); + let bytes = if rel == Path::new("index.js") { + replaced_index.to_vec() + } else { + std::fs::read(p).unwrap() + }; + let mut header = tar::Header::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(file_mode(p, &name)); + header.set_mtime(0); + header.set_cksum(); + builder + .append_data(&mut header, format!("package/{name}"), bytes.as_slice()) + .unwrap(); + } + builder.into_inner().unwrap().finish().unwrap() +} + +#[cfg(unix)] +fn file_mode(p: &Path, _name: &str) -> u32 { + use std::os::unix::fs::PermissionsExt as _; + std::fs::metadata(p).unwrap().permissions().mode() & 0o777 +} + +#[cfg(not(unix))] +fn file_mode(_p: &Path, name: &str) -> u32 { + if name.starts_with("bin/") { + 0o755 + } else { + 0o644 + } +} + +/// The `packages` line for `name` in a bun.lock (` "name": [...]`, +/// verbatim, no line terminator). +fn packages_line(lock: &str, name: &str) -> String { + let key = format!("\"{name}\": ["); + lock.lines() + .find(|l| l.trim_start().starts_with(&key)) + .unwrap_or_else(|| panic!("no packages entry for {name} in:\n{lock}")) + .to_string() +} + +/// The redirect ledger's `redirect_bun_lock_package` edit keyed by the +/// lock's package-map key (`name`), if any. +fn ledger_edit_for(ledger: &Value, name: &str) -> Option { + ledger["edits"].as_array().and_then(|edits| { + edits + .iter() + .find(|e| e["kind"] == "redirect_bun_lock_package" && e["key"] == name) + .cloned() + }) +} + +fn warning_codes(v: &Value) -> Vec { + v.as_array() + .map(|w| { + w.iter() + .filter_map(|x| x["code"].as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() +} + +fn event_codes(v: &Value) -> Vec { + v["events"] + .as_array() + .map(|evs| { + evs.iter() + .filter_map(|e| e["errorCode"].as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() +} + +// ── fixture ──────────────────────────────────────────────────────────────── + +/// The pristine and marker-patched `index.js` of one installed dep. +struct DepBytes { + orig: Vec, + patched: Vec, +} + +/// The two-dep fixture project after a REAL `bun install`, plus the +/// pristine snapshots every unwind assertion diffs against. +struct Fixture { + tmp: tempfile::TempDir, + proj: PathBuf, + bun_raw: String, + lock_version: u64, + lock_pristine: Vec, + pkg_json_pristine: Vec, + a: DepBytes, + b: DepBytes, +} + +impl Fixture { + fn lock_pristine_str(&self) -> String { + String::from_utf8(self.lock_pristine.clone()).expect("bun.lock is UTF-8") + } + /// The pristine registry 4-tuple line for `dep`, verbatim. + fn pristine_line(&self, dep: &Dep) -> String { + packages_line(&self.lock_pristine_str(), dep.name) + } + fn bytes(&self, dep: &Dep) -> &DepBytes { + if dep.name == DEP_A.name { + &self.a + } else { + &self.b + } + } + /// A fresh dir under the fixture tempdir. + fn dir(&self, name: &str) -> PathBuf { + let d = self.tmp.path().join(name); + std::fs::create_dir_all(&d).unwrap(); + d + } + /// The patched tarball for `dep`, from the fixture's installed copy. + fn patched_tgz(&self, dep: &Dep) -> Vec { + make_tgz_from_installed(&dep.installed_dir(&self.proj), &self.bytes(dep).patched) + } +} + +/// package.json + real install + era assertions. `None` = skip (already +/// reported), or a hard failure under the REQUIRED gate. +fn stage_fixture(tag: &str) -> Option { + let (bun_raw, bun_version) = bun_toolchain(tag)?; + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + let pkg_json = format!( + r#"{{"name":"mode-migration-bun","version":"0.0.0","private":true,"dependencies":{{"{}":"{}","{}":"{}"}}}}"#, + DEP_A.name, DEP_A.version, DEP_B.name, DEP_B.version + ); + std::fs::write(proj.join("package.json"), &pkg_json).unwrap(); + + let bun_home = tmp.path().join("fixture-bun-home"); + let install = bun(&proj, &fixture_install_args(bun_version), &bun_home); + if !install.status.success() { + assert!( + !bun_required(), + "required bun {bun_raw} fixture `bun install` failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&install.stdout), + String::from_utf8_lossy(&install.stderr) + ); + println!( + "SKIP {SUITE} ({tag}): fixture `bun install` failed (registry unreachable?):\n{}", + String::from_utf8_lossy(&install.stderr) + ); + return None; + } + let lock_path = proj.join("bun.lock"); + if !lock_path.is_file() { + assert!( + !bun_required(), + "required bun {bun_raw} produced no text bun.lock after {:?}", + fixture_install_args(bun_version) + ); + println!("SKIP {SUITE} ({tag}): bun produced no text bun.lock (binary lockfile?)"); + return None; + } + // Hermeticity guard: the install must have gone through the PRIVATE + // cache, or the fresh-checkout "empty cache" premise below is void. + let cache = bun_home.join("cache"); + assert!( + cache.is_dir() && std::fs::read_dir(&cache).unwrap().next().is_some(), + "fixture install did not populate the private BUN_INSTALL_CACHE_DIR at {}", + cache.display() + ); + + let lock_pristine = std::fs::read(&lock_path).unwrap(); + let lock_text = String::from_utf8(lock_pristine.clone()).expect("bun.lock is UTF-8"); + // The era table, asserted rather than assumed — pinning the mapping is + // what makes a lock-era CI leg prove the era it claims to cover. + let native_version = lock_version(&lock_text).unwrap_or_else(|| { + panic!("fixture bun.lock has no integer lockfileVersion in its head:\n{lock_text}") + }); + assert_eq!( + native_version, + expected_lock_version(bun_version), + "bun {bun_raw} wrote lockfileVersion {native_version}; the era table expects {} \ + (1.1.39–1.1.x → 0, 1.2–1.3 → 1, ≥ 1.4 → 2):\n{lock_text}", + expected_lock_version(bun_version) + ); + let mut bytes: Vec = Vec::new(); + for dep in [&DEP_A, &DEP_B] { + // Pre-migration: the registry 4-tuple with bun's default-registry + // `""` field and `{}` meta — one spelling across 0/1/2. + let head = format!("\"{}@{}\", \"\", {{}}, \"sha512-", dep.name, dep.version); + assert!( + packages_line(&lock_text, dep.name).contains(&head), + "pristine packages entry for {} must be the registry 4-tuple {head}…:\n{lock_text}", + dep.name + ); + let orig = std::fs::read(dep.installed_dir(&proj).join("index.js")) + .unwrap_or_else(|e| panic!("installed {}/index.js: {e}", dep.name)); + assert!( + !orig.starts_with(MARKER.as_bytes()), + "pristine install of {} must not carry the marker", + dep.name + ); + let patched = [MARKER.as_bytes(), orig.as_slice()].concat(); + bytes.push(DepBytes { orig, patched }); + } + let b = bytes.pop().unwrap(); + let a = bytes.pop().unwrap(); + eprintln!("FIXTURE OK (bun {bun_raw}, lockfileVersion {native_version}, {tag})"); + Some(Fixture { + tmp, + proj, + bun_raw, + lock_version: native_version, + lock_pristine, + pkg_json_pristine: pkg_json.into_bytes(), + a, + b, + }) +} + +/// Copy a WORKING project (committable files + the installed tree) so two +/// drivers can start from one identical state without a second registry +/// install. +fn copy_project(from: &Path, to: &Path) { + std::fs::create_dir_all(to).unwrap(); + std::fs::copy(from.join("package.json"), to.join("package.json")).unwrap(); + std::fs::copy(from.join("bun.lock"), to.join("bun.lock")).unwrap(); + if from.join(".socket").is_dir() { + copy_dir_recursive(&from.join(".socket"), &to.join(".socket")); + } + copy_dir_recursive(&from.join("node_modules"), &to.join("node_modules")); +} + +/// ONLY the committable files (package.json, bun.lock, `.socket/` when it +/// exists — rollback removes it) into a fresh dir: the fresh-checkout proof. +fn fresh_checkout(from: &Path, to: &Path) { + std::fs::create_dir_all(to).unwrap(); + std::fs::copy(from.join("package.json"), to.join("package.json")).unwrap(); + std::fs::copy(from.join("bun.lock"), to.join("bun.lock")).unwrap(); + if from.join(".socket").is_dir() { + copy_dir_recursive(&from.join(".socket"), &to.join(".socket")); + } +} + +/// Fresh checkout of `proj` named `name` + `bun install --frozen-lockfile +/// --ignore-scripts` against a brand-new (EMPTY) bun home; asserts success +/// and returns the checkout dir so callers probe the installed bytes. +fn fresh_frozen_install(fx: &Fixture, proj: &Path, name: &str) -> PathBuf { + let fresh = fx.tmp.path().join(name); + fresh_checkout(proj, &fresh); + let ci = bun( + &fresh, + &["install", "--frozen-lockfile", "--ignore-scripts"], + &fx.tmp.path().join(format!("{name}-bun-home")), + ); + assert!( + ci.status.success(), + "fresh-checkout `bun install --frozen-lockfile` ({name}) must succeed.\nbun.lock:\n{}\n\ + stdout:\n{}\nstderr:\n{}", + read(&fresh, "bun.lock"), + String::from_utf8_lossy(&ci.stdout), + String::from_utf8_lossy(&ci.stderr), + ); + fresh +} + +/// The installed `index.js` of `dep` in `root` must be exactly `expected` +/// (the marker-patched or the pristine bytes). +fn assert_installed(root: &Path, dep: &Dep, expected: &[u8], what: &str) { + let installed = std::fs::read(dep.installed_dir(root).join("index.js")) + .unwrap_or_else(|e| panic!("{what}: installed {}/index.js: {e}", dep.name)); + assert_eq!( + installed, + expected, + "{what}: {} must install the {} bytes; got:\n{}", + dep.name, + if expected.starts_with(MARKER.as_bytes()) { + "PATCHED (marker)" + } else { + "ORIGINAL" + }, + String::from_utf8_lossy(&installed[..installed.len().min(120)]) + ); +} + +/// Write `.socket/manifest.json` + the after-hash blob for `dep` at +/// `uuid_v` so `vendor --offline` runs fully offline (npm-family file keys +/// carry the `package/` prefix). Hosted mode writes no manifest — its +/// ledger is its store — so this is the yarn twin's `stage_patch`. +fn stage_manifest(fx: &Fixture, proj: &Path, dep: &Dep) { + let bytes = fx.bytes(dep); + let socket = proj.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + let manifest = json!({ + "patches": { dep.purl: { + "uuid": dep.uuid_v, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { "package/index.js": { + "beforeHash": compute_git_sha256_from_bytes(&bytes.orig), + "afterHash": compute_git_sha256_from_bytes(&bytes.patched), + }}, + "vulnerabilities": { GHSA: { + "cves": ["CVE-2026-99999"], + "summary": "migration vuln", "severity": "high", "description": "d", + }}, + "description": "migration patch", "license": "MIT", "tier": "free", + }} + }); + std::fs::write( + socket.join("manifest.json"), + serde_json::to_string_pretty(&manifest).unwrap(), + ) + .unwrap(); + std::fs::write( + socket + .join("blobs") + .join(compute_git_sha256_from_bytes(&bytes.patched)), + &bytes.patched, + ) + .unwrap(); +} + +/// One hosted patch the mock API serves: its tarball (built from the +/// installed bytes) and the hosted URL it lands on. +struct HostedPatch { + dep: &'static Dep, + tgz: Vec, + url: String, +} + +impl HostedPatch { + fn sri(&self) -> String { + sri(&self.tgz) + } +} + +/// Mount the full hosted-mode mock set for `deps` over one wiremock: +/// discovery (`batch`), the per-package detail query scan runs for EVERY +/// discovered package (`by-package/` — matched per dep on +/// the name inside the encoded purl, so a two-record scan gets each dep's +/// own patch back), the grant (`package`, results for every uuid; the bun +/// rewriter needs `artifacts[kind=tarball].integrity.sha512`), the patch +/// view with `blobContent` (what `scan --mode vendored` stages from), and +/// the hosted tarball routes bun downloads at install time. +async fn mount_hosted_api( + server: &MockServer, + fx: &Fixture, + deps: &[&'static Dep], +) -> Vec { + let patches: Vec = deps + .iter() + .map(|dep| HostedPatch { + dep, + tgz: fx.patched_tgz(dep), + url: dep.hosted_url(&server.uri()), + }) + .collect(); + + let batch_packages: Vec = patches + .iter() + .map(|p| { + json!({ + "purl": p.dep.purl, + "patches": [{ + "uuid": p.dep.uuid_h, "purl": p.dep.purl, "tier": "free", + "cveIds": [], "ghsaIds": [], "severity": "high", + "title": format!("bun migration fixture {}", p.dep.name) + }] + }) + }) + .collect(); + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "packages": batch_packages, + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + + let mut results = serde_json::Map::new(); + for p in &patches { + let dep = p.dep; + let bytes = fx.bytes(dep); + Mock::given(method("GET")) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.*{}.*$", + dep.name + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "patches": [{ + "uuid": dep.uuid_h, "purl": dep.purl, + "publishedAt": "2026-01-01T00:00:00Z", + "description": "x", "license": "MIT", "tier": "free", + "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + results.insert( + dep.uuid_h.to_string(), + json!({ + "status": "granted", + "url": p.url, + "purl": dep.purl, + "artifacts": [{ + "kind": "tarball", "url": p.url, + "integrity": { "sha512": p.sri() } + }], + "registryOverride": null + }), + ); + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{}", dep.uuid_h))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "uuid": dep.uuid_h, + "purl": dep.purl, + "publishedAt": "2026-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": compute_git_sha256_from_bytes(&bytes.orig), + "afterHash": compute_git_sha256_from_bytes(&bytes.patched), + "blobContent": b64(&bytes.patched), + } + }, + "vulnerabilities": { + GHSA: { + "cves": ["CVE-2026-3333"], + "summary": "migration vuln", "severity": "high", "description": "d" + } + }, + "description": "x", "license": "MIT", "tier": "free" + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path(dep.hosted_path())) + .respond_with( + ResponseTemplate::new(200).set_body_raw(p.tgz.clone(), "application/octet-stream"), + ) + .mount(server) + .await; + } + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with( + ResponseTemplate::new(200).set_body_json(json!({ "results": Value::Object(results) })), + ) + .mount(server) + .await; + patches +} + +// ── shared state assertions ──────────────────────────────────────────────── + +/// `proj` is PURELY hosted for `hp.dep`: no vendored ledger claim, no +/// committed artifact (at either uuid), no `.socket/vendor/` residue in the +/// lock, the packages line IS the URL 3-tuple, and the redirect ledger's +/// record + edit are present with `original` == the PRISTINE registry line +/// and `new` == the live line. Every other dep's line is byte-identical to +/// the pristine lock. +fn assert_pure_hosted(fx: &Fixture, proj: &Path, hp: &HostedPatch) { + let dep = hp.dep; + let state = read(proj, ".socket/vendor/state.json"); + assert!( + !state.contains(dep.purl), + "the displaced vendored ledger entry must be dropped: {state}" + ); + for stale in [dep.uuid_v, dep.uuid_h] { + // The message deliberately names no identifier: CodeQL's + // cleartext-logging heuristic treats anything flowing from a + // `uuid`-named binding as sensitive. + assert!( + !proj.join(".socket/vendor/npm").join(stale).exists(), + "every orphaned committed artifact dir under .socket/vendor/npm must be removed \ + after the hosted takeover" + ); + } + let lock = read(proj, "bun.lock"); + assert_eq!( + packages_line(&lock, dep.name), + dep.hosted_line(&hp.url, &hp.sri()), + "bun.lock must carry the hosted URL 3-tuple for {}:\n{lock}", + dep.name + ); + assert!( + !lock.contains(".socket/vendor/"), + "no vendored residue may survive in the lock:\n{lock}" + ); + assert_eq!( + lock_version(&lock), + Some(fx.lock_version), + "the rewrite must keep the lock's own lockfileVersion line:\n{lock}" + ); + for other in [&DEP_A, &DEP_B].into_iter().filter(|d| d.name != dep.name) { + assert_eq!( + packages_line(&lock, other.name), + fx.pristine_line(other), + "the un-patched {}'s registry 4-tuple must be byte-identical:\n{lock}", + other.name + ); + } + let ledger = read_json(proj, ".socket/vendor/redirect-state.json"); + assert_eq!( + ledger["records"][dep.purl]["uuid"], dep.uuid_h, + "the redirect ledger must record the hosted patch: {ledger:#}" + ); + let edit = ledger_edit_for(&ledger, dep.name).unwrap_or_else(|| { + panic!( + "no redirect_bun_lock_package edit for {}: {ledger:#}", + dep.name + ) + }); + assert_eq!(edit["path"], "bun.lock", "{edit:#}"); + assert_eq!( + edit["original"], + json!(fx.pristine_line(dep)), + "the redirect ledger's `original` must be the PRISTINE registry line — never a \ + `.socket/vendor/` local-path line (originals chain intact across migrations): {edit:#}" + ); + assert_eq!( + edit["new"], + json!(packages_line(&lock, dep.name)), + "the redirect ledger's `new` must be the live lock line: {edit:#}" + ); +} + +/// `proj` is PURELY vendored for `dep` at `uuid`: the redirect ledger no +/// longer claims the purl (record and edit both gone; file removed when +/// emptied), the packages line carries the local `.socket/vendor/npm//` +/// 3-tuple and no hosted URL, the artifact is committed, and the vendor +/// ledger's `bun_lock_package` wiring records the PRISTINE registry line as +/// its `original` (never the grant-tokenized hosted URL line). Every other +/// dep's line is byte-identical to the pristine lock. +fn assert_pure_vendored(fx: &Fixture, proj: &Path, dep: &Dep, uuid: &str, hosted_url: &str) { + match std::fs::read_to_string(proj.join(".socket/vendor/redirect-state.json")) { + Ok(text) => { + let ledger: Value = serde_json::from_str(&text).unwrap(); + assert!( + ledger["records"].get(dep.purl).is_none(), + "the superseded redirect record must be dropped: {ledger:#}" + ); + assert!( + ledger_edit_for(&ledger, dep.name).is_none(), + "the superseded bun.lock edit must be dropped: {ledger:#}" + ); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + // An emptied ledger is deleted — the expected outcome when this + // was the only hosted record. + } + Err(e) => panic!("unreadable redirect ledger: {e}"), + } + let lock = read(proj, "bun.lock"); + assert!( + !lock.contains(hosted_url) && !lock.contains("/patch/npm/"), + "the hosted URL must be gone from bun.lock:\n{lock}" + ); + let rel = dep.vendored_rel(uuid); + let line = packages_line(&lock, dep.name); + assert!( + line.starts_with(&format!( + " \"{}\": [\"{}@{rel}\", {{}}, \"sha512-", + dep.name, dep.name + )), + "bun.lock must carry the local vendored 3-tuple for {}:\n{line}", + dep.name + ); + assert_eq!( + lock_version(&lock), + Some(fx.lock_version), + "the rewrite must keep the lock's own lockfileVersion line:\n{lock}" + ); + for other in [&DEP_A, &DEP_B].into_iter().filter(|d| d.name != dep.name) { + assert_eq!( + packages_line(&lock, other.name), + fx.pristine_line(other), + "the un-patched {}'s registry 4-tuple must be byte-identical:\n{lock}", + other.name + ); + } + assert!( + proj.join(&rel).is_file(), + "the committed artifact tarball must exist under .socket/vendor/npm" + ); + let state = read_json(proj, ".socket/vendor/state.json"); + let wiring = state["entries"][dep.purl]["wiring"] + .as_array() + .unwrap_or_else(|| panic!("wiring array for {}: {state:#}", dep.purl)); + let lock_wiring = wiring + .iter() + .find(|w| w["kind"] == "bun_lock_package") + .unwrap_or_else(|| panic!("bun_lock_package wiring record: {state:#}")); + assert_eq!( + lock_wiring["original"], + json!(fx.pristine_line(dep)), + "the vendor ledger must record the PRISTINE registry line as its original (never \ + the grant-tokenized hosted URL line): {state:#}" + ); + let state_text = read(proj, ".socket/vendor/state.json"); + assert!( + !state_text.contains("/patch/npm/"), + "the vendor ledger must NOT record the hosted fragment anywhere: {state_text}" + ); +} + +/// After a full unwind: bun.lock and package.json byte-identical to the +/// pristine snapshots, and no `.socket/vendor/` artifacts or ledgers left. +/// The vendor ledger's delete-when-empty prunes the emptied `.socket/vendor/` +/// dir itself (`expect_dir_pruned` — `vendor --revert`); the redirect +/// ledger's removes only its file, so a `rollback` whose last act is the +/// hosted unwind leaves the empty dir behind — asserted EMPTY, never +/// holding a ledger or an `npm/` artifact dir. +fn assert_pristine_unwound(fx: &Fixture, proj: &Path, what: &str, expect_dir_pruned: bool) { + assert_eq!( + std::fs::read(proj.join("bun.lock")).unwrap(), + fx.lock_pristine, + "{what}: bun.lock must be byte-identical to the pristine registry lock; got:\n{}", + read(proj, "bun.lock") + ); + assert_eq!( + std::fs::read(proj.join("package.json")).unwrap(), + fx.pkg_json_pristine, + "{what}: package.json must be untouched" + ); + let vendor = proj.join(".socket/vendor"); + assert!( + !vendor.join("state.json").exists() && !vendor.join("redirect-state.json").exists(), + "{what}: no ledger may survive under .socket/vendor/" + ); + assert!( + !vendor.join("npm").exists(), + "{what}: no committed artifact may survive under .socket/vendor/npm/" + ); + if expect_dir_pruned { + assert!( + !vendor.exists(), + "{what}: the emptied .socket/vendor/ dir must be pruned" + ); + } else if vendor.exists() { + let leftovers: Vec = std::fs::read_dir(&vendor) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + assert!( + leftovers.is_empty(), + "{what}: .socket/vendor/ must be empty after the unwind; found {leftovers:?}" + ); + } +} + +/// `rollback --yes --json` (unscoped) must exit 0 with `status: success` +/// and land the pristine state; a fresh frozen install then reproduces the +/// ORIGINAL bytes for every dep. +fn assert_unscoped_rollback_restores_pristine(fx: &Fixture, proj: &Path, tag: &str) { + let (code, stdout, stderr) = rollback_cmd(proj, &[]); + assert_eq!(code, 0, "rollback failed ({tag}): {stdout}\n{stderr}"); + let env = envelope(&stdout, &stderr); + assert_eq!( + env["status"], "success", + "rollback envelope ({tag}): {env:#}" + ); + assert_eq!( + env["hosted"]["failed"], + json!([]), + "rollback ({tag}) must not fail any hosted purl: {env:#}" + ); + assert_pristine_unwound(fx, proj, &format!("rollback ({tag})"), false); + let fresh = fresh_frozen_install(fx, proj, &format!("fresh-rolled-back-{tag}")); + assert_installed(&fresh, &DEP_A, &fx.a.orig, "after rollback"); + assert_installed(&fresh, &DEP_B, &fx.b.orig, "after rollback"); + eprintln!("ROLLBACK OK ({tag}, bun {})", fx.bun_raw); +} + +/// The vendored → hosted takeover on `proj` (already vendored for DEP_A at +/// `uuid_v`): `scan --mode hosted` must announce the takeover, leave the +/// project purely hosted, and a fresh frozen install from an empty cache +/// must land the MARKER bytes from the hosted tarball. +fn take_over_to_hosted(fx: &Fixture, proj: &Path, api: &str, hp: &HostedPatch, tag: &str) { + let (code, stdout, stderr) = hosted_scan(proj, api, &[]); + assert_eq!(code, 0, "hosted scan failed ({tag}): {stdout}\n{stderr}"); + let env = envelope(&stdout, &stderr); + assert_eq!(env["status"], "success", "{env:#}"); + assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + let codes = warning_codes(&env["redirect"]["warnings"]); + assert!( + codes + .iter() + .any(|c| c == "redirect_takeover_reverted_vendored"), + "the takeover must be announced ({tag}): redirect.warnings codes = {codes:?}\n{env:#}" + ); + assert!( + !codes.iter().any(|c| c == "redirect_vendored_revert_failed"), + "the vendored revert must not be refused ({tag}): {env:#}" + ); + assert_pure_hosted(fx, proj, hp); + let fresh = fresh_frozen_install(fx, proj, &format!("fresh-hosted-{tag}")); + assert_installed(&fresh, &DEP_A, &fx.a.patched, "hosted fresh install"); + assert_installed( + &fresh, + &DEP_B, + &fx.b.orig, + "hosted fresh install (bystander)", + ); + eprintln!("VENDORED→HOSTED OK ({tag}, bun {})", fx.bun_raw); +} + +/// The hosted → vendored takeover on `proj` (already hosted for DEP_A), +/// driven by `driver`: the takeover must be announced, the project left +/// purely vendored at the uuid the driver vendors under, the manifest must +/// hold that record, and a fresh frozen install from an empty cache must +/// land the MARKER bytes from the committed artifact. Returns that uuid. +#[derive(Clone, Copy, PartialEq, Debug)] +enum VendoredDriver { + /// `vendor --json --offline` over a hand-staged manifest (uuid_v). + VendorOffline, + /// `scan --mode vendored --json --yes` — discovery + download from the + /// mock API (uuid_h), then the same vendor engine. + ScanVendored, +} + +fn take_over_to_vendored( + fx: &Fixture, + proj: &Path, + api: &str, + hp: &HostedPatch, + driver: VendoredDriver, + tag: &str, +) -> &'static str { + let dep = hp.dep; + // Kept apart from the envelope on purpose (no tuple): CodeQL's + // cleartext-logging heuristic would otherwise taint every `{vendor_env}` + // assertion message with the `uuid`-named half. + let uuid = match driver { + VendoredDriver::VendorOffline => dep.uuid_v, + VendoredDriver::ScanVendored => dep.uuid_h, + }; + let vendor_env = match driver { + VendoredDriver::VendorOffline => { + stage_manifest(fx, proj, dep); + let (code, stdout, stderr) = vendor_cmd(proj, &[]); + assert_eq!(code, 0, "vendor failed ({tag}): {stdout}\n{stderr}"); + envelope(&stdout, &stderr) + } + VendoredDriver::ScanVendored => { + let (code, stdout, stderr) = vendored_scan(proj, api, &[]); + assert_eq!( + code, 0, + "scan --mode vendored failed ({tag}): {stdout}\n{stderr}" + ); + let env = envelope(&stdout, &stderr); + assert_eq!(env["status"], "success", "{env:#}"); + env["vendor"].clone() + } + }; + assert_eq!(vendor_env["status"], "success", "{vendor_env:#}"); + assert_eq!(vendor_env["summary"]["applied"], 1, "{vendor_env:#}"); + assert_eq!(vendor_env["summary"]["failed"], 0, "{vendor_env:#}"); + let codes = event_codes(&vendor_env); + assert!( + codes + .iter() + .any(|c| c == "vendor_takeover_reverted_redirect"), + "the takeover must be announced ({tag}, {driver:?}): event codes = {codes:?}\n\ + {vendor_env:#}" + ); + assert!( + !codes.iter().any(|c| c == "redirect_revert_failed"), + "the hosted revert must not be refused ({tag}, {driver:?}): {vendor_env:#}" + ); + assert_pure_vendored(fx, proj, dep, uuid, &hp.url); + let manifest = read_json(proj, ".socket/manifest.json"); + assert_eq!( + manifest["patches"][dep.purl]["uuid"], uuid, + "the manifest must record the vendored patch ({tag}, {driver:?}): {manifest:#}" + ); + let fresh = fresh_frozen_install(fx, proj, &format!("fresh-vendored-{tag}")); + assert_installed(&fresh, &DEP_A, &fx.a.patched, "vendored fresh install"); + assert_installed( + &fresh, + &DEP_B, + &fx.b.orig, + "vendored fresh install (bystander)", + ); + eprintln!("HOSTED→VENDORED OK ({tag}, {driver:?}, bun {})", fx.bun_raw); + uuid +} + +/// `vendor --revert` must restore the REGISTRY lock byte-identically (the +/// pre-redirect resolution the takeover carried forward, not the hosted +/// splice) and prune `.socket/vendor/`. +fn assert_vendor_revert_restores_pristine(fx: &Fixture, proj: &Path, tag: &str) { + let (code, stdout, stderr) = vendor_cmd(proj, &["--revert"]); + assert_eq!( + code, 0, + "vendor --revert failed ({tag}): {stdout}\n{stderr}" + ); + let env = envelope(&stdout, &stderr); + assert_eq!(env["status"], "success", "{env:#}"); + assert_eq!(env["summary"]["removed"], 1, "{env:#}"); + assert_pristine_unwound(fx, proj, &format!("vendor --revert ({tag})"), true); + eprintln!("VENDOR REVERT OK ({tag})"); +} + +// ───────────────────────────────────────────────────────────────────────── +// 1. vendored → hosted takeover leaves the project purely hosted +// ───────────────────────────────────────────────────────────────────────── + +// #[serial]: bun keeps state under the sandboxed `~/.bun` besides the +// per-project cache dirs; serializing keeps legs that install the same +// hosted URL / local tarball spec from ever racing each other. +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +async fn bun_vendored_then_hosted_takeover_leaves_pure_hosted() { + let Some(fx) = stage_fixture("vendored-then-hosted") else { + return; + }; + let proj = fx.proj.clone(); + + // A: vendor (offline) from the staged manifest. + stage_manifest(&fx, &proj, &DEP_A); + let (code, stdout, stderr) = vendor_cmd(&proj, &[]); + assert_eq!(code, 0, "vendor failed: {stdout}\n{stderr}"); + let env = envelope(&stdout, &stderr); + assert_eq!(env["summary"]["applied"], 1, "{env:#}"); + assert!( + read(&proj, ".socket/vendor/state.json").contains(DEP_A.purl), + "the vendored ledger must claim the purl" + ); + assert!( + read(&proj, "bun.lock").contains(&DEP_A.vendored_rel(DEP_A.uuid_v)), + "bun.lock must be vendored-wired before the takeover:\n{}", + read(&proj, "bun.lock") + ); + + // B: hosted redirect over the vendored state — the takeover — then the + // fresh-checkout marker proof. + let server = MockServer::start().await; + let patches = mount_hosted_api(&server, &fx, &[&DEP_A]).await; + take_over_to_hosted(&fx, &proj, &server.uri(), &patches[0], "rev"); + + // C: unscoped rollback → pristine bytes, no vendor artifacts or + // ledgers, fresh install → original bytes. + assert_unscoped_rollback_restores_pristine(&fx, &proj, "after-vendored-then-hosted"); +} + +// ───────────────────────────────────────────────────────────────────────── +// 2. hosted → vendored takeover round-trips to the registry (both drivers) +// ───────────────────────────────────────────────────────────────────────── + +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +async fn bun_hosted_then_vendored_takeover_round_trips_to_registry() { + let Some(fx) = stage_fixture("hosted-then-vendored") else { + return; + }; + let proj = fx.proj.clone(); + let server = MockServer::start().await; + let patches = mount_hosted_api(&server, &fx, &[&DEP_A]).await; + let hp = &patches[0]; + + // A: hosted redirect: registry 4-tuple → URL 3-tuple, ledger claims the + // purl with one `redirect_bun_lock_package` edit whose original is + // the pristine registry line; a fresh frozen install lands the + // patched tree. + let (code, stdout, stderr) = hosted_scan(&proj, &server.uri(), &[]); + assert_eq!(code, 0, "hosted scan failed: {stdout}\n{stderr}"); + let env = envelope(&stdout, &stderr); + assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + assert_pure_hosted(&fx, &proj, hp); + let fresh = fresh_frozen_install(&fx, &proj, "fresh-hosted"); + assert_installed(&fresh, &DEP_A, &fx.a.patched, "hosted fresh install"); + + // B: BOTH vendored drivers, each on its own copy of the hosted project. + let by_vendor = fx.dir("hosted-copy-vendor"); + copy_project(&proj, &by_vendor); + let by_scan = fx.dir("hosted-copy-scan"); + copy_project(&proj, &by_scan); + + take_over_to_vendored( + &fx, + &by_vendor, + &server.uri(), + hp, + VendoredDriver::VendorOffline, + "vendor-offline", + ); + assert_vendor_revert_restores_pristine(&fx, &by_vendor, "vendor-offline"); + + take_over_to_vendored( + &fx, + &by_scan, + &server.uri(), + hp, + VendoredDriver::ScanVendored, + "scan-vendored", + ); + // A re-run is an in-sync no-op with no second takeover. + let (code, stdout, stderr) = vendored_scan(&by_scan, &server.uri(), &[]); + assert_eq!(code, 0, "vendored re-run failed: {stdout}\n{stderr}"); + let rerun = envelope(&stdout, &stderr); + let codes = event_codes(&rerun["vendor"]); + assert!( + codes.iter().any(|c| c == "already_vendored") + && !codes + .iter() + .any(|c| c == "vendor_takeover_reverted_redirect"), + "the re-run must be `already_vendored` with no second takeover: {codes:?}\n{rerun:#}" + ); + assert_vendor_revert_restores_pristine(&fx, &by_scan, "scan-vendored"); +} + +// ───────────────────────────────────────────────────────────────────────── +// 3. dry-run previews match the wet outcomes, and write nothing +// ───────────────────────────────────────────────────────────────────────── + +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +async fn bun_dry_run_previews_match_wet_outcomes() { + let Some(fx) = stage_fixture("dry-run") else { + return; + }; + let proj = fx.proj.clone(); + let server = MockServer::start().await; + let patches = mount_hosted_api(&server, &fx, &[&DEP_A]).await; + let hp = &patches[0]; + let api = server.uri(); + + // ── over a LIVE HOSTED redirect ────────────────────────────────────── + let hosted = fx.dir("live-hosted"); + copy_project(&proj, &hosted); + let (code, stdout, stderr) = hosted_scan(&hosted, &api, &[]); + assert_eq!(code, 0, "hosted scan failed: {stdout}\n{stderr}"); + assert_pure_hosted(&fx, &hosted, hp); + // The manifest record `vendor` acts on (offline: the staged blob). + stage_manifest(&fx, &hosted, &DEP_A); + let before = snapshot(&hosted); + + // `vendor --dry-run`: the takeover is PROBED (write-free per-purl revert + // on a ledger clone) and previewed; the backend preview does not run + // against the still-hosted lock, so no false `vendor_lock_entry_not_found` + // and no refusal. + let (code, stdout, stderr) = vendor_cmd(&hosted, &["--dry-run"]); + assert_eq!(code, 0, "vendor --dry-run must succeed: {stdout}\n{stderr}"); + let env = envelope(&stdout, &stderr); + assert_eq!(env["dryRun"], true, "{env:#}"); + assert_eq!(env["summary"]["failed"], 0, "{env:#}"); + let advisory = env["events"] + .as_array() + .and_then(|evs| { + evs.iter() + .find(|e| e["errorCode"] == "vendor_would_revert_redirect") + }) + .unwrap_or_else(|| panic!("expected a `vendor_would_revert_redirect` preview: {env:#}")); + assert_eq!(advisory["action"], "skipped", "{advisory:#}"); + assert_eq!(advisory["purl"], DEP_A.purl, "{advisory:#}"); + let codes = event_codes(&env); + for forbidden in ["vendor_lock_entry_not_found", "redirect_revert_failed"] { + assert!( + !codes.iter().any(|c| c == forbidden), + "vendor --dry-run must not emit `{forbidden}` over a takeover it can perform: \ + {env:#}" + ); + } + assert_unchanged(&before, &hosted, "vendor --dry-run"); + + // `scan --mode vendored --dry-run`: the scan-side preview is a ledger + // classification by contract (`would_vendor` | `already_vendored` | + // `would_revendor`, plus the additive Bun-preflight `would_refuse`); over + // a takeover the wet run performs it must say `would_vendor` — never + // `would_refuse`, never a refusal — and write nothing. + let (code, stdout, stderr) = vendored_scan(&hosted, &api, &["--dry-run"]); + assert_eq!( + code, 0, + "scan --mode vendored --dry-run must succeed: {stdout}\n{stderr}" + ); + let env = envelope(&stdout, &stderr); + assert_eq!(env["status"], "success", "{env:#}"); + assert_eq!(env["vendor"]["dryRun"], true, "{env:#}"); + let preview = env["vendor"]["patches"] + .as_array() + .and_then(|p| p.iter().find(|p| p["purl"] == DEP_A.purl)) + .unwrap_or_else(|| { + panic!( + "expected a vendored preview record for {}: {env:#}", + DEP_A.purl + ) + }); + assert_eq!( + preview["action"], "would_vendor", + "the vendored preview over a live hosted bun redirect must classify `would_vendor` \ + (the wet run takes over and vendors): {env:#}" + ); + assert!( + !stdout.contains("redirect_revert_failed") && !stdout.contains("would_refuse"), + "the vendored preview must not advertise a refusal the wet run never makes:\n{stdout}" + ); + assert_unchanged(&before, &hosted, "scan --mode vendored --dry-run"); + + // The WET vendor lands exactly the takeover previewed. + let (code, stdout, stderr) = vendor_cmd(&hosted, &[]); + assert_eq!(code, 0, "wet vendor failed: {stdout}\n{stderr}"); + let env = envelope(&stdout, &stderr); + assert_eq!(env["summary"]["applied"], 1, "{env:#}"); + assert!( + event_codes(&env) + .iter() + .any(|c| c == "vendor_takeover_reverted_redirect"), + "the wet vendor must perform the takeover the preview promised: {env:#}" + ); + assert_pure_vendored(&fx, &hosted, &DEP_A, DEP_A.uuid_v, &hp.url); + + // ── over a LIVE VENDORED state ─────────────────────────────────────── + let vendored = fx.dir("live-vendored"); + copy_project(&proj, &vendored); + stage_manifest(&fx, &vendored, &DEP_A); + let (code, stdout, stderr) = vendor_cmd(&vendored, &[]); + assert_eq!(code, 0, "vendor failed: {stdout}\n{stderr}"); + assert_pure_vendored(&fx, &vendored, &DEP_A, DEP_A.uuid_v, &hp.url); + let before = snapshot(&vendored); + + // `scan --mode hosted --dry-run`: the vendored revert is probed + // write-free and the takeover previewed; nothing is rewritten, no ledger + // is written. + let (code, stdout, stderr) = hosted_scan(&vendored, &api, &["--dry-run"]); + assert_eq!( + code, 0, + "scan --mode hosted --dry-run must succeed: {stdout}\n{stderr}" + ); + let env = envelope(&stdout, &stderr); + assert_eq!(env["redirect"]["dryRun"], true, "{env:#}"); + let codes = warning_codes(&env["redirect"]["warnings"]); + assert!( + codes.iter().any(|c| c == "redirect_would_revert_vendored"), + "the hosted preview must announce the vendored takeover: {codes:?}\n{env:#}" + ); + assert!( + !codes.iter().any(|c| c == "redirect_vendored_revert_failed"), + "the hosted preview must not refuse a takeover the wet run performs: {env:#}" + ); + assert_unchanged(&before, &vendored, "scan --mode hosted --dry-run"); + + // The WET hosted scan lands exactly the takeover previewed. + take_over_to_hosted(&fx, &vendored, &api, hp, "after-dry-run"); +} + +// ───────────────────────────────────────────────────────────────────────── +// 4. scoped rollback / remove of ONE of two hosted records +// ───────────────────────────────────────────────────────────────────────── + +/// After unwinding ONLY DEP_A: its line is the pristine registry tuple, +/// DEP_B is still hosted, the ledger keeps exactly DEP_B's record + edit, +/// and a fresh frozen install lands A's ORIGINAL and B's MARKER bytes. +fn assert_only_a_unwound(fx: &Fixture, proj: &Path, b: &HostedPatch, tag: &str) { + let lock = read(proj, "bun.lock"); + assert_eq!( + packages_line(&lock, DEP_A.name), + fx.pristine_line(&DEP_A), + "{tag}: {} must be back to its registry tuple:\n{lock}", + DEP_A.name + ); + assert_eq!( + packages_line(&lock, DEP_B.name), + DEP_B.hosted_line(&b.url, &b.sri()), + "{tag}: {} must stay hosted:\n{lock}", + DEP_B.name + ); + let ledger = read_json(proj, ".socket/vendor/redirect-state.json"); + assert!( + ledger["records"].get(DEP_A.purl).is_none(), + "{tag}: A's record must be dropped: {ledger:#}" + ); + assert_eq!( + ledger["records"][DEP_B.purl]["uuid"], DEP_B.uuid_h, + "{tag}: B's record must stay: {ledger:#}" + ); + let edits = ledger["edits"].as_array().unwrap(); + assert_eq!( + edits.len(), + 1, + "{tag}: exactly B's edit must stay: {ledger:#}" + ); + assert_eq!(edits[0]["key"], DEP_B.name, "{tag}: {ledger:#}"); + let fresh = fresh_frozen_install(fx, proj, &format!("fresh-{tag}")); + assert_installed(&fresh, &DEP_A, &fx.a.orig, tag); + assert_installed(&fresh, &DEP_B, &fx.b.patched, tag); + eprintln!("SCOPED UNWIND OK ({tag}, bun {})", fx.bun_raw); +} + +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +async fn bun_scoped_rollback_and_remove_unwind_one_of_two_hosted_records() { + let Some(fx) = stage_fixture("scoped-unwind") else { + return; + }; + let proj = fx.proj.clone(); + let server = MockServer::start().await; + let patches = mount_hosted_api(&server, &fx, &[&DEP_A, &DEP_B]).await; + let (a, b) = (&patches[0], &patches[1]); + + // Both deps hosted-redirected in ONE scan: two records, two edits. + let (code, stdout, stderr) = hosted_scan(&proj, &server.uri(), &[]); + assert_eq!(code, 0, "hosted scan failed: {stdout}\n{stderr}"); + let env = envelope(&stdout, &stderr); + assert_eq!(env["redirect"]["redirected"], 2, "{env:#}"); + let lock = read(&proj, "bun.lock"); + for hp in [a, b] { + assert_eq!( + packages_line(&lock, hp.dep.name), + hp.dep.hosted_line(&hp.url, &hp.sri()), + "{} must be hosted:\n{lock}", + hp.dep.name + ); + } + let ledger = read_json(&proj, ".socket/vendor/redirect-state.json"); + assert_eq!( + ledger["records"].as_object().map(|m| m.len()), + Some(2), + "{ledger:#}" + ); + assert_eq!( + ledger["edits"].as_array().map(|e| e.len()), + Some(2), + "{ledger:#}" + ); + for hp in [a, b] { + let edit = ledger_edit_for(&ledger, hp.dep.name) + .unwrap_or_else(|| panic!("no edit for {}: {ledger:#}", hp.dep.name)); + assert_eq!( + edit["original"], + json!(fx.pristine_line(hp.dep)), + "{edit:#}" + ); + } + let fresh = fresh_frozen_install(&fx, &proj, "fresh-two-hosted"); + assert_installed(&fresh, &DEP_A, &fx.a.patched, "two hosted records"); + assert_installed(&fresh, &DEP_B, &fx.b.patched, "two hosted records"); + + // Scoped rollback of A: per-purl path (two records ⇒ the whole-ledger + // replay is not eligible). Used to exit 1 with hosted.failed = ["cannot + // replay yet"]. + let by_rollback = fx.dir("two-hosted-copy-rollback"); + copy_project(&proj, &by_rollback); + let (code, stdout, stderr) = rollback_cmd(&by_rollback, &[DEP_A.purl]); + assert_eq!(code, 0, "scoped rollback must succeed: {stdout}\n{stderr}"); + let env = envelope(&stdout, &stderr); + assert_eq!(env["status"], "success", "{env:#}"); + assert_eq!(env["hosted"]["reverted"], json!([DEP_A.purl]), "{env:#}"); + assert_eq!(env["hosted"]["failed"], json!([]), "{env:#}"); + assert_eq!(env["hosted"]["unsupported"], json!([]), "{env:#}"); + assert_only_a_unwound(&fx, &by_rollback, b, "scoped-rollback"); + // Then the unscoped rollback: covers the last record ⇒ whole-ledger + // replay ⇒ pristine. + assert_unscoped_rollback_restores_pristine(&fx, &by_rollback, "after-scoped-rollback"); + + // `remove ` takes the same per-purl hosted leg; used to exit 1 + // with `hosted_revert_failed`. + let by_remove = fx.dir("two-hosted-copy-remove"); + copy_project(&proj, &by_remove); + let (code, stdout, stderr) = run_socket( + &by_remove, + &[ + "remove", + DEP_A.purl, + "--yes", + "--json", + "--cwd", + by_remove.to_str().unwrap(), + ], + ); + assert_eq!(code, 0, "scoped remove must succeed: {stdout}\n{stderr}"); + let env = envelope(&stdout, &stderr); + assert!( + env["error"].is_null(), + "remove must report no top-level error: {env:#}" + ); + assert_eq!(env["status"], "success", "{env:#}"); + assert_eq!(env["summary"]["removed"], 1, "{env:#}"); + assert_eq!(env["summary"]["failed"], 0, "{env:#}"); + // The hosted unwind rides a `removed` event tagged `hosted_reverted` + // (remove's per-purl hosted leg; a `hosted_revert_failed` top-level + // error was the pre-fix shape). + assert!( + env["events"] + .as_array() + .is_some_and(|evs| evs.iter().any(|e| { + e["action"] == "removed" + && e["errorCode"] == "hosted_reverted" + && e["purl"] == DEP_A.purl + })), + "remove must report the hosted unwind of {}: {env:#}", + DEP_A.purl + ); + assert_only_a_unwound(&fx, &by_remove, b, "scoped-remove"); + assert_unscoped_rollback_restores_pristine(&fx, &by_remove, "after-scoped-remove"); +} + +// ───────────────────────────────────────────────────────────────────────── +// 5. unscoped rollback from each mixed state restores pristine +// ───────────────────────────────────────────────────────────────────────── + +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +async fn bun_rollback_from_each_mixed_state_restores_pristine() { + let Some(fx) = stage_fixture("rollback-mixed") else { + return; + }; + let proj = fx.proj.clone(); + let server = MockServer::start().await; + let patches = mount_hosted_api(&server, &fx, &[&DEP_A]).await; + let hp = &patches[0]; + let api = server.uri(); + + // State (1): vendored → hosted, then rollback. The manifest still holds + // the vendored record the hosted takeover superseded; rollback's manifest + // leg retires it alongside the hosted unwind. + let one = fx.dir("mixed-vendored-then-hosted"); + copy_project(&proj, &one); + stage_manifest(&fx, &one, &DEP_A); + let (code, stdout, stderr) = vendor_cmd(&one, &[]); + assert_eq!(code, 0, "vendor failed: {stdout}\n{stderr}"); + take_over_to_hosted(&fx, &one, &api, hp, "mixed-1"); + assert_unscoped_rollback_restores_pristine(&fx, &one, "mixed-1"); + + // State (2): hosted → vendored (scan-driven), then rollback: the + // vendored leg unwires + removes the artifact, the (emptied) redirect + // ledger is already gone, the manifest record is retired. + let two = fx.dir("mixed-hosted-then-vendored"); + copy_project(&proj, &two); + let (code, stdout, stderr) = hosted_scan(&two, &api, &[]); + assert_eq!(code, 0, "hosted scan failed: {stdout}\n{stderr}"); + take_over_to_vendored(&fx, &two, &api, hp, VendoredDriver::ScanVendored, "mixed-2"); + let (code, stdout, stderr) = rollback_cmd(&two, &[]); + assert_eq!(code, 0, "rollback failed (mixed-2): {stdout}\n{stderr}"); + let env = envelope(&stdout, &stderr); + assert_eq!(env["status"], "success", "{env:#}"); + assert_eq!( + env["vendoredReverted"], + json!([DEP_A.purl]), + "rollback must unwire the vendored purl: {env:#}" + ); + assert_eq!(env["vendoredFailed"], json!([]), "{env:#}"); + assert_pristine_unwound(&fx, &two, "rollback (mixed-2)", false); + let fresh = fresh_frozen_install(&fx, &two, "fresh-rolled-back-mixed-2"); + assert_installed(&fresh, &DEP_A, &fx.a.orig, "after rollback (mixed-2)"); + assert_installed(&fresh, &DEP_B, &fx.b.orig, "after rollback (mixed-2)"); + eprintln!("ROLLBACK OK (mixed-2, bun {})", fx.bun_raw); +} diff --git a/crates/socket-patch-cli/tests/repair_vendor_flavors_e2e.rs b/crates/socket-patch-cli/tests/repair_vendor_flavors_e2e.rs index a14c1b59..24fa198e 100644 --- a/crates/socket-patch-cli/tests/repair_vendor_flavors_e2e.rs +++ b/crates/socket-patch-cli/tests/repair_vendor_flavors_e2e.rs @@ -49,20 +49,125 @@ fn git_sha256(content: &[u8]) -> String { /// The three npm flavors this file parameterizes over. Each knows how to lay /// down its pre-vendor lockfile and how to prove the vendor lock rewrite -/// survived a repair. +/// survived a repair. The bun arm is further parameterized over the text +/// lock's `lockfileVersion` and the presence of a workspace member. #[derive(Clone, Copy)] enum Flavor { Pnpm, YarnBerry, - Bun, + Bun(BunLock), +} + +/// One bun.lock shape: `lockfileVersion` 0 (bun 1.1.39–1.1.45 text opt-in), +/// 1 (bun 1.2/1.3) or 2 (bun 1.4), with or without a `workspace:` packages +/// entry. Workspace shapes matter because the vendor engine's workspace +/// gate refuses a FRESH vendor into a pre-v2 workspace lock, while `repair` +/// (and in-sync re-runs) on a lock that ALREADY carries the vendored tuple +/// must keep working — a project vendored before it grew a workspace member +/// used to be refused every maintenance verb, with `repair` leaving the lock +/// pointing at a tarball it declined to rebuild. +#[derive(Clone, Copy)] +struct BunLock { + version: u64, + workspace: bool, +} + +impl BunLock { + /// The plain v1 shape the flavor-generic arms use. + const V1: BunLock = BunLock { + version: 1, + workspace: false, + }; + + /// lockfileVersion {0, 1, 2} × {plain, workspace}. + const MATRIX: [BunLock; 6] = [ + BunLock { + version: 0, + workspace: false, + }, + BunLock { + version: 0, + workspace: true, + }, + BunLock { + version: 1, + workspace: false, + }, + BunLock { + version: 1, + workspace: true, + }, + BunLock { + version: 2, + workspace: false, + }, + BunLock { + version: 2, + workspace: true, + }, + ]; + + /// The `packages` entry bun writes for the `consumer` workspace member — + /// the REAL per-version grammar (bun 1.1.45 vs 1.3.14/1.4.2 output): v0 + /// emits a 2-tuple carrying the member's deps object, v1/v2 the + /// 1-tuple. Followed by bun's blank-line entry separator. + fn workspace_entry(self) -> &'static str { + if self.version == 0 { + " \"consumer\": [\"consumer@workspace:packages/consumer\", { \"dependencies\": { \"left-pad\": \"1.3.0\" } }],\n\n" + } else { + " \"consumer\": [\"consumer@workspace:packages/consumer\"],\n\n" + } + } + + /// Only a v2 lock accepts a FRESH vendor with the workspace entry + /// present; the v0/v1 workspace shapes are reached the way real projects + /// reach them — vendored first, workspace member added afterwards (bun + /// keeps both the version and the vendored tuple on an in-place + /// `bun install`; see [`BunLock::add_workspace_member`]). + fn workspace_present_before_vendor(self) -> bool { + self.workspace && self.version == 2 + } + + /// The pre-vendor lock text (real bun shape: no `configVersion` line on + /// v0; the registry 4-tuple is grammar-identical across 0/1/2). + fn lock_text(self, with_workspace: bool) -> String { + format!( + "{{\n \"lockfileVersion\": {},\n \"packages\": {{\n{} \"{DEP}\": \ + [\"{DEP}@{DEP_VERSION}\", \"\", {{}}, \"sha512-orig==\"],\n }}\n}}\n", + self.version, + if with_workspace { + self.workspace_entry() + } else { + "" + }, + ) + } + + /// Splice the workspace member into an already-vendored lock — what the + /// post-vendor `bun install` leaves behind (vendored tuple byte-identical, + /// version unchanged). + fn add_workspace_member(self, root: &Path) { + let path = root.join("bun.lock"); + let lock = std::fs::read_to_string(&path).unwrap(); + let spliced = lock.replacen( + " \"packages\": {\n", + &format!(" \"packages\": {{\n{}", self.workspace_entry()), + 1, + ); + assert_ne!(spliced, lock, "the workspace splice must hit"); + std::fs::write(&path, spliced).unwrap(); + } } impl Flavor { - fn tag(self) -> &'static str { + fn tag(self) -> String { match self { - Flavor::Pnpm => "pnpm", - Flavor::YarnBerry => "yarn-berry", - Flavor::Bun => "bun", + Flavor::Pnpm => "pnpm".to_string(), + Flavor::YarnBerry => "yarn-berry".to_string(), + Flavor::Bun(BunLock { version, workspace }) => format!( + "bun(lockfileVersion {version}{})", + if workspace { ", workspace" } else { "" } + ), } } @@ -71,10 +176,23 @@ impl Flavor { match self { Flavor::Pnpm => "pnpm-lock.yaml", Flavor::YarnBerry => "yarn.lock", - Flavor::Bun => "bun.lock", + Flavor::Bun(_) => "bun.lock", } } + /// The `consumer` workspace member's manifest (bun refuses to install a + /// lock that names a missing member; the engine is lock-only, this keeps + /// the fixture honest). + fn workspace_member(self) -> bool { + matches!( + self, + Flavor::Bun(BunLock { + workspace: true, + .. + }) + ) + } + /// Write the pre-vendor lockfile (the shape each backend's capstone /// asserts as its `lock_before`). Extra files (`.yarnrc.yml` for berry) /// are laid down too. @@ -127,14 +245,10 @@ snapshots: ) .unwrap(); } - Flavor::Bun => { + Flavor::Bun(shape) => { std::fs::write( root.join("bun.lock"), - format!( - "{{\n \"lockfileVersion\": 1,\n \"packages\": {{\n \ - \"{DEP}\": [\"{DEP}@{DEP_VERSION}\", \"\", {{}}, \"sha512-orig==\"],\n \ - }}\n}}\n" - ), + shape.lock_text(shape.workspace_present_before_vendor()), ) .unwrap(); } @@ -161,7 +275,7 @@ snapshots: // berry: the `file:./` locator entry. Flavor::YarnBerry => format!("{DEP}@file:./{tgz_rel}"), // bun: the local-tarball 3-tuple element 0 `@`. - Flavor::Bun => format!("\"{DEP}@{tgz_rel}\""), + Flavor::Bun(_) => format!("\"{DEP}@{tgz_rel}\""), } } } @@ -169,14 +283,30 @@ snapshots: /// Vendorable flavor project: package.json + the flavor lockfile + the /// installed package copy the vendor backend packs from. fn write_fixture(root: &Path, flavor: Flavor) { + let workspaces = if flavor.workspace_member() { + r#","workspaces":["packages/*"]"# + } else { + "" + }; std::fs::write( root.join("package.json"), format!( - r#"{{"name":"{}","version":"0.0.0","private":true,"dependencies":{{"{DEP}":"{DEP_VERSION}"}}}}"#, + r#"{{"name":"{}","version":"0.0.0","private":true{workspaces},"dependencies":{{"{DEP}":"{DEP_VERSION}"}}}}"#, flavor.root_name() ), ) .unwrap(); + if flavor.workspace_member() { + let member = root.join("packages/consumer"); + std::fs::create_dir_all(&member).unwrap(); + std::fs::write( + member.join("package.json"), + format!( + r#"{{"name":"consumer","version":"1.0.0","dependencies":{{"{DEP}":"{DEP_VERSION}"}}}}"# + ), + ) + .unwrap(); + } flavor.write_lock(root); let pkg = root.join("node_modules").join(DEP); @@ -284,12 +414,28 @@ fn run_cli(root: &Path, mock_uri: &str, argv: &[&str]) -> (i32, String, String) } /// `scan --vendor --yes` to establish a vendored flavor project; returns the -/// vendored tarball path (identical layout for every npm flavor). -fn vendor_project(root: &Path, mock_uri: &str) -> PathBuf { +/// vendored tarball path (identical layout for every npm flavor). A v0/v1 +/// bun workspace shape gains its workspace member AFTER vendoring — the only +/// way such a lock arises (a fresh vendor into it is refused by design). +fn vendor_project(root: &Path, mock_uri: &str, flavor: Flavor) -> PathBuf { let (code, stdout, stderr) = run_cli(root, mock_uri, &["scan", "--vendor", "--yes"]); - assert_eq!(code, 0, "vendor setup failed: {stdout} {stderr}"); + assert_eq!( + code, + 0, + "{}: vendor setup failed: {stdout} {stderr}", + flavor.tag() + ); let tgz = root.join(format!(".socket/vendor/npm/{UUID}/{DEP}-{DEP_VERSION}.tgz")); - assert!(tgz.is_file(), "setup must vendor the tarball: {stdout}"); + assert!( + tgz.is_file(), + "{}: setup must vendor the tarball: {stdout}", + flavor.tag() + ); + if let Flavor::Bun(shape) = flavor { + if shape.workspace && !shape.workspace_present_before_vendor() { + shape.add_workspace_member(root); + } + } tgz } @@ -321,7 +467,7 @@ async fn deleted_tarball_rebuilds(flavor: Flavor) { mount_patch_api(&mock).await; let tmp = tempfile::tempdir().unwrap(); write_fixture(tmp.path(), flavor); - let tgz = vendor_project(tmp.path(), &mock.uri()); + let tgz = vendor_project(tmp.path(), &mock.uri(), flavor); let tgz_bytes = std::fs::read(&tgz).unwrap(); let lock1 = std::fs::read(tmp.path().join(flavor.lock_name())).unwrap(); @@ -363,9 +509,16 @@ async fn repair_rebuilds_deleted_yarn_berry_tarball() { deleted_tarball_rebuilds(Flavor::YarnBerry).await; } +/// Every bun.lock shape — lockfileVersion {0, 1, 2} × {plain, workspace}. +/// `repair` rebuilds through the vendor engine, whose workspace gate must +/// let an already-vendored (`Ours`) instance through on a pre-v2 workspace +/// lock instead of refusing and leaving the lock pointing at a tarball +/// nobody rebuilt (cold `bun install --frozen-lockfile` then ENOENTs). #[tokio::test] async fn repair_rebuilds_deleted_bun_tarball() { - deleted_tarball_rebuilds(Flavor::Bun).await; + for shape in BunLock::MATRIX { + deleted_tarball_rebuilds(Flavor::Bun(shape)).await; + } } // ── (b) corrupt tarball → detected + rebuilt ─────────────────────────────── @@ -375,7 +528,7 @@ async fn corrupt_tarball_rebuilds(flavor: Flavor) { mount_patch_api(&mock).await; let tmp = tempfile::tempdir().unwrap(); write_fixture(tmp.path(), flavor); - let tgz = vendor_project(tmp.path(), &mock.uri()); + let tgz = vendor_project(tmp.path(), &mock.uri(), flavor); let tgz_bytes = std::fs::read(&tgz).unwrap(); std::fs::write(&tgz, b"\x1f\x8bgarbage").unwrap(); @@ -405,7 +558,9 @@ async fn repair_rebuilds_corrupt_yarn_berry_tarball() { #[tokio::test] async fn repair_rebuilds_corrupt_bun_tarball() { - corrupt_tarball_rebuilds(Flavor::Bun).await; + for shape in BunLock::MATRIX { + corrupt_tarball_rebuilds(Flavor::Bun(shape)).await; + } } // ── (c) tampered ledger sha → fail-closed ────────────────────────────────── @@ -415,7 +570,7 @@ async fn tampered_ledger_fails_closed(flavor: Flavor) { mount_patch_api(&mock).await; let tmp = tempfile::tempdir().unwrap(); write_fixture(tmp.path(), flavor); - let tgz = vendor_project(tmp.path(), &mock.uri()); + let tgz = vendor_project(tmp.path(), &mock.uri(), flavor); let state_path = tmp.path().join(".socket/vendor/state.json"); let state = std::fs::read_to_string(&state_path).unwrap(); @@ -452,7 +607,7 @@ async fn repair_fails_closed_on_tampered_yarn_berry_ledger_sha() { #[tokio::test] async fn repair_fails_closed_on_tampered_bun_ledger_sha() { - tampered_ledger_fails_closed(Flavor::Bun).await; + tampered_ledger_fails_closed(Flavor::Bun(BunLock::V1)).await; } // ── (d) ledger deleted wholesale → reconstruct from lockfile references ───── @@ -462,7 +617,7 @@ async fn ledger_gone_reconstructs_from_lock(flavor: Flavor) { mount_patch_api(&mock).await; let tmp = tempfile::tempdir().unwrap(); write_fixture(tmp.path(), flavor); - let tgz = vendor_project(tmp.path(), &mock.uri()); + let tgz = vendor_project(tmp.path(), &mock.uri(), flavor); let lock1 = std::fs::read(tmp.path().join(flavor.lock_name())).unwrap(); // The whole .socket/vendor tree (state.json included) is gone — only the @@ -521,7 +676,7 @@ async fn ledger_gone_drifted_copy_fails_closed(flavor: Flavor) { mount_patch_api(&mock).await; let tmp = tempfile::tempdir().unwrap(); write_fixture(tmp.path(), flavor); - let tgz = vendor_project(tmp.path(), &mock.uri()); + let tgz = vendor_project(tmp.path(), &mock.uri(), flavor); let lock1 = std::fs::read(tmp.path().join(flavor.lock_name())).unwrap(); // Drift an UNPATCHED part of the installed copy (patched-file tampering @@ -579,7 +734,7 @@ async fn repair_fails_closed_on_drifted_copy_yarn_berry() { #[tokio::test] async fn repair_fails_closed_on_drifted_copy_bun() { - ledger_gone_drifted_copy_fails_closed(Flavor::Bun).await; + ledger_gone_drifted_copy_fails_closed(Flavor::Bun(BunLock::V1)).await; } #[tokio::test] @@ -613,7 +768,7 @@ async fn revert_of_reconstructed_pnpm_entry_fails_closed_then_recovers() { write_fixture(tmp.path(), Flavor::Pnpm); let lock_pre = std::fs::read(tmp.path().join("pnpm-lock.yaml")).unwrap(); let pkg_pre = std::fs::read(tmp.path().join("package.json")).unwrap(); - let tgz = vendor_project(tmp.path(), &mock.uri()); + let tgz = vendor_project(tmp.path(), &mock.uri(), Flavor::Pnpm); let lock_vendored = std::fs::read(tmp.path().join("pnpm-lock.yaml")).unwrap(); // Ledger gone; artifact + rewired lock intact (the empirical shape). @@ -724,5 +879,5 @@ async fn repair_reconstructs_yarn_berry_ledger_from_lockfile() { #[tokio::test] async fn repair_reconstructs_bun_ledger_from_lockfile() { - ledger_gone_reconstructs_from_lock(Flavor::Bun).await; + ledger_gone_reconstructs_from_lock(Flavor::Bun(BunLock::V1)).await; } diff --git a/crates/socket-patch-cli/tests/scan_vendor_e2e.rs b/crates/socket-patch-cli/tests/scan_vendor_e2e.rs index a8ff8903..a976bb03 100644 --- a/crates/socket-patch-cli/tests/scan_vendor_e2e.rs +++ b/crates/socket-patch-cli/tests/scan_vendor_e2e.rs @@ -1679,3 +1679,180 @@ async fn scan_apply_skips_lockfile_only_without_error() { "no manifest entry is written for a not-installed package" ); } + +// --------------------------------------------------------------------------- +// Bun vendored-mode preflight through `scan`: download phase, --detached, +// --silent +// --------------------------------------------------------------------------- + +const BUN_WS_CODE: &str = "vendor_bun_workspace_unsupported"; + +/// `write_fixture` re-locked by bun 1.3.14 as a workspace: the real +/// lockfileVersion-1 grammar (1-tuple `workspace:` entry, blank line +/// between entries, trailing commas; registry integrity from the BN3 spike +/// fixture), left-pad declared by the member — the shape the vendored gate +/// refuses (bun < 1.4 resolves member tarball paths relative to the member). +fn write_bun_v1_workspace_fixture(root: &Path) { + write_fixture(root); + std::fs::remove_file(root.join("package-lock.json")).unwrap(); + std::fs::write( + root.join("package.json"), + r#"{ "name": "scan-vendor-test", "version": "0.0.0", "private": true, "workspaces": ["packages/*"], "dependencies": { "consumer": "workspace:*" } }"#, + ) + .unwrap(); + let consumer = root.join("packages/consumer"); + std::fs::create_dir_all(&consumer).unwrap(); + std::fs::write( + consumer.join("package.json"), + r#"{ "name": "consumer", "version": "1.0.0", "dependencies": { "left-pad": "1.3.0" } }"#, + ) + .unwrap(); + std::fs::write( + root.join("bun.lock"), + "{\n \"lockfileVersion\": 1,\n \"configVersion\": 1,\n \"workspaces\": {\n \"\": {\n \"name\": \"scan-vendor-test\",\n \"dependencies\": {\n \"consumer\": \"workspace:*\",\n },\n },\n \"packages/consumer\": {\n \"name\": \"consumer\",\n \"version\": \"1.0.0\",\n \"dependencies\": {\n \"left-pad\": \"1.3.0\",\n },\n },\n },\n \"packages\": {\n \"consumer\": [\"consumer@workspace:packages/consumer\"],\n\n \"left-pad\": [\"left-pad@1.3.0\", \"\", {}, \"sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==\"],\n }\n}\n", + ) + .unwrap(); +} + +/// The manifest-tracked vendored scan refuses a v1 workspace lock IN THE +/// DOWNLOAD PHASE: the record is `failed` with the vendor code + detail, +/// nothing is fetched (request-log oracle), the lock is byte-identical, +/// nothing is vendored, and the manifest — written by contract — holds no +/// record for the refused purl. +#[tokio::test] +async fn scan_vendored_bun_v1_workspace_refuses_in_download_phase() { + let mock = MockServer::start().await; + mount_patch_api(&mock, UUID).await; + let tmp = tempfile::tempdir().unwrap(); + write_bun_v1_workspace_fixture(tmp.path()); + let lock_before = std::fs::read(tmp.path().join("bun.lock")).unwrap(); + + let (code, stdout, stderr) = run_scan_vendor(tmp.path(), &mock.uri(), &["--mode", "vendored"]); + assert_eq!(code, 1, "stdout={stdout}; stderr={stderr}"); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + assert_eq!(v["status"], "partial_failure", "envelope={v}"); + let dl = &v["download"]; + assert_eq!(dl["found"], 1, "envelope={v}"); + assert_eq!(dl["downloaded"], 0, "envelope={v}"); + assert_eq!(dl["failed"], 1, "envelope={v}"); + assert_eq!(dl["patches"][0]["purl"], PURL, "envelope={v}"); + assert_eq!(dl["patches"][0]["action"], "failed", "envelope={v}"); + assert_eq!(dl["patches"][0]["errorCode"], BUN_WS_CODE, "envelope={v}"); + assert!( + dl["patches"][0]["error"] + .as_str() + .is_some_and(|d| !d.is_empty()), + "the refused record carries the engine's detail: {v}" + ); + assert_eq!(v["vendor"]["summary"]["applied"], 0, "envelope={v}"); + + let reqs = mock.received_requests().await.unwrap(); + assert!( + !reqs.iter().any(|r| r.url.path().contains("/patches/view/")), + "a refused patch must never be fetched" + ); + assert_eq!( + std::fs::read(tmp.path().join("bun.lock")).unwrap(), + lock_before, + "bun.lock must be byte-identical" + ); + assert!(!tmp.path().join(".socket/vendor").exists()); + let manifest: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).unwrap(), + ) + .unwrap(); + assert_eq!( + manifest, + serde_json::json!({ "patches": {} }), + "no record may be claimed for the refused purl" + ); +} + +/// The `--detached` twin refuses BEFORE any fetch too (it used to fetch the +/// view and only fail in the vendor step): same record, zero downloads, +/// and — detached — no manifest at all. +#[tokio::test] +async fn scan_vendored_bun_detached_refuses_before_fetch() { + let mock = MockServer::start().await; + mount_patch_api(&mock, UUID).await; + let tmp = tempfile::tempdir().unwrap(); + write_bun_v1_workspace_fixture(tmp.path()); + let lock_before = std::fs::read(tmp.path().join("bun.lock")).unwrap(); + + let (code, stdout, stderr) = run_scan_vendor( + tmp.path(), + &mock.uri(), + &["--mode", "vendored", "--detached"], + ); + assert_eq!(code, 1, "stdout={stdout}; stderr={stderr}"); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + assert_eq!(v["status"], "partial_failure", "envelope={v}"); + assert_eq!(v["download"]["detached"], true, "envelope={v}"); + assert_eq!(v["download"]["downloaded"], 0, "envelope={v}"); + assert_eq!(v["download"]["failed"], 1, "envelope={v}"); + assert_eq!( + v["download"]["patches"][0]["action"], "failed", + "envelope={v}" + ); + assert_eq!( + v["download"]["patches"][0]["errorCode"], BUN_WS_CODE, + "envelope={v}" + ); + let reqs = mock.received_requests().await.unwrap(); + assert!( + !reqs.iter().any(|r| r.url.path().contains("/patches/view/")), + "detached must refuse before fetching" + ); + assert!( + !tmp.path().join(".socket/manifest.json").exists(), + "detached mode never writes a manifest" + ); + assert!(!tmp.path().join(".socket/vendor").exists()); + assert_eq!( + std::fs::read(tmp.path().join("bun.lock")).unwrap(), + lock_before + ); +} + +/// The interactive (`--silent`, non-JSON) arm: "errors only" means the +/// refusal line — code-tagged, naming the purl — stays on stderr while +/// stdout is empty, exit 1. Regression guard: the line was gated on +/// `!silent`, so a `--silent` scan exited 1 with no text at all. +#[tokio::test] +async fn scan_vendored_bun_silent_human_names_code_on_stderr() { + let mock = MockServer::start().await; + mount_patch_api(&mock, UUID).await; + let tmp = tempfile::tempdir().unwrap(); + write_bun_v1_workspace_fixture(tmp.path()); + + let uri = mock.uri(); + let (code, stdout, stderr) = run_cli_env( + tmp.path(), + &[ + "scan", + "--mode", + "vendored", + "--vendor-source", + "build", + "--silent", + "--yes", + "--api-url", + &uri, + "--api-token", + "fake-token", + "--org", + ORG_SLUG, + ], + &[], + ); + assert_eq!(code, 1, "stdout={stdout}; stderr={stderr}"); + assert!( + stdout.trim().is_empty(), + "--silent must print nothing on stdout:\n{stdout}" + ); + assert!( + stderr.contains(&format!("[error] {PURL} ({BUN_WS_CODE}):")), + "--silent must keep the code-tagged refusal on stderr:\n{stderr}" + ); + assert!(!tmp.path().join(".socket/vendor").exists()); +} diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index e4f0d016..b31c5742 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -242,7 +242,10 @@ pub fn pipenv_lock_targets(files: &BTreeMap, overrides: &[DepOve /// Whether a live Pipfile.lock entry is the one Socket wrote, re-serialized by /// a Pipenv relock (same `file`/`path` reference; only `hashes`/`version`/ /// `index` may differ). Shared with the vendored backend's revert. -pub fn pipenv_reserialized_around_reference(live: &serde_json::Value, ours: &serde_json::Value) -> bool { +pub fn pipenv_reserialized_around_reference( + live: &serde_json::Value, + ours: &serde_json::Value, +) -> bool { pipenv::reserialized_around_reference(live, ours) } @@ -2374,14 +2377,81 @@ fn rewrite_yarn_berry( // Binary `bun.lockb` is NEVER parsed — its presence (without a text `bun.lock`) // is a documented refusal. Uses the shared `bun_lock_text` grammar (fail-CLOSED // on any deviation). Byte-for-byte twin of the TS `rewriteBun`. +/// Check a text Bun lock before reverting any existing vendored wiring. +/// Uses the rewriter's own version, grammar and workspace compatibility rules. +pub fn preflight_bun_hosted(content: &str) -> Result<(), RewriteWarning> { + parse_bun_hosted_lock(content).map(|_| ()) +} + +fn parse_bun_hosted_lock( + content: &str, +) -> Result<(Vec, Vec), RewriteWarning> { + use crate::vendor::bun_lock_text::{ + check_lock_version, has_workspace_packages, lock_version, parse_packages_section, + }; + + // The shared gate's `Err` text IS the detail: hosted and vendored refuse + // an unsupported head with one message (and one remedy per arm — a + // future version means "update socket-patch", a missing integer means + // "re-lock"), so the two modes cannot drift apart. + if let Err(detail) = check_lock_version(content) { + return Err(RewriteWarning { + code: "redirect_bun_lock_unsupported".into(), + detail, + }); + } + let lines: Vec = content.split('\n').map(str::to_string).collect(); + let entries = match parse_packages_section(&lines) { + Ok(entries) => entries, + Err(_) => { + // Fail-closed: never line-splice a lock whose packages section + // deviates from bun's emitted single-line grammar. + return Err(RewriteWarning { + code: "redirect_bun_lock_unsupported".into(), + detail: "bun.lock packages section is not in bun's emitted single-line shape" + .into(), + }); + } + }; + + // Version-0 locks (bun 1.1.39–1.1.45's opt-in text lockfile) with a + // `workspace:` member are refused. The remedy that converges on every + // release (measured against real Bun 1.2.0, 1.2.23, 1.3.0, 1.3.9, + // 1.3.14, 1.4.0–1.4.2) is `rm bun.lock && bun install` with Bun ≥ 1.2: + // 1.2–1.3 write lockfileVersion 1, 1.4 writes 2, both accepted here. A + // plain IN-PLACE `bun install` bumps a v0 workspace lock to 1 only when + // some workspace depends on another workspace (root → member, as in the + // backtest's `workspace` shapes, or member → member): Bun ≥ 1.2 re-saves + // the bare-path spelling of that dependency as `workspace:*`, which + // forces the save. Without such a dependency (a root that only lists + // `workspaces`), 1.2.0 exits 0 and keeps 0, and 1.2.23–1.4.2 exit 1 with + // `@ failed to resolve` and keep 0 — so the in-place bump is + // stated as conditional, never as the remedy. (A v0 lock WITHOUT + // workspaces is kept at 0 by an in-place install on 1.2.0, 1.2.23 and + // 1.3.0 and bumped to 1 by 1.3.9 and every later release; that case is + // accepted here either way.) + if lock_version(content) == Some(0) && has_workspace_packages(&entries) { + return Err(RewriteWarning { + code: "redirect_bun_workspace_unsupported".into(), + detail: "Bun version-0 workspace locks cannot preserve hosted tarballs on frozen \ + installs; delete bun.lock and re-run `bun install` with Bun >= 1.2 (which \ + writes lockfileVersion 1, accepted by hosted mode) — a plain in-place `bun \ + install` bumps the version only when a workspace depends on another \ + workspace (e.g. root -> member); otherwise it keeps version 0 or fails to \ + resolve" + .into(), + }); + } + + Ok((lines, entries)) +} + fn rewrite_bun_lock( files: &BTreeMap, overrides: &[DepOverride], result: &mut RewriteResult, ) { - use crate::vendor::bun_lock_text::{ - check_lock_version, decode_json_string, parse_packages_section, - }; + use crate::vendor::bun_lock_text::decode_json_string; let npm: Vec<&DepOverride> = overrides.iter().filter(|o| o.ecosystem == "npm").collect(); if npm.is_empty() { @@ -2401,24 +2471,10 @@ fn rewrite_bun_lock( let Some(content) = files.get("bun.lock") else { return; }; - if check_lock_version(content).is_err() { - result.warnings.push(RewriteWarning { - code: "redirect_bun_lock_unsupported".into(), - detail: "bun.lock lockfileVersion is not 1 or 2; re-lock with bun >= 1.3".into(), - }); - return; - } - let mut lines: Vec = content.split('\n').map(str::to_string).collect(); - let entries = match parse_packages_section(&lines) { - Ok(entries) => entries, - Err(_) => { - // Fail-closed: never line-splice a lock whose packages section - // deviates from bun's emitted single-line grammar. - result.warnings.push(RewriteWarning { - code: "redirect_bun_lock_unsupported".into(), - detail: "bun.lock packages section is not in bun's emitted single-line shape" - .into(), - }); + let (mut lines, entries) = match parse_bun_hosted_lock(content) { + Ok(parsed) => parsed, + Err(warning) => { + result.warnings.push(warning); return; } }; @@ -2449,24 +2505,35 @@ fn rewrite_bun_lock( { // Registry 4-tuple → URL 3-tuple. Deps object preserved verbatim. deps_verbatim = entry.elems[2].clone(); - } else if entry.elems.len() == 3 && spec == url_spec { - // Already one of our URL 3-tuples for this exact URL. Idempotent - // if the integrity already matches; otherwise refresh it. + } else if matches!(entry.elems.len(), 2 | 3) && spec == url_spec { + // Already one of our URL tuples for this exact URL. A 3-tuple + // is idempotent if the integrity already matches and is + // refreshed otherwise. A 2-tuple is our wiring with its digest + // DROPPED: Bun 1.1.39–1.3.9 re-save a URL tuple without its + // `"sha512-…"` on any lock re-save (`bun add`, `bun install` + // after a manifest change) — the spec bun installs from is + // intact, so the patch still lands, but ≥ 1.3.10 consumers of + // the same lock lose digest verification. HEAL it back to the + // canonical 3-tuple; the edit below records the 2-tuple as its + // `original`, and replay accepts that spelling of a recorded + // `new` (`bun_lock_text::same_wiring_modulo_integrity`), so + // the chain still unwinds to the pristine registry line. matched_any = true; - if entry.elems[2] == format!("\"{sha512}\"") { + if entry.elems.len() == 3 && entry.elems[2] == format!("\"{sha512}\"") { continue; } deps_verbatim = entry.elems[1].clone(); - } else if entry.elems.len() == 3 + } else if matches!(entry.elems.len(), 2 | 3) && entry.elems[1].starts_with('{') && is_prior_hosted_bun_spec(&spec, &fname, &dep.artifact_url) { - // A URL 3-tuple written by an EARLIER redirect whose artifact + // A URL tuple written by an EARLIER redirect whose artifact // URL has since changed (a patch republish rotates the uuid // path segment; grant-token rotation changes the token — the // registry `name@version` spec was destroyed by that first // rewrite, so exact-URL matching alone would strand the stale - // pin forever). Re-pin to the current URL. Ownership is + // pin forever). Re-pin to the current URL — from the 3-tuple + // or from its digest-less 2-tuple re-save alike. Ownership is // claimed narrowly — same origin and same `- // .tgz` leaf as the CURRENT artifact URL — so user URL deps // and other-version entries never match (fail-closed). @@ -2478,8 +2545,15 @@ fn rewrite_bun_lock( } matched_any = true; let original = lines[entry.line_idx].clone(); + // Lines come from a bare `split('\n')`, so a CRLF lock's lines + // carry a trailing `\r` (the grammar trims it away when parsing). + // Re-emit it verbatim — mirroring `vendor/bun_lock.rs` — so the + // rewritten line never becomes the lone LF line of a CRLF file, + // and the ledger `new` fragment matches the on-disk bytes the + // way `original` already does (replay matches fragments exactly). + let cr = if original.ends_with('\r') { "\r" } else { "" }; let rebuilt = format!( - "{indent}{key}: [{url}, {deps}, {integrity}]{comma}", + "{indent}{key}: [{url}, {deps}, {integrity}]{comma}{cr}", indent = entry.indent, key = entry.key_raw, url = serde_json::to_string(&url_spec) @@ -2758,7 +2832,9 @@ fn rewrite_uv_lock( continue; } Err(detail) => { - result.refused_python_lock_uuids.insert(dep.patch_uuid.clone()); + result + .refused_python_lock_uuids + .insert(dep.patch_uuid.clone()); result.warnings.push(RewriteWarning { code: "redirect_uv_lock_unsupported".into(), detail: format!("{path}: {detail}"), @@ -2770,7 +2846,9 @@ fn rewrite_uv_lock( match plan_python_metadata(path, &content, files, dep, result) { Ok(plan) => plan, Err(warning) => { - result.refused_python_lock_uuids.insert(dep.patch_uuid.clone()); + result + .refused_python_lock_uuids + .insert(dep.patch_uuid.clone()); result.warnings.push(warning); continue; } @@ -2785,7 +2863,9 @@ fn rewrite_uv_lock( ) { Ok(rewritten) => rewritten, Err(detail) => { - result.refused_python_lock_uuids.insert(dep.patch_uuid.clone()); + result + .refused_python_lock_uuids + .insert(dep.patch_uuid.clone()); result.warnings.push(RewriteWarning { code: "redirect_uv_metadata_unsupported".into(), detail: format!("{path}: {detail}"), @@ -2793,7 +2873,9 @@ fn rewrite_uv_lock( continue; } }; - result.confirmed_python_lock_uuids.insert(dep.patch_uuid.clone()); + result + .confirmed_python_lock_uuids + .insert(dep.patch_uuid.clone()); if let Some(edit) = metadata_edit { record_python_metadata_edit(edit, dep, result); } @@ -6604,10 +6686,46 @@ mod tests { assert!(r.files.is_empty()); assert_eq!(r.warnings[0].code, "redirect_bun_lock_unsupported"); assert!( - r.warnings[0].detail.contains("not 1 or 2"), - "the refusal must name the supported versions: {}", + r.warnings[0] + .detail + .contains("lockfileVersion 3, newer than this socket-patch release supports") + && r.warnings[0].detail.contains("(0, 1 and 2)") + && r.warnings[0].detail.contains("update socket-patch"), + "the refusal must name the found version, the supported set and a remedy that \ + can work (a v3 lock was written by a NEWER Bun): {}", + r.warnings[0].detail + ); + // One message for both modes: the hosted detail IS the shared gate's + // error text, so vendored and hosted refusals cannot drift apart. + assert_eq!( + r.warnings[0].detail, + crate::vendor::bun_lock_text::check_lock_version(&files["bun.lock"]).unwrap_err() + ); + + // No integer lockfileVersion at all → the OTHER remedy (re-lock). + let mut files = BTreeMap::new(); + files.insert( + "bun.lock".to_string(), + "{\n \"packages\": {\n \ + \"left-pad\": [\"left-pad@1.3.0\", \"\", {}, \"sha512-OLD==\"],\n }\n}\n" + .to_string(), + ); + let mut r = RewriteResult::default(); + rewrite_bun_lock(&files, std::slice::from_ref(&ovr), &mut r); + assert!(r.files.is_empty()); + assert_eq!(r.warnings[0].code, "redirect_bun_lock_unsupported"); + assert!( + r.warnings[0].detail.contains("no integer lockfileVersion") + && r.warnings[0] + .detail + .contains("re-lock with Bun ≥ 1.2 (`bun install`)"), + "a head without an integer version must point at a Bun re-lock: {}", r.warnings[0].detail ); + assert_eq!( + r.warnings[0].detail, + crate::vendor::bun_lock_text::check_lock_version(&files["bun.lock"]).unwrap_err() + ); // Non-single-line packages section → fail-closed refusal. let mut files = BTreeMap::new(); @@ -6733,6 +6851,164 @@ mod tests { assert_eq!(r.warnings[0].code, "redirect_bun_entry_not_found"); } + /// bun's REAL emitted workspace-lock shape (captured from bun 1.1.45 at + /// lockfileVersion 0 and from 1.3.14 / 1.4.2 at 1 / 2): trailing commas + /// throughout and a blank line between packages entries. Only the + /// version integer and the packages entries vary per test; the root + /// workspace dep is spelled as the bare path bun 1.1.x writes (≥ 1.2 + /// writes `workspace:*`) — the rewriter never reads that block. + fn bun_workspace_lock(version: u64, entries: &[&str]) -> String { + format!( + "{{\n \"lockfileVersion\": {version},\n \"workspaces\": {{\n \"\": {{\n \ + \"name\": \"bun-patch-backtest\",\n \"dependencies\": {{\n \ + \"consumer\": \"packages/consumer\",\n }},\n }},\n \ + \"packages/consumer\": {{\n \"name\": \"consumer\",\n \"version\": \ + \"1.0.0\",\n \"dependencies\": {{\n \"left-pad\": \"1.3.0\",\n \ + }},\n }},\n }},\n \"packages\": {{\n{}\n }}\n}}\n", + entries.join("\n\n") + ) + } + + /// The bun 1.1.39–1.1.45 (lockfileVersion 0) workspace refusal, on the + /// grammar those releases actually write — the 2-tuple + /// `["consumer@workspace:packages/consumer", { "dependencies": {…} }]` + /// (v1/v2 write a 1-tuple) — and its positive twins: the SAME entries at + /// lockfileVersion 1 and 2 must rewrite with the workspace line kept + /// byte-identical, proving the gate is version-0-only and not "any + /// workspace lock". Mutations this pins: dropping the `Some(0)` half of + /// the gate, renaming the code, or regressing the remedy text. + #[test] + fn bun_lock_v0_workspace_refuses_and_v1_v2_workspace_rewrite() { + let sha512 = format!("sha512-{}==", "A".repeat(86)); + let ovr = npm_override("left-pad", "1.3.0", "http://p.test/lp.tgz", &sha512); + let ws_v0 = " \"consumer\": [\"consumer@workspace:packages/consumer\", \ + { \"dependencies\": { \"left-pad\": \"1.3.0\" } }],"; + let registry = " \"left-pad\": [\"left-pad@1.3.0\", \"\", {}, \"sha512-OLD==\"],"; + + // Version 0 + workspace member → refused, byte-untouched, exactly one + // warning (no entry-not-found double-warn) with the verified remedy. + let mut files = BTreeMap::new(); + files.insert( + "bun.lock".to_string(), + bun_workspace_lock(0, &[ws_v0, registry]), + ); + let mut r = RewriteResult::default(); + rewrite_bun_lock(&files, std::slice::from_ref(&ovr), &mut r); + assert!(r.files.is_empty() && r.edits.is_empty(), "{:?}", r.files); + assert_eq!(r.warnings.len(), 1, "{:?}", r.warnings); + assert_eq!(r.warnings[0].code, "redirect_bun_workspace_unsupported"); + let detail = &r.warnings[0].detail; + assert_eq!( + detail, + "Bun version-0 workspace locks cannot preserve hosted tarballs on frozen installs; \ + delete bun.lock and re-run `bun install` with Bun >= 1.2 (which writes \ + lockfileVersion 1, accepted by hosted mode) — a plain in-place `bun install` bumps \ + the version only when a workspace depends on another workspace (e.g. root -> \ + member); otherwise it keeps version 0 or fails to resolve", + "the refusal must lead with the remedy that converges on every release \ + (delete + re-lock) and state the in-place bump as CONDITIONAL" + ); + assert!( + !detail.contains("rewrites the lock as lockfileVersion 1"), + "the old unconditional in-place claim must be gone: {detail}" + ); + + // The SAME entries at lockfileVersion 1 and 2 → rewritten; the + // workspace line survives byte-for-byte; no warning of any kind. + for version in [1u64, 2] { + let mut files = BTreeMap::new(); + files.insert( + "bun.lock".to_string(), + bun_workspace_lock(version, &[ws_v0, registry]), + ); + let mut r = RewriteResult::default(); + rewrite_bun_lock(&files, std::slice::from_ref(&ovr), &mut r); + assert!(r.warnings.is_empty(), "v{version}: {:?}", r.warnings); + let out = r + .files + .get("bun.lock") + .unwrap_or_else(|| panic!("a v{version} workspace lock must be rewritten")); + assert!( + out.contains(&format!("\"lockfileVersion\": {version},")), + "{out}" + ); + assert!( + out.contains(&format!("{ws_v0}\n")), + "v{version}: the workspace line must be byte-identical: {out}" + ); + assert!( + out.contains("\"left-pad\": [\"left-pad@http://p.test/lp.tgz\", {}, \"sha512-"), + "v{version}: the registry tuple must become the URL 3-tuple: {out}" + ); + assert_eq!(r.edits.len(), 1, "v{version}: {:?}", r.edits); + assert_eq!(r.edits[0].key.as_deref(), Some("left-pad")); + assert_eq!( + out, + &bun_workspace_lock( + version, + &[ + ws_v0, + &format!( + " \"left-pad\": [\"left-pad@http://p.test/lp.tgz\", {{}}, \ + \"{sha512}\"]," + ) + ] + ), + "v{version}: only the target line may change" + ); + } + + // The 1-tuple spelling bun ≥ 1.2 actually writes for a workspace + // member is rewritten the same way (the gate reads the spec only). + let ws_v1 = " \"consumer\": [\"consumer@workspace:packages/consumer\"],"; + let mut files = BTreeMap::new(); + files.insert( + "bun.lock".to_string(), + bun_workspace_lock(1, &[ws_v1, registry]), + ); + let mut r = RewriteResult::default(); + rewrite_bun_lock(&files, std::slice::from_ref(&ovr), &mut r); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + let out = r + .files + .get("bun.lock") + .expect("real v1 grammar must rewrite"); + assert!(out.contains(&format!("{ws_v1}\n")), "{out}"); + assert!(out.contains("left-pad@http://p.test/lp.tgz"), "{out}"); + } + + /// A version-0 lock whose only `workspaces` key is the root `""` (bun + /// 1.1.45 `--save-text-lockfile` on a plain project — captured grammar: + /// no `configVersion`, trailing commas) has no `workspace:` member and + /// must be rewritten, not refused: the gate is "v0 AND a workspace + /// member", not "v0". + #[test] + fn bun_lock_v0_root_only_workspace_rewrites() { + let sha512 = format!("sha512-{}==", "A".repeat(86)); + let ovr = npm_override("left-pad", "1.3.0", "http://p.test/lp.tgz", &sha512); + let lock = "{\n \"lockfileVersion\": 0,\n \"workspaces\": {\n \"\": {\n \ + \"name\": \"bun-patch-backtest\",\n \"dependencies\": {\n \ + \"left-pad\": \"1.3.0\",\n },\n },\n },\n \"packages\": {\n \ + \"left-pad\": [\"left-pad@1.3.0\", \"\", {}, \"sha512-OLD==\"],\n }\n}\n"; + let mut files = BTreeMap::new(); + files.insert("bun.lock".to_string(), lock.to_string()); + let mut r = RewriteResult::default(); + rewrite_bun_lock(&files, std::slice::from_ref(&ovr), &mut r); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + let out = r + .files + .get("bun.lock") + .expect("a root-only v0 lock must be rewritten"); + assert_eq!( + out, + &lock.replace( + "[\"left-pad@1.3.0\", \"\", {}, \"sha512-OLD==\"]", + &format!("[\"left-pad@http://p.test/lp.tgz\", {{}}, \"{sha512}\"]") + ) + ); + assert_eq!(r.edits.len(), 1); + } + /// A granted dep that matches no rewritable tuple (lock re-resolved to a /// different version) must warn — mirroring pnpm/berry/uv — instead of /// silently dropping out of the `redirected` count. @@ -6774,6 +7050,68 @@ mod tests { assert!(r.warnings.is_empty(), "{:?}", r.warnings); } + /// A CRLF bun.lock (Windows `core.autocrlf` checkout) must keep CRLF on + /// the REWRITTEN line too — the vendored engine already does — so the + /// file never ends up mixed-EOL, and the ledger `new` fragment carries + /// the same on-disk `\r` as `original` (replay matches fragments + /// byte-exactly: an LF `new` would no longer be found after an autocrlf + /// commit/checkout round-trip, leaving `\r\r\n` on revert). Modelled on + /// `yarn_classic_crlf_lock_rewrites_only_the_target_entry`. + #[test] + fn bun_crlf_lock_keeps_crlf_on_rewritten_line() { + let sha512 = format!("sha512-{}==", "A".repeat(86)); + let ovr = npm_override("left-pad", "1.3.0", "http://p.test/lp.tgz", &sha512); + let decoy = " \"abbrev\": [\"abbrev@1.1.1\", \"\", {}, \"sha512-DECOYdecoy==\"],"; + let target = " \"left-pad\": [\"left-pad@1.3.0\", \"\", {}, \"sha512-OLD==\"],"; + let lf_lock = bun_workspace_lock(2, &[decoy, target]); + + let mut files = BTreeMap::new(); + files.insert("bun.lock".to_string(), lf_lock.replace('\n', "\r\n")); + let mut r = RewriteResult::default(); + rewrite_bun_lock(&files, std::slice::from_ref(&ovr), &mut r); + assert!(r.warnings.is_empty(), "clean rewrite: {:?}", r.warnings); + let out = r.files.get("bun.lock").expect("bun.lock must be rewritten"); + assert!( + out.contains(&format!("{decoy}\r\n")), + "the decoy entry must stay byte-identical: {out}" + ); + assert!( + out.contains(&format!( + " \"left-pad\": [\"left-pad@http://p.test/lp.tgz\", {{}}, \"{sha512}\"],\r\n" + )), + "the target entry must pin the hosted artifact AND keep its CRLF: {out}" + ); + assert_eq!( + out.matches('\n').count(), + out.matches("\r\n").count(), + "every line must keep its CRLF ending: {out}" + ); + + // The CRLF output is exactly the LF rewrite re-expanded. + let mut lf_files = BTreeMap::new(); + lf_files.insert("bun.lock".to_string(), lf_lock); + let mut lf_r = RewriteResult::default(); + rewrite_bun_lock(&lf_files, std::slice::from_ref(&ovr), &mut lf_r); + assert_eq!( + out, + &lf_r.files["bun.lock"].replace('\n', "\r\n"), + "CRLF rewrite must equal the LF rewrite modulo line endings" + ); + + // Ledger fragments carry the on-disk (CR-bearing) byte form on BOTH + // sides, so revert finds `new` and restores `original` byte-exactly. + assert_eq!(r.edits.len(), 1); + let original = r.edits[0].original.as_ref().unwrap().as_str().unwrap(); + let new = r.edits[0].new.as_ref().unwrap().as_str().unwrap(); + assert_eq!( + original, + format!("{target}\r"), + "original must carry the \\r" + ); + assert!(new.ends_with("],\r"), "new must carry the \\r too: {new:?}"); + assert!(!new.contains('\n') && !original.contains('\n')); + } + /// A packages header spelled any way other than bun's byte-exact emitted /// shape must fail CLOSED with the unsupported warning — not parse as an /// empty lock and silently skip the dep. @@ -12082,6 +12420,155 @@ packages: assert!(r.warnings.is_empty(), "{:?}", r.warnings); } + /// Bun 1.1.39–1.3.9 re-save our URL 3-tuple WITHOUT its sha512 on any + /// later lock re-save (`bun add`, `bun install` after a manifest + /// change) — verified on real 1.2.23 and 1.3.9; 1.3.10+ keep it. A + /// 2-tuple at the CURRENT artifact URL is our wiring with its digest + /// dropped: heal it back to the 3-tuple (the edit records the 2-tuple + /// as `original`), never warn `redirect_bun_entry_not_found`, and stay + /// a no-op on the healed lock. + #[test] + fn bun_lock_digestless_current_url_tuple_is_healed() { + let sha = format!("sha512-{}==", "N".repeat(86)); + let url = "https://patch.socket.dev/patch/npm/tok-1111/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/left-pad-1.3.0.tgz"; + let ovr = npm_override("left-pad", "1.3.0", url, &sha); + let digestless = format!("\"left-pad\": [\"left-pad@{url}\", {{}}],"); + let healed = format!("\"left-pad\": [\"left-pad@{url}\", {{}}, \"{sha}\"],"); + + let mut files = BTreeMap::new(); + files.insert("bun.lock".to_string(), bun_lock_file(&digestless, 1)); + let mut r = RewriteResult::default(); + rewrite_bun_lock(&files, std::slice::from_ref(&ovr), &mut r); + let out = r + .files + .get("bun.lock") + .expect("the digest-less tuple must be healed"); + assert_eq!( + out, + &bun_lock_file(&healed, 1), + "healed back to the canonical 3-tuple, byte-exact" + ); + assert!( + r.warnings.is_empty(), + "a digest-less instance of our own wiring is not `entry_not_found`: {:?}", + r.warnings + ); + assert_eq!(r.edits.len(), 1, "{:?}", r.edits); + assert_eq!(r.edits[0].key.as_deref(), Some("left-pad")); + assert_eq!( + r.edits[0].original.as_ref().and_then(Value::as_str), + Some(format!(" {digestless}").as_str()), + "the heal records the 2-tuple it found as its original" + ); + assert_eq!( + r.edits[0].new.as_ref().and_then(Value::as_str), + Some(format!(" {healed}").as_str()) + ); + + // The healed lock is in sync: no files, no edits, no warnings. + let mut files = BTreeMap::new(); + files.insert("bun.lock".to_string(), out.clone()); + let mut r = RewriteResult::default(); + rewrite_bun_lock(&files, std::slice::from_ref(&ovr), &mut r); + assert!(r.files.is_empty() && r.edits.is_empty(), "{:?}", r.edits); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + + // CRLF lock: the healed line keeps its `\r`, and both ledger + // fragments carry it (replay matches bytes). + let mut files = BTreeMap::new(); + files.insert( + "bun.lock".to_string(), + bun_lock_file(&digestless, 1).replace('\n', "\r\n"), + ); + let mut r = RewriteResult::default(); + rewrite_bun_lock(&files, std::slice::from_ref(&ovr), &mut r); + let out = r.files.get("bun.lock").expect("CRLF heal"); + assert_eq!(out, &bun_lock_file(&healed, 1).replace('\n', "\r\n")); + assert_eq!( + r.edits[0].original.as_ref().and_then(Value::as_str), + Some(format!(" {digestless}\r").as_str()) + ); + assert_eq!( + r.edits[0].new.as_ref().and_then(Value::as_str), + Some(format!(" {healed}\r").as_str()) + ); + } + + /// The digest-less re-save of a STALE hosted URL (an earlier grant's + /// token/uuid) is re-pinned to the current URL exactly like its 3-tuple + /// form; ownership stays origin + `-.tgz` leaf, so a + /// foreign-origin or other-version 2-tuple is never claimed and a + /// 2-tuple carrying the bare registry spec (not a shape bun emits for a + /// registry package) is not rewritten either. + #[test] + fn bun_lock_digestless_stale_url_tuple_is_repinned_and_unowned_ones_are_not() { + let new_sha = format!("sha512-{}==", "N".repeat(86)); + let old_url = "https://patch.socket.dev/patch/npm/oldtoken-1111/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/left-pad-1.3.0.tgz"; + let new_url = "https://patch.socket.dev/patch/npm/newtoken-2222/bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb/left-pad-1.3.0.tgz"; + let ovr = npm_override("left-pad", "1.3.0", new_url, &new_sha); + + let stale = format!("\"left-pad\": [\"left-pad@{old_url}\", {{}}],"); + let mut files = BTreeMap::new(); + files.insert("bun.lock".to_string(), bun_lock_file(&stale, 1)); + let mut r = RewriteResult::default(); + rewrite_bun_lock(&files, std::slice::from_ref(&ovr), &mut r); + let out = r + .files + .get("bun.lock") + .expect("stale digest-less URL must be re-pinned"); + assert_eq!( + out, + &bun_lock_file( + &format!("\"left-pad\": [\"left-pad@{new_url}\", {{}}, \"{new_sha}\"],"), + 1 + ) + ); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert_eq!(r.edits.len(), 1); + assert_eq!( + r.edits[0].original.as_ref().and_then(Value::as_str), + Some(format!(" {stale}").as_str()) + ); + + for unowned in [ + // Foreign origin, same leaf: a user's own URL dep. + "\"left-pad\": [\"left-pad@https://example.com/mirror/left-pad-1.3.0.tgz\", {}],", + // Our origin, another version's leaf. + "\"left-pad\": [\"left-pad@https://patch.socket.dev/patch/npm/oldtoken-1111/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/left-pad-1.2.0.tgz\", {}],", + // Registry spec in a 2-tuple: not bun's registry grammar. + "\"left-pad\": [\"left-pad@1.3.0\", {}],", + ] { + let mut files = BTreeMap::new(); + files.insert("bun.lock".to_string(), bun_lock_file(unowned, 1)); + let mut r = RewriteResult::default(); + rewrite_bun_lock(&files, std::slice::from_ref(&ovr), &mut r); + assert!( + r.files.is_empty() && r.edits.is_empty(), + "unowned 2-tuple must stay untouched: {unowned}" + ); + assert_eq!( + r.warnings.iter().map(|w| w.code.as_str()).collect::>(), + vec!["redirect_bun_entry_not_found"], + "{unowned}" + ); + } + + // A version-1 workspace lock's 2-tuple workspace entry (the v0 + // grammar hand-carried forward) beside the target: the registry + // tuple is redirected, the workspace 2-tuple is never touched. + let ws = " \"consumer\": [\"consumer@workspace:packages/consumer\", { \"dependencies\": { \"left-pad\": \"1.3.0\" } }],"; + let target = " \"left-pad\": [\"left-pad@1.3.0\", \"\", {}, \"sha512-OLD==\"],"; + let mut files = BTreeMap::new(); + files.insert("bun.lock".to_string(), bun_workspace_lock(1, &[ws, target])); + let mut r = RewriteResult::default(); + rewrite_bun_lock(&files, std::slice::from_ref(&ovr), &mut r); + let out = r.files.get("bun.lock").expect("target rewritten"); + assert!(out.contains(ws), "{out}"); + assert!(out.contains(&format!("\"left-pad@{new_url}\"")), "{out}"); + assert_eq!(r.edits.len(), 1); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + } + /// Fail-closed ownership legs of the URL-tuple takeover: an OTHER-name /// spec, a non-http `file:` spec, and a foreign-origin URL all survive /// byte-identically while the target registry tuple in the same lock is @@ -13400,4 +13887,3 @@ mod hatch_tests { assert!(second.files.is_empty()); } } - diff --git a/crates/socket-patch-core/src/patch/redirect/replay.rs b/crates/socket-patch-core/src/patch/redirect/replay.rs index 938676af..33907ed5 100644 --- a/crates/socket-patch-core/src/patch/redirect/replay.rs +++ b/crates/socket-patch-core/src/patch/redirect/replay.rs @@ -71,8 +71,14 @@ enum Inverse { /// The pnpm `trustLockfile` auto-config (kind-specific: `created` /// deletes the scaffold, `added` removes exactly one line). PnpmTrust, - /// bun.lockb was migrated to a text bun.lock; the binary original was - /// never captured (git history is the restore path). Warn and drop. + /// bun.lockb was migrated to a text bun.lock during the redirect and + /// removed. When the writer captured the pre-migration bytes (standard + /// base64 in `original`, see the hosted flow's `LOCKB_ORIGINAL_CAP`) the + /// binary lock is written back atomically; the generated text bun.lock is + /// always left in place (its fragments replay back to registry tuples in + /// the same group). Without captured bytes — a ledger from before the + /// capture, or an oversize lock — the inverse warns only when bun.lockb is + /// actually absent on disk, and drops the edit either way. BunLockbMigrated, /// Owned by a per-purl revert (npm JSON kinds). Present here only /// when that revert failed — refuse the group rather than guess. @@ -248,10 +254,32 @@ async fn read_rel(project_root: &Path, rel: &str) -> Result, Stri Ok(Some(content)) } +/// Byte twin of [`read_rel`] for the binary bun.lockb restore — same FIFO +/// guard, `Ok(None)` when the file is absent. +async fn read_rel_bytes(project_root: &Path, rel: &str) -> Result>, String> { + use tokio::io::AsyncReadExt; + let path = project_root.join(rel); + let (mut file, metadata) = match crate::utils::fs::open_regular_file(&path).await { + Ok(pair) => pair, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(format!("read {rel}: {e}")), + }; + let mut content = Vec::with_capacity(metadata.len() as usize); + file.read_to_end(&mut content) + .await + .map_err(|e| format!("read {rel}: {e}"))?; + Ok(Some(content)) +} + /// Files the group's unwind has decided but not yet written: /// `Some(content)` to write, `None` to delete. type Staged = BTreeMap>; +/// Binary files the group's unwind restores byte-for-byte (the migrated +/// bun.lockb) — flushed through the crate's atomic writer after the text +/// stage, so a torn write can never leave a half-restored binary lock. +type StagedBytes = BTreeMap>; + async fn staged_read( staged: &Staged, project_root: &Path, @@ -342,6 +370,7 @@ pub async fn revert_remaining_redirect_edits( 'group: for (group, indices) in &groups { let mut staged: Staged = BTreeMap::new(); + let mut staged_bytes: StagedBytes = BTreeMap::new(); let mut group_drops: BTreeSet = BTreeSet::new(); let mut group_warnings: Vec<(String, String)> = Vec::new(); let files: BTreeSet = indices @@ -362,10 +391,8 @@ pub async fn revert_remaining_redirect_edits( for &idx in indices.iter().rev() { let edit = state.edits[idx].clone(); let (_, inverse) = classify(&edit.kind, &edit.action); - if !matches!( - inverse, - Inverse::NoopDrop | Inverse::BunLockbMigrated | Inverse::Unsupported - ) && !safe_rel_path(&edit.path) + if !matches!(inverse, Inverse::NoopDrop | Inverse::Unsupported) + && !safe_rel_path(&edit.path) { refuse( format!("ledger edit for {} has an unsafe path", edit.kind), @@ -380,13 +407,68 @@ pub async fn revert_remaining_redirect_edits( group_drops.insert(idx); } Inverse::BunLockbMigrated => { - group_warnings.push(( - "redirect_bun_lockb_unrestorable".into(), - "bun.lockb was migrated to a text bun.lock during the redirect and \ - its binary content was not captured — restore bun.lockb from git \ - history if the binary format is required" - .into(), - )); + // The pre-migration bytes, when the writer captured them + // (standard base64 in `original`). An undecodable payload + // (tampered / torn ledger) degrades like an absent one: + // refusing the whole bun group over the binary sibling + // would also block the bun.lock fragment restore, which + // is the part that un-redirects the install. + let recorded: Option> = str_payload(&edit.original).and_then(|b64| { + use base64::Engine as _; + base64::engine::general_purpose::STANDARD.decode(b64).ok() + }); + let on_disk = match read_rel_bytes(project_root, &edit.path).await { + Ok(current) => current, + Err(e) => { + refuse(e, &mut outcome); + refused_groups.insert(group); + continue 'group; + } + }; + match (recorded, on_disk) { + (Some(bytes), None) => { + staged_bytes.insert(edit.path.clone(), bytes); + group_warnings.push(( + "redirect_bun_lockb_restored".into(), + "bun.lockb was restored from the redirect ledger; the text \ + bun.lock generated during the redirect was left in place \ + (Bun ≥ 1.1.39 reads bun.lock when both exist) — delete \ + whichever lockfile you do not want" + .into(), + )); + } + // Already at the pre-redirect bytes (an interrupted + // earlier revert, a hand restore) — nothing to do. + (Some(bytes), Some(current)) if current == bytes => {} + // A DIFFERENT bun.lockb has appeared since (the user + // re-locked with an old bun): never clobber it. + (Some(_), Some(_)) => { + group_warnings.push(( + "redirect_bun_lockb_unrestorable".into(), + format!( + "{} already exists and differs from the pre-redirect \ + bytes recorded in the redirect ledger; it was left \ + untouched — restore it from git history if the recorded \ + binary lock is the one you want", + edit.path + ), + )); + } + (None, None) => { + group_warnings.push(( + "redirect_bun_lockb_unrestorable".into(), + "bun.lockb was migrated to a text bun.lock during the redirect \ + and its binary content was not captured — restore bun.lockb \ + from git history if the binary format is required" + .into(), + )); + } + // Present without captured bytes: the binary lock the + // redirect found is still there (bun 1.1.4x kept it and + // a pre-capture CLI recorded the removal anyway) — + // nothing to restore, nothing to say. + (None, Some(_)) => {} + } group_drops.insert(idx); } Inverse::PerPurlOnly => { @@ -417,9 +499,8 @@ pub async fn revert_remaining_redirect_edits( } Inverse::PipenvEntry => { let restored = match staged_read(&staged, project_root, &edit.path).await { - Ok(Some(content)) => { - super::pipenv::restore(&content, &edit).map(|restored| (content, restored)) - } + Ok(Some(content)) => super::pipenv::restore(&content, &edit) + .map(|restored| (content, restored)), Ok(None) => Err(format!("{} no longer exists", edit.path)), Err(error) => Err(error), }; @@ -471,7 +552,10 @@ pub async fn revert_remaining_redirect_edits( group_drops.insert(idx); } _ => { - refuse(format!("{}: Hatch configuration drifted", edit.path), &mut outcome); + refuse( + format!("{}: Hatch configuration drifted", edit.path), + &mut outcome, + ); refused_groups.insert(group); continue 'group; } @@ -507,17 +591,43 @@ pub async fn revert_remaining_redirect_edits( // the edit as reverted. group_drops.insert(idx); } else { - refuse( - format!( - "{}: content matches neither the redirected nor the \ - original fragment for {} — the file drifted; re-run \ - `scan --mode hosted` to normalize", - edit.path, edit.kind - ), - &mut outcome, - ); - refused_groups.insert(group); - continue 'group; + // bun only: Bun 1.1.39–1.3.9 re-save our URL 3-tuple + // WITHOUT its sha512 on any later lock re-save, so + // the recorded `new` is on disk as a digest-less + // 2-tuple — same key, spec and meta. That spelling + // is the recorded wiring, not drift: put `original` + // back over it. Anything else still refuses. + let healed = if edit.kind == "redirect_bun_lock_package" { + crate::vendor::bun_lock_text::restore_digestless_line( + &content, new, original, + ) + } else { + Ok(None) + }; + match healed { + Ok(Some(restored)) => { + staged.insert(edit.path.clone(), Some(restored)); + group_drops.insert(idx); + } + Ok(None) => { + refuse( + format!( + "{}: content matches neither the redirected nor the \ + original fragment for {} — the file drifted; re-run \ + `scan --mode hosted` to normalize", + edit.path, edit.kind + ), + &mut outcome, + ); + refused_groups.insert(group); + continue 'group; + } + Err(ambiguous) => { + refuse(format!("{}: {ambiguous}", edit.path), &mut outcome); + refused_groups.insert(group); + continue 'group; + } + } } } Inverse::RemoveAddedFragment => { @@ -679,10 +789,27 @@ pub async fn revert_remaining_redirect_edits( continue 'group; } } + // Binary restores go through the atomic writer (stage + fsync + + // rename): a torn bun.lockb is worse than none — bun ≤ 1.1.38 + // would read it. + for (rel, bytes) in &staged_bytes { + let path = project_root.join(rel); + if let Ok(meta) = tokio::fs::symlink_metadata(&path).await { + if !meta.is_file() { + refuse(format!("{rel} is not a regular file"), &mut outcome); + refused_groups.insert(group); + continue 'group; + } + } + if let Err(e) = crate::utils::fs::atomic_write_bytes(&path, bytes).await { + refuse(format!("write {rel}: {e}"), &mut outcome); + refused_groups.insert(group); + continue 'group; + } + } } - outcome - .reverted_files - .extend(staged.keys().cloned()); + outcome.reverted_files.extend(staged.keys().cloned()); + outcome.reverted_files.extend(staged_bytes.keys().cloned()); pending_warnings.extend(group_warnings); drop_indices.extend(group_drops); } @@ -749,9 +876,9 @@ mod tests { let mut state = RedirectState::new(); state.edits = edits; for p in record_purls { - state - .records - .insert((*p).to_string(), crate::manifest::schema::PatchRecord { + state.records.insert( + (*p).to_string(), + crate::manifest::schema::PatchRecord { uuid: "u".into(), exported_at: "now".into(), files: Default::default(), @@ -780,20 +907,42 @@ mod tests { #[tokio::test] async fn hatch_documents_revert_after_checkout_newline_conversion() { let original = "[project]\ndependencies=[\"one==1\"]\n[tool.hatch.envs.default]\n"; - let files = [("pyproject.toml".to_owned(), original.to_owned())].into_iter().collect(); - let patched = crate::utils::hatch::rewrite(&files, "one", "1", "https://patch.test/one.whl").unwrap().remove("pyproject.toml").unwrap(); + let files = [("pyproject.toml".to_owned(), original.to_owned())] + .into_iter() + .collect(); + let patched = + crate::utils::hatch::rewrite(&files, "one", "1", "https://patch.test/one.whl") + .unwrap() + .remove("pyproject.toml") + .unwrap(); for drift in [false, true] { let dir = TempDir::new().unwrap(); - let live = if drift {patched.replace("one.whl", "changed.whl")} else {patched.replace('\n', "\r\n")}; + let live = if drift { + patched.replace("one.whl", "changed.whl") + } else { + patched.replace('\n', "\r\n") + }; write(dir.path(), "pyproject.toml", &live).await; - let mut state = state_with(vec![edit("pyproject.toml", "redirect_hatch_document", "rewritten", Some(original), Some(&patched))], &["pkg:pypi/one@1"]); + let mut state = state_with( + vec![edit( + "pyproject.toml", + "redirect_hatch_document", + "rewritten", + Some(original), + Some(&patched), + )], + &["pkg:pypi/one@1"], + ); let outcome = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; assert_eq!(outcome.fully_reverted(), !drift); if drift { assert_eq!(read(dir.path(), "pyproject.toml").await, live); assert_eq!(state.edits.len(), 1); } else { - assert_eq!(read(dir.path(), "pyproject.toml").await, original.replace('\n', "\r\n")); + assert_eq!( + read(dir.path(), "pyproject.toml").await, + original.replace('\n', "\r\n") + ); assert!(state.edits.is_empty()); } } @@ -822,7 +971,10 @@ mod tests { ); let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; assert!(out.fully_reverted(), "{:?}", out.refusals); - assert_eq!(read(dir.path(), "requirements.txt").await, "left-pad==1.3.0\n"); + assert_eq!( + read(dir.path(), "requirements.txt").await, + "left-pad==1.3.0\n" + ); assert!(state.edits.is_empty()); assert!(state.records.is_empty()); assert_eq!(out.dropped_records, vec!["pkg:pypi/left-pad@1.3.0"]); @@ -850,7 +1002,10 @@ mod tests { ); let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; assert!(out.fully_reverted(), "{:?}", out.refusals); - assert_eq!(read(dir.path(), "pom.xml").await, "2.17.1\n"); + assert_eq!( + read(dir.path(), "pom.xml").await, + "2.17.1\n" + ); } #[tokio::test] @@ -979,6 +1134,182 @@ mod tests { assert_eq!(read(dir.path(), "go.mod").await, "module m\n"); } + // ---------- bun: digest-less re-saves (Bun 1.1.39–1.3.9, every text-lock release below 1.3.10) ---------- + + const BUN_URL: &str = + "https://patch.socket.dev/patch/npm/tok-1111/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/left-pad-1.3.0.tgz"; + const BUN_REGISTRY_LINE: &str = + " \"left-pad\": [\"left-pad@1.3.0\", \"\", {}, \"sha512-XI5M==\"],"; + + fn bun_url_line(sha: &str) -> String { + format!(" \"left-pad\": [\"left-pad@{BUN_URL}\", {{}}, \"{sha}\"],") + } + + fn bun_digestless_line() -> String { + format!(" \"left-pad\": [\"left-pad@{BUN_URL}\", {{}}],") + } + + fn bun_lock(entry: &str) -> String { + format!( + "{{\n \"lockfileVersion\": 1,\n \"packages\": {{\n \"abbrev\": [\"abbrev@1.1.1\", \ + \"\", {{}}, \"sha512-D==\"],\n\n{entry}\n }}\n}}\n" + ) + } + + fn bun_edit(original: &str, new: &str) -> FileEdit { + edit( + "bun.lock", + "redirect_bun_lock_package", + "rewritten", + Some(original), + Some(new), + ) + } + + /// The live lock carries the digest-less 2-tuple Bun < 1.3.10 re-saved + /// our URL 3-tuple as; the recorded `new` is the 3-tuple. That is the + /// recorded wiring, not drift: the registry original comes back, the + /// edit and record are consumed. + #[tokio::test] + async fn bun_digestless_live_line_replays_to_the_registry_original() { + let dir = TempDir::new().unwrap(); + write(dir.path(), "bun.lock", &bun_lock(&bun_digestless_line())).await; + let mut state = state_with( + vec![bun_edit(BUN_REGISTRY_LINE, &bun_url_line("sha512-AAAA=="))], + &["pkg:npm/left-pad@1.3.0"], + ); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert!(out.fully_reverted(), "{:?}", out.refusals); + assert_eq!( + read(dir.path(), "bun.lock").await, + bun_lock(BUN_REGISTRY_LINE), + "the pristine registry line is restored, the decoy untouched" + ); + assert!(state.edits.is_empty() && state.records.is_empty()); + assert_eq!(out.dropped_records, vec!["pkg:npm/left-pad@1.3.0"]); + + // CRLF lock (ledger recorded with `\r` on both fragments, as the + // rewriter does): every line keeps its `\r\n`. + let dir = TempDir::new().unwrap(); + write( + dir.path(), + "bun.lock", + &bun_lock(&bun_digestless_line()).replace('\n', "\r\n"), + ) + .await; + let mut state = state_with( + vec![bun_edit( + &format!("{BUN_REGISTRY_LINE}\r"), + &format!("{}\r", bun_url_line("sha512-AAAA==")), + )], + &[], + ); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert!(out.fully_reverted(), "{:?}", out.refusals); + assert_eq!( + read(dir.path(), "bun.lock").await, + bun_lock(BUN_REGISTRY_LINE).replace('\n', "\r\n") + ); + } + + /// The hosted rewriter HEALS a digest-less tuple and records that heal + /// as a second edit for the same key (`original` = the 2-tuple). The + /// chain unwinds newest-first: heal → 2-tuple, then the first edit + /// recognises the 2-tuple as its digest-less `new` → registry line. + /// Same end state when Bun has since dropped the digest AGAIN (the + /// heal edit is then "already at its original" and simply drops). + #[tokio::test] + async fn bun_heal_chain_unwinds_to_the_registry_line() { + let healed = bun_url_line("sha512-AAAA=="); + for live in [healed.clone(), bun_digestless_line()] { + let dir = TempDir::new().unwrap(); + write(dir.path(), "bun.lock", &bun_lock(&live)).await; + let mut state = state_with( + vec![ + bun_edit(BUN_REGISTRY_LINE, &healed), + bun_edit(&bun_digestless_line(), &healed), + ], + &["pkg:npm/left-pad@1.3.0"], + ); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert!(out.fully_reverted(), "live={live}: {:?}", out.refusals); + assert_eq!( + read(dir.path(), "bun.lock").await, + bun_lock(BUN_REGISTRY_LINE), + "live={live}: the chain must end at the pristine registry line" + ); + assert!(state.edits.is_empty() && state.records.is_empty()); + } + } + + /// The relaxation is exactly "our tuple minus its digest": another + /// uuid/token in the URL, a re-laid meta object, a duplicate digest-less + /// instance, or a non-bun edit kind over the same bytes all still + /// refuse, leaving the file byte-identical. + #[tokio::test] + async fn bun_digestless_relaxation_is_narrow() { + let recorded_new = bun_url_line("sha512-AAAA=="); + let other_uuid = bun_digestless_line().replace( + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + ); + let other_meta = bun_digestless_line().replace("{}", "{ \"bin\": \"x\" }"); + for (live, kind, reason) in [ + ( + bun_lock(&other_uuid), + "redirect_bun_lock_package", + "neither the redirected nor the original", + ), + ( + bun_lock(&other_meta), + "redirect_bun_lock_package", + "neither the redirected nor the original", + ), + ( + bun_lock(&format!( + "{}\n{}", + bun_digestless_line(), + bun_digestless_line() + )), + "redirect_bun_lock_package", + "more than once", + ), + ( + bun_lock(&bun_digestless_line()), + "redirect_pnpm_resolution", + "neither the redirected nor the original", + ), + ] { + let dir = TempDir::new().unwrap(); + write(dir.path(), "bun.lock", &live).await; + let mut state = state_with( + vec![edit( + "bun.lock", + kind, + "rewritten", + Some(BUN_REGISTRY_LINE), + Some(&recorded_new), + )], + &["pkg:npm/left-pad@1.3.0"], + ); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert_eq!( + out.refusals.len(), + 1, + "{kind}: exactly one refusal expected, got {}", + out.refusals.len() + ); + assert!( + out.refusals[0].reason.contains(reason), + "{kind}: {}", + out.refusals[0].reason + ); + assert_eq!(read(dir.path(), "bun.lock").await, live, "file untouched"); + assert_eq!(state.edits.len(), 1, "refused edit kept"); + assert!(state.records.contains_key("pkg:npm/left-pad@1.3.0")); + } + } + // ---------- RemoveAddedFragment / ReinsertRemoved ---------- #[tokio::test] @@ -1294,7 +1625,9 @@ mod tests { // The npm group refused; the bun group replayed. assert_eq!(out.refusals.len(), 1); assert_eq!(out.refusals[0].group, "npm"); - assert!(read(dir.path(), "bun.lock").await.contains("upstream.example")); + assert!(read(dir.path(), "bun.lock") + .await + .contains("upstream.example")); // npm-family records are held while ANY npm-family group refused. assert!(state.records.contains_key("pkg:npm/a@1")); assert_eq!(state.edits.len(), 1, "only the refused npm edit remains"); @@ -1474,7 +1807,9 @@ mod tests { assert_eq!(out.dropped_records, vec!["pkg:pypi/left-pad@1.3.0"]); assert!(out.reverted_files.contains("requirements.txt")); // Disk and ledger untouched. - assert!(read(dir.path(), "requirements.txt").await.contains("patch.example")); + assert!(read(dir.path(), "requirements.txt") + .await + .contains("patch.example")); assert_eq!(state.edits.len(), 1); assert_eq!(state.records.len(), 1); } @@ -1542,6 +1877,257 @@ mod tests { assert!(state.edits.is_empty()); } + // ---------- bun.lockb migration: byte restore from the ledger ---------- + + const LOCKB_BYTES: &[u8] = b"\x00BUN-BINARY\xff\xfe\x00LOCK"; + + fn lockb_edit(original: Option) -> FileEdit { + FileEdit { + path: "bun.lockb".into(), + kind: "redirect_bun_lockb_migrated".into(), + action: "removed".into(), + key: None, + original, + new: None, + } + } + + fn lockb_b64() -> Value { + use base64::Engine as _; + Value::String(base64::engine::general_purpose::STANDARD.encode(LOCKB_BYTES)) + } + + fn codes(out: &ReplayOutcome) -> Vec<&str> { + out.warnings.iter().map(|(c, _)| c.as_str()).collect() + } + + /// Captured bytes + bun.lockb absent (bun ≥ 1.2 deleted it, or the CLI + /// removed the 1.1.4x leftover): the binary lock comes back + /// byte-identical, the informational `restored` warning names the + /// leftover text lock, and no `unrestorable` warning fires. + #[tokio::test] + async fn bun_lockb_migration_restores_recorded_bytes() { + let dir = TempDir::new().unwrap(); + let mut state = state_with(vec![lockb_edit(Some(lockb_b64()))], &[]); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert!(out.fully_reverted(), "{:?}", out.refusals); + assert_eq!( + std::fs::read(dir.path().join("bun.lockb")).unwrap(), + LOCKB_BYTES, + "the recorded bytes must be written back verbatim" + ); + assert_eq!(codes(&out), vec!["redirect_bun_lockb_restored"]); + let detail = &out.warnings[0].1; + assert!( + detail.contains("bun.lock generated during the redirect was left in place") + && detail.contains("delete whichever lockfile you do not want"), + "{detail}" + ); + assert!(out.reverted_files.contains("bun.lockb")); + assert!(state.edits.is_empty(), "the replayed edit is dropped"); + assert!( + std::fs::read_dir(dir.path()).unwrap().all(|e| !e + .unwrap() + .file_name() + .to_string_lossy() + .starts_with(".socket-stage-")), + "the atomic writer leaves no stage file behind" + ); + } + + /// The migration record rides the bun group BEHIND the bun.lock fragment + /// (ledger order: migration first, so the reverse walk replays the + /// fragment first): one pass restores the registry tuple in bun.lock AND + /// writes bun.lockb back, and never deletes the text lock. + #[tokio::test] + async fn bun_lockb_restore_rides_the_bun_group_behind_the_lock_fragment() { + let dir = TempDir::new().unwrap(); + let original = " \"lp\": [\"lp@1.0.0\", \"\", {}, \"sha512-UP==\"],"; + let redirected = + " \"lp\": [\"lp@http://p.test/lp-1.0.0.tgz\", {}, \"sha512-PATCHED==\"],"; + let lock = |entry: &str| { + format!("{{\n \"lockfileVersion\": 1,\n \"packages\": {{\n{entry}\n }}\n}}\n") + }; + std::fs::write(dir.path().join("bun.lock"), lock(redirected)).unwrap(); + let mut state = state_with( + vec![ + lockb_edit(Some(lockb_b64())), + edit( + "bun.lock", + "redirect_bun_lock_package", + "rewritten", + Some(original), + Some(redirected), + ), + ], + &[], + ); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert!(out.fully_reverted(), "{:?}", out.refusals); + assert_eq!( + std::fs::read_to_string(dir.path().join("bun.lock")).unwrap(), + lock(original), + "the text lock is un-redirected and KEPT" + ); + assert_eq!( + std::fs::read(dir.path().join("bun.lockb")).unwrap(), + LOCKB_BYTES + ); + assert_eq!(codes(&out), vec!["redirect_bun_lockb_restored"]); + assert!( + out.reverted_files.contains("bun.lock") && out.reverted_files.contains("bun.lockb") + ); + assert!(state.edits.is_empty()); + } + + /// Dry-run stages the restore and reports it (warning + would-revert + /// file) but writes nothing and leaves the ledger untouched. + #[tokio::test] + async fn bun_lockb_restore_is_staged_not_written_on_dry_run() { + let dir = TempDir::new().unwrap(); + let mut state = state_with(vec![lockb_edit(Some(lockb_b64()))], &[]); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, true).await; + assert!(out.fully_reverted()); + assert!( + !dir.path().join("bun.lockb").exists(), + "dry-run must not write" + ); + assert_eq!(codes(&out), vec!["redirect_bun_lockb_restored"]); + assert!(out.reverted_files.contains("bun.lockb")); + assert_eq!(out.dropped_edits, 1); + assert_eq!(state.edits.len(), 1, "dry-run leaves the ledger alone"); + } + + /// No captured bytes (pre-capture ledger / oversize lock) and bun.lockb + /// PRESENT on disk (bun 1.1.4x kept it): nothing to restore, nothing to + /// warn about — the old unconditional `unrestorable` was a lie here. + #[tokio::test] + async fn bun_lockb_present_without_recorded_bytes_says_nothing() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("bun.lockb"), LOCKB_BYTES).unwrap(); + let mut state = state_with(vec![lockb_edit(None)], &[]); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert!(out.fully_reverted()); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + assert_eq!( + std::fs::read(dir.path().join("bun.lockb")).unwrap(), + LOCKB_BYTES + ); + assert!(!out.reverted_files.contains("bun.lockb")); + assert!(state.edits.is_empty()); + } + + /// Captured bytes and an IDENTICAL bun.lockb already on disk (an + /// interrupted earlier revert): already at the original — no write, no + /// warning, edit dropped. + #[tokio::test] + async fn bun_lockb_identical_to_recorded_bytes_is_already_restored() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("bun.lockb"), LOCKB_BYTES).unwrap(); + let mut state = state_with(vec![lockb_edit(Some(lockb_b64()))], &[]); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert!(out.fully_reverted()); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + assert!(!out.reverted_files.contains("bun.lockb")); + assert!(state.edits.is_empty()); + } + + /// Captured bytes but a DIFFERENT bun.lockb on disk (the user re-locked + /// with an old bun since): never clobbered; the honest `unrestorable` + /// says why and the edit is dropped. + #[tokio::test] + async fn bun_lockb_differing_from_recorded_bytes_is_left_untouched() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("bun.lockb"), b"a newer binary lock").unwrap(); + let mut state = state_with(vec![lockb_edit(Some(lockb_b64()))], &[]); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert!(out.fully_reverted()); + assert_eq!(codes(&out), vec!["redirect_bun_lockb_unrestorable"]); + assert!( + out.warnings[0] + .1 + .contains("differs from the pre-redirect bytes"), + "{}", + out.warnings[0].1 + ); + assert_eq!( + std::fs::read(dir.path().join("bun.lockb")).unwrap(), + b"a newer binary lock" + ); + assert!(state.edits.is_empty()); + } + + /// An undecodable `original` (tampered / torn ledger) degrades to the + /// no-bytes path: `unrestorable` only because the file is absent, and the + /// bun group is NOT refused over it. + #[tokio::test] + async fn bun_lockb_undecodable_original_degrades_to_unrestorable() { + let dir = TempDir::new().unwrap(); + for bad in [ + Value::String("not base64 !!".into()), + json!(42), + json!({"b": 1}), + ] { + let mut state = state_with(vec![lockb_edit(Some(bad.clone()))], &[]); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert!(out.fully_reverted(), "{bad}: {:?}", out.refusals); + assert_eq!( + codes(&out), + vec!["redirect_bun_lockb_unrestorable"], + "{bad}" + ); + assert!( + !dir.path().join("bun.lockb").exists(), + "{bad}: nothing may be written" + ); + assert!(state.edits.is_empty()); + } + } + + /// The restore WRITES `edit.path`, so the ledger-path safety rule now + /// applies to the migration record too: a tampered path refuses the + /// group and writes nothing outside the project. + #[tokio::test] + async fn bun_lockb_migration_with_unsafe_path_refuses() { + let dir = TempDir::new().unwrap(); + let project = dir.path().join("project"); + std::fs::create_dir_all(&project).unwrap(); + let mut bad = lockb_edit(Some(lockb_b64())); + bad.path = "../escaped.lockb".into(); + let mut state = state_with(vec![bad], &[]); + let out = revert_remaining_redirect_edits(&project, &mut state, false).await; + assert_eq!(out.refusals.len(), 1); + assert!(out.refusals[0].reason.contains("unsafe path")); + assert!(!dir.path().join("escaped.lockb").exists()); + assert_eq!(state.edits.len(), 1, "a refused group keeps its edits"); + } + + /// A FIFO squatting bun.lockb must refuse fast instead of wedging the + /// replay (the same guard every other raw read in the engine has). + #[cfg(unix)] + #[tokio::test] + async fn bun_lockb_fifo_squatting_the_path_refuses_the_group() { + let dir = TempDir::new().unwrap(); + let fifo = dir.path().join("bun.lockb"); + let c_path = std::ffi::CString::new(fifo.to_str().unwrap()).unwrap(); + // SAFETY: a valid NUL-terminated path; mkfifo has no other preconditions. + assert_eq!(unsafe { libc::mkfifo(c_path.as_ptr(), 0o644) }, 0); + let mut state = state_with(vec![lockb_edit(Some(lockb_b64()))], &[]); + let out = tokio::time::timeout( + std::time::Duration::from_secs(10), + revert_remaining_redirect_edits(dir.path(), &mut state, false), + ) + .await + .expect("the FIFO guard must not wedge the replay"); + assert_eq!(out.refusals.len(), 1, "{:?}", out.warnings); + assert!( + out.refusals[0].reason.contains("bun.lockb"), + "{}", + out.refusals[0].reason + ); + assert_eq!(state.edits.len(), 1); + } + /// Every kind the hosted writers emit today must have a deliberate /// classification — a new writer kind landing without a replay arm /// falls to the "unknown" group, which fails closed at runtime; this @@ -1691,7 +2277,9 @@ mod tests { ); let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; assert_eq!(out.refusals.len(), 1, "{out:?}"); - assert!(out.refusals[0].reason.contains("missing its recorded lines")); + assert!(out.refusals[0] + .reason + .contains("missing its recorded lines")); assert_eq!(state.edits.len(), 1); assert_eq!(read(dir.path(), "go.sum").await, "x v1 h1:a\n"); } @@ -1704,6 +2292,7 @@ mod tests { // return InvalidInput (open + fstat) — the fail-fast posture the // module doc claims. Every per-arm read must refuse the group with // the read error and keep the ledger. + #[allow(clippy::type_complexity)] let cases: [(&str, &str, &str, Option<&str>, Option<&str>); 4] = [ ( "composer.lock", @@ -1736,10 +2325,16 @@ mod tests { ]; for (path, kind, action, original, new) in cases { let dir = TempDir::new().unwrap(); - tokio::fs::create_dir_all(dir.path().join(path)).await.unwrap(); + tokio::fs::create_dir_all(dir.path().join(path)) + .await + .unwrap(); let mut state = state_with(vec![edit(path, kind, action, original, new)], &[]); let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; - assert_eq!(out.refusals.len(), 1, "{kind}/{action} must refuse: {out:?}"); + assert_eq!( + out.refusals.len(), + 1, + "{kind}/{action} must refuse: {out:?}" + ); assert!( out.refusals[0].reason.starts_with(&format!("read {path}:")), "{kind}/{action}: {}", @@ -2004,7 +2599,10 @@ mod tests { ); let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; assert_eq!(out.refusals.len(), 1, "{out:?}"); - assert_eq!(out.refusals[0].reason, "composer.lock is not a regular file"); + assert_eq!( + out.refusals[0].reason, + "composer.lock is not a regular file" + ); assert_eq!( read(dir.path(), "real.lock").await, "https://patch.example/a\n", diff --git a/crates/socket-patch-core/src/patch/redirect/takeover.rs b/crates/socket-patch-core/src/patch/redirect/takeover.rs index e6fdf82a..0ff70adc 100644 --- a/crates/socket-patch-core/src/patch/redirect/takeover.rs +++ b/crates/socket-patch-core/src/patch/redirect/takeover.rs @@ -13,16 +13,20 @@ //! patches crates-io-sourced deps) and the project is unbuildable in both //! modes. //! -//! npm family (package-lock/npm-shrinkwrap, yarn classic, yarn berry, pnpm): -//! each recorded lock edit's `original` fragment is replayed over its `new` -//! fragment. Here the follow-up vendor rewire happens to succeed either way -//! (the vendored wiring replaces whatever resolution is present), but -//! WITHOUT the pre-revert the vendor ledger records the grant-tokenized -//! hosted fragment as its unrecoverable pre-vendor "original" (so `vendor -//! --revert` restores an expiring hosted URL with no CLI path back to -//! registry state), and the superseded redirect records/edits survive -//! forever — a stale ledger that VEX/audits keep reading and a replay hazard -//! for any later redirect revert. +//! npm family (package-lock/npm-shrinkwrap, yarn classic, yarn berry, pnpm, +//! bun): each recorded lock edit's `original` fragment is replayed over its +//! `new` fragment. For most flavors the follow-up vendor rewire happens to +//! succeed either way (the vendored wiring replaces whatever resolution is +//! present), but WITHOUT the pre-revert the vendor ledger records the +//! grant-tokenized hosted fragment as its unrecoverable pre-vendor +//! "original" (so `vendor --revert` restores an expiring hosted URL with no +//! CLI path back to registry state), and the superseded redirect +//! records/edits survive forever — a stale ledger that VEX/audits keep +//! reading and a replay hazard for any later redirect revert. bun is +//! stricter still: its hosted rewrite REPLACES the `name@version` spec the +//! bun vendor backend keys on (registry 4-tuple → URL 3-tuple), so without +//! the pre-revert the package cannot be vendored at all +//! (`vendor_lock_entry_not_found`). //! //! FAIL CLOSED: a file that matches neither the recorded redirected fragment //! nor the recorded original has drifted — the revert refuses (`Err`) rather @@ -348,14 +352,130 @@ fn parse_npm_purl(canon: &str) -> Option<(&str, &str)> { (!name.is_empty() && !version.is_empty()).then_some((name, version)) } -/// The npm-family text-fragment edit kinds: `original`/`new` hold the whole -/// lock fragment as a string, and the revert is a `replacen(new, original)`. +/// The npm-family text-fragment edit kinds CLAIMED BY KEY: `original`/`new` +/// hold the whole lock fragment as a string, the edit's `key` embeds +/// `@`, and the revert is a `replacen(new, original)`. const NPM_TEXT_KINDS: [&str; 3] = [ "redirect_yarn_classic_entry", "redirect_yarn_berry_entry", "redirect_pnpm_resolution", ]; +/// The bun hosted rewriter's edit kind (`rewrite_bun_lock`): `original`/`new` +/// hold the whole `packages` entry LINE, so it replays exactly like the +/// [`NPM_TEXT_KINDS`]. It is CLAIMED differently: bun edits key by the +/// lock's package map key — `minimist`, a nested `other/minimist`, or the +/// alias of an `alias@npm:minimist@1.2.2` install — never by +/// `name@version`, so ownership is read from the recorded line's spec +/// (`elems[0]`), the field the rewriter itself matched on. +const BUN_TEXT_KIND: &str = "redirect_bun_lock_package"; + +/// Does this edit kind replay as a whole text fragment +/// (`content.replacen(new, original, 1)`, fail-closed on drift)? +fn replays_as_text_fragment(kind: &str) -> bool { + NPM_TEXT_KINDS.contains(&kind) || kind == BUN_TEXT_KIND +} + +/// Ownership verdict for one [`BUN_TEXT_KIND`] edit. +#[derive(Debug, PartialEq)] +enum BunClaim { + /// The edit rewrote an instance of exactly this `name@version`. + Ours, + /// Another package, or another version of this one (a nested + /// `other/minimist` instance at 1.2.8 while reverting 1.2.2): not ours + /// to touch, and no reason to refuse. + Foreign, + /// The recorded fragments mention this package but neither one parses + /// under bun's entry grammar (hand-edited or truncated ledger), so + /// ownership cannot be decided. Deciding "foreign" would drop this + /// purl's record while stranding an edit that may be its own — half a + /// takeover — so the caller refuses. + Undecidable, +} + +/// Attribute a bun.lock edit to `name@version` the way the hosted rewriter +/// matched it: by the spec of the recorded line. +/// +/// A registry 4-tuple's spec is exactly `@`; a hosted URL +/// 3-tuple's spec is `@`. Either fragment may be the +/// hosted URL (a re-redirect chain records `original` = the PRIOR hosted +/// line, `new` = the current one), so both are consulted and one match +/// claims. The URL half is discriminated by version through its tarball +/// leaf — see [`hosted_url_names`] — never by the name substring alone, +/// which would claim a sibling version's edit and silently un-host it. +fn bun_edit_ownership(edit: &FileEdit, name: &str, version: &str) -> BunClaim { + use crate::vendor::bun_lock_text::{decode_json_string, parse_entry_line}; + fn fragment(v: &Option) -> Option<&str> { + v.as_ref().and_then(Value::as_str) + } + let spec_of = |line: &str| -> Option { + let entry = parse_entry_line(line).ok()?; + decode_json_string(entry.elems.first()?) + }; + let mut parsed_any = false; + for line in [fragment(&edit.original), fragment(&edit.new)] + .into_iter() + .flatten() + { + if let Some(spec) = spec_of(line) { + parsed_any = true; + if bun_spec_names(&spec, name, version) { + return BunClaim::Ours; + } + } + } + if parsed_any { + return BunClaim::Foreign; + } + // Neither fragment is a bun entry line. Only refuse when the raw text + // so much as mentions this package; an edit naming nothing of ours is + // someone else's problem and must not block this purl's takeover. + let probe = format!("\"{name}@"); + let mentions = |v: &Option| fragment(v).is_some_and(|s| s.contains(&probe)); + if mentions(&edit.original) || mentions(&edit.new) { + BunClaim::Undecidable + } else { + BunClaim::Foreign + } +} + +/// Is `spec` (a bun.lock entry's decoded `elems[0]`) the registry spec +/// `@` or a hosted artifact URL spec for that exact +/// `name@version`? +fn bun_spec_names(spec: &str, name: &str, version: &str) -> bool { + use crate::vendor::bun_lock_text::split_name_spec; + let Some((spec_name, rest)) = split_name_spec(spec) else { + return false; + }; + spec_name == name && (rest == version || hosted_url_names(rest, name, version)) +} + +/// True when `url` is an http(s) artifact URL whose last path segment is +/// `-.tgz` — the leaf every hosted artifact URL for this +/// `name@version` ends in. `` is the name without its `@scope/`: the +/// vendor path layer (`tgz_rel_leaf`) keeps a scope as a directory level +/// (`@scope/pkg-1.0.0.tgz`), and the hosted rewriter's prior-URL match +/// (`is_prior_hosted_bun_spec`) compares the same last path segment, so +/// `pkg-1.0.0.tgz` is the one spelling both agree on. Anything that fails +/// to parse fails the match (closed). The exact-leaf comparison is the +/// version discriminator: `pkg-1.3.0.tgz` never equals `pkg-11.3.0.tgz` +/// or `pkg-1.3.0-rc1.tgz`. +fn hosted_url_names(url: &str, name: &str, version: &str) -> bool { + if !url.starts_with("https://") && !url.starts_with("http://") { + return false; + } + let scheme_end = url + .find("://") + .expect("url starts with http(s):// — checked above") + + 3; + let Some(path_start) = url[scheme_end..].find('/').map(|i| i + scheme_end) else { + return false; + }; + let leaf = url[path_start..].rsplit('/').next().unwrap_or_default(); + let bare = name.rsplit('/').next().unwrap_or(name); + !leaf.is_empty() && leaf == format!("{bare}-{version}.tgz") +} + /// Revert every hosted-redirect edit the ledger records for `purl` (an npm /// package), then drop that purl's record and edits from `state`. The caller /// persists the mutated ledger (see `persist_redirect_state`). @@ -414,10 +534,13 @@ pub async fn revert_npm_redirect_purl( // SIBLING purl's edits (left-pad@1.2.0 vs @1.3.0 both hosted-redirected, // or `npm i name@npm:other` aliasing another package onto this key path) // and replaying those silently un-hosts the other purl while dropping - // its edits. A bun.lock edit that may belong to this purl is a hard - // refusal: bun edits key by the lock's package key (not name@version) - // and their revert is not implemented, so vendoring over one would drop - // the record while stranding its edits — half a takeover. + // its edits. bun edits key by the lock's package MAP key (`minimist`, + // nested `other/minimist`, an install alias) — never `name@version` — + // so they are attributed by the spec of the recorded line, the field + // the rewriter matched on (`bun_edit_ownership`); a sibling version's + // line is foreign, and a fragment that mentions the package but cannot + // be parsed at all refuses rather than guess (dropping the record while + // stranding a possibly-own edit would be half a takeover). let mut mine: Vec = Vec::new(); for (i, e) in state.edits.iter().enumerate() { let key = e.key.as_deref().unwrap_or_default(); @@ -481,23 +604,25 @@ pub async fn revert_npm_redirect_purl( } } } - "redirect_bun_lock_package" => { - let probe = format!("\"{name}@"); - let holds = |v: &Option| { - v.as_ref() - .and_then(Value::as_str) - .is_some_and(|s| s.contains(&probe)) - }; - if holds(&e.new) || holds(&e.original) { + BUN_TEXT_KIND => match bun_edit_ownership(e, &name, &version) { + BunClaim::Ours => true, + BunClaim::Foreign => false, + // The WORKING remedy is the whole-ledger replay: a plain + // `bun install` keeps a hosted URL tuple byte-identically + // (it re-locks nothing), and hand-editing the ledger is + // exactly what the hosted flow tells users never to do. + BunClaim::Undecidable => { return Err(format!( - "the redirect ledger records a bun.lock hosted redirect \ - for {name}, which this revert cannot replay yet; \ - restore the registry wiring manually (or re-lock with \ - `bun install`), remove the ledger entry, then re-run" + "the redirect ledger records a {} hosted redirect edit \ + that mentions {name} but is not a bun packages entry \ + line, so it cannot be attributed to {lock_key}; run an \ + unscoped `socket-patch rollback` (the whole-ledger \ + replay unwinds bun.lock hosted edits), then re-run; do \ + not edit .socket/vendor/redirect-state.json by hand", + e.path )); } - false - } + }, _ => false, }; if claimed { @@ -512,7 +637,11 @@ pub async fn revert_npm_redirect_purl( // previous step's `new`). for &i in mine.iter().rev() { let edit = state.edits[i].clone(); - if NPM_TEXT_KINDS.contains(&edit.kind.as_str()) { + if replays_as_text_fragment(&edit.kind) { + // Whole-fragment replay. For bun the fragments are whole lines + // (a CRLF lock's carry their trailing `\r`), so a + // `contains`/`replacen` on the raw content restores the line + // byte-exactly whatever the line ending. let (Some(new), Some(orig)) = ( edit.new.as_ref().and_then(Value::as_str), edit.original.as_ref().and_then(Value::as_str), @@ -536,14 +665,35 @@ pub async fn revert_npm_redirect_purl( } else if content.contains(orig) { // Already at (or unwound to) the pre-redirect fragment. } else { - return Err(format!( - "the {} entry for {lock_key} has drifted from the recorded \ - hosted redirect (neither the redirected nor the original \ - fragment is present); refusing to touch it — re-run \ - `scan --mode hosted` to normalize the redirect, or \ - restore the registry wiring manually, then re-run", - edit.path - )); + // bun only: Bun 1.1.39–1.3.9 re-save our URL 3-tuple WITHOUT + // its sha512 on any later lock re-save, so the recorded + // `new` is on disk as a digest-less 2-tuple (same key, spec + // and meta). That spelling IS the recorded wiring — restore + // `orig` over it; a stale ledger of its own making must not + // block the takeover, scoped rollback or remove. Anything + // else still refuses (fail closed). + let healed = if edit.kind == BUN_TEXT_KIND { + crate::vendor::bun_lock_text::restore_digestless_line(&content, new, orig) + .map_err(|ambiguous| format!("{}: {ambiguous}", edit.path))? + } else { + None + }; + match healed { + Some(restored) => { + staged.insert(edit.path.clone(), Some(restored)); + out.reverted_files.push(edit.path.clone()); + } + None => { + return Err(format!( + "the {} entry for {lock_key} has drifted from the recorded \ + hosted redirect (neither the redirected nor the original \ + fragment is present); refusing to touch it — re-run \ + `scan --mode hosted` to normalize the redirect, or \ + restore the registry wiring manually, then re-run", + edit.path + )); + } + } } } else { revert_npm_json_edit(project_root, &mut staged, &edit, &name, &version, &mut out) @@ -1082,7 +1232,10 @@ mod tests { // previews — the whole-ledger replay running after per-purl // reverts — must see the post-claim state); the caller owns the // clone and never persists it on a dry run. - assert!(state.records.len() < records_before, "record claimed in memory"); + assert!( + state.records.len() < records_before, + "record claimed in memory" + ); assert!(state.edits.len() < edits_before, "edits claimed in memory"); // The preview names exactly the files a wet run then reverts — @@ -1901,7 +2054,9 @@ mod tests { .and_then(Value::as_object_mut) .unwrap(); entry.remove("name").expect("fixture name field present"); - entry.remove("version").expect("fixture version field present"); + entry + .remove("version") + .expect("fixture version field present"); tokio::fs::write( root.join("package-lock.json"), serde_json::to_string_pretty(&on_disk).unwrap(), @@ -2038,9 +2193,7 @@ mod tests { .await; let root = tmp.path(); assert_eq!(state.edits.len(), 2, "{:?}", state.edits); - let scoped_url = npm_dep_for("@scope/left-pad", "1.3.0") - .artifact_url - .clone(); + let scoped_url = npm_dep_for("@scope/left-pad", "1.3.0").artifact_url.clone(); // Hand edit / merge artifact: strip the alias entry's name+version. let mut on_disk: Value = serde_json::from_str( &tokio::fs::read_to_string(root.join("package-lock.json")) @@ -2054,7 +2207,9 @@ mod tests { .and_then(Value::as_object_mut) .unwrap(); entry.remove("name").expect("fixture name field present"); - entry.remove("version").expect("fixture version field present"); + entry + .remove("version") + .expect("fixture version field present"); tokio::fs::write( root.join("package-lock.json"), serde_json::to_string_pretty(&on_disk).unwrap(), @@ -2217,34 +2372,646 @@ mod tests { assert!(err.contains("records no hosted redirect"), "{err}"); } + // ── bun ────────────────────────────────────────────────────────────── + + /// Real text-lock grammar (bun 1.4.2 matrix capture, lockfileVersion 2; + /// the `packages` tuple grammar is identical on 0/1/2): the root + /// `left-pad@1.3.0` registry 4-tuple, a nested `haspad/left-pad` + /// instance at the SIBLING version 1.2.0, and an unrelated `other`. + fn bun_pristine() -> String { + r#"{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "takeover-fixture", + "dependencies": { + "haspad": "1.0.0", + "left-pad": "1.3.0", + "other": "1.0.0", + }, + }, + }, + "packages": { + "haspad": ["haspad@1.0.0", "", { "dependencies": { "left-pad": "^1.2.0" } }, "sha512-hh=="], + + "left-pad": ["left-pad@1.3.0", "", {}, "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="], + + "other": ["other@1.0.0", "", {}, "sha512-oo=="], + + "haspad/left-pad": ["left-pad@1.2.0", "", {}, "sha512-OQadpCyFCT/VLniZQgym8d3/ofIJtuZyw2ibsVeIUOexKgW/osn8+mMFJbwGMPeDC4GnLzD8q115WPCDx4YRWg=="], + } +} +"# + .to_string() + } + + /// The packages-entry line keyed `key` (verbatim, without its line + /// terminator). + fn bun_line(lock: &str, key: &str) -> String { + let prefix = format!(" \"{key}\": ["); + lock.split('\n') + .find(|l| l.starts_with(&prefix)) + .unwrap_or_else(|| panic!("no `{key}` entry in:\n{lock}")) + .trim_end_matches('\r') + .to_string() + } + + async fn read_lock(root: &Path) -> String { + tokio::fs::read_to_string(root.join("bun.lock")) + .await + .unwrap() + } + + /// The bun revert used to be a hard refusal ("cannot replay yet"); + /// now the takeover claims the purl's `redirect_bun_lock_package` edit + /// by the recorded line's spec and replays the registry line back, + /// leaving the sibling-version and foreign entries untouched. + #[tokio::test] + async fn npm_bun_lock_takeover_restores_the_registry_line_and_drops_the_ledger() { + let (tmp, mut state) = npm_redirected_fixture("bun.lock", &bun_pristine()).await; + let root = tmp.path(); + let wired = read_lock(root).await; + assert!(wired.contains(NPM_URL), "fixture is hosted-wired:\n{wired}"); + assert_eq!( + state + .edits + .iter() + .filter(|e| e.kind == BUN_TEXT_KIND) + .count(), + 1, + "one bun edit for the one 1.3.0 instance: {:?}", + state.edits + ); + + let out = revert_redirect_purl(root, &mut state, NPM_PURL, false) + .await + .expect("bun takeover revert succeeds"); + assert_eq!(out.reverted_files, vec!["bun.lock".to_string()]); + assert_eq!( + read_lock(root).await, + bun_pristine(), + "bun.lock restored byte-identical" + ); + assert!(state.records.is_empty(), "record dropped"); + assert!(state.edits.is_empty(), "edit consumed"); + } + + /// (a) A hosted edit for ANOTHER VERSION of the same package (the nested + /// `haspad/left-pad` at 1.2.0) is not this purl's to claim: reverting + /// 1.3.0 leaves the 1.2.0 line hosted and its record + edit in the + /// ledger. + #[tokio::test] + async fn npm_bun_sibling_version_edit_is_neither_claimed_nor_a_refusal() { + const SIBLING: &str = "pkg:npm/left-pad@1.2.0"; + let (tmp, mut state) = npm_redirected_fixture_multi( + "bun.lock", + &bun_pristine(), + &[ + (NPM_PURL, npm_dep()), + (SIBLING, npm_dep_for("left-pad", "1.2.0")), + ], + ) + .await; + let root = tmp.path(); + let wired = read_lock(root).await; + let sibling_line = bun_line(&wired, "haspad/left-pad"); + assert!( + sibling_line.contains("/left-pad/1.2.0/") + && sibling_line.contains("left-pad-1.2.0.tgz"), + "sibling instance is hosted-wired too: {sibling_line}" + ); + assert_eq!(state.edits.len(), 2, "{:?}", state.edits); + + revert_redirect_purl(root, &mut state, NPM_PURL, false) + .await + .expect("takeover of 1.3.0 succeeds"); + let after = read_lock(root).await; + assert_eq!( + bun_line(&after, "left-pad"), + bun_line(&bun_pristine(), "left-pad"), + "the 1.3.0 line is back to its registry tuple" + ); + assert_eq!( + bun_line(&after, "haspad/left-pad"), + sibling_line, + "the sibling version's hosted line is untouched" + ); + assert_eq!(state.edits.len(), 1, "{:?}", state.edits); + assert_eq!(state.edits[0].key.as_deref(), Some("haspad/left-pad")); + assert!( + state.records.contains_key(SIBLING) && !state.records.contains_key(NPM_PURL), + "{:?}", + state.records.keys() + ); + } + + /// The digest-less spelling Bun 1.1.39–1.3.9 re-save a URL 3-tuple as + /// (`bun add`, `bun install` after a manifest change): the recorded + /// `new` is no longer on disk byte-for-byte, but the 2-tuple with the + /// same key/spec/meta IS our wiring — the claim must not refuse as + /// drift (that blocked hosted→vendored takeover, scoped `rollback` and + /// `remove` for every user on those releases). The registry line comes + /// back and the ledger is cleared. + fn drop_digest(line: &str) -> String { + let cut = line + .rfind(", \"sha512-") + .unwrap_or_else(|| panic!("no sha512 element in {line}")); + let tail = if line.trim_end_matches('\r').ends_with("],") { + "]," + } else { + "]" + }; + let cr = if line.ends_with('\r') { "\r" } else { "" }; + format!("{}{tail}{cr}", &line[..cut]) + } + + #[tokio::test] + async fn npm_bun_digestless_live_line_is_claimed_and_restored() { + let (tmp, mut state) = npm_redirected_fixture("bun.lock", &bun_pristine()).await; + let root = tmp.path(); + let wired = read_lock(root).await; + let wired_line = bun_line(&wired, "left-pad"); + let digestless = drop_digest(&wired_line); + assert!( + digestless.ends_with("{}],") && !digestless.contains("sha512"), + "{digestless}" + ); + tokio::fs::write( + root.join("bun.lock"), + wired.replace(&wired_line, &digestless), + ) + .await + .unwrap(); + + let out = revert_redirect_purl(root, &mut state, NPM_PURL, false) + .await + .expect("the digest-less spelling of our own wiring must not refuse"); + assert_eq!(out.reverted_files, vec!["bun.lock".to_string()]); + assert_eq!( + read_lock(root).await, + bun_pristine(), + "registry line restored" + ); + assert!(state.records.is_empty() && state.edits.is_empty()); + } + + /// Both hosted instances re-saved digest-less; reverting 1.3.0 restores + /// ONLY its line — the sibling 1.2.0 keeps its digest-less hosted + /// 2-tuple untouched (it is that purl's wiring, not ours to heal). + #[tokio::test] + async fn npm_bun_digestless_sibling_stays_untouched() { + const SIBLING: &str = "pkg:npm/left-pad@1.2.0"; + let (tmp, mut state) = npm_redirected_fixture_multi( + "bun.lock", + &bun_pristine(), + &[ + (NPM_PURL, npm_dep()), + (SIBLING, npm_dep_for("left-pad", "1.2.0")), + ], + ) + .await; + let root = tmp.path(); + let wired = read_lock(root).await; + let main_line = bun_line(&wired, "left-pad"); + let sibling_line = bun_line(&wired, "haspad/left-pad"); + let sibling_digestless = drop_digest(&sibling_line); + let live = wired + .replace(&main_line, &drop_digest(&main_line)) + .replace(&sibling_line, &sibling_digestless); + tokio::fs::write(root.join("bun.lock"), &live) + .await + .unwrap(); + + revert_redirect_purl(root, &mut state, NPM_PURL, false) + .await + .expect("takeover of 1.3.0 succeeds"); + let after = read_lock(root).await; + assert_eq!( + bun_line(&after, "left-pad"), + bun_line(&bun_pristine(), "left-pad") + ); + assert_eq!( + bun_line(&after, "haspad/left-pad"), + sibling_digestless, + "the sibling's digest-less hosted line is left exactly as found" + ); + assert_eq!(state.edits.len(), 1); + assert!(state.records.contains_key(SIBLING) && !state.records.contains_key(NPM_PURL)); + } + + /// A digest-less 2-tuple at ANOTHER uuid (someone re-granted the patch + /// and Bun re-saved it) is not the recorded wiring: still drift, still + /// a refusal, file byte-identical. + #[tokio::test] + async fn npm_bun_digestless_line_at_another_uuid_still_refuses() { + let (tmp, mut state) = npm_redirected_fixture("bun.lock", &bun_pristine()).await; + let root = tmp.path(); + let wired = read_lock(root).await; + let wired_line = bun_line(&wired, "left-pad"); + let foreign = drop_digest(&wired_line).replace("/6b7c/", "/7c8d/"); + assert_ne!( + foreign, + drop_digest(&wired_line), + "the uuid segment must differ" + ); + let live = wired.replace(&wired_line, &foreign); + tokio::fs::write(root.join("bun.lock"), &live) + .await + .unwrap(); + + let err = revert_redirect_purl(root, &mut state, NPM_PURL, false) + .await + .expect_err("another uuid's digest-less line is drift"); + assert!(err.contains("drifted"), "{err}"); + assert_eq!( + read_lock(root).await, + live, + "refusal leaves the lock untouched" + ); + assert_eq!(state.edits.len(), 1); + assert!(state.records.contains_key(NPM_PURL)); + } + + /// (b) Scoped package + re-redirect chain: the hosted rewrite destroys + /// the `@scope/pkg@1.0.0` spec, so the SECOND redirect (a rotated + /// artifact URL) records a hosted-URL line as its `original`. That edit + /// is claimed through the URL's tarball leaf (`pkg-1.0.0.tgz` — the + /// scope is a path level, not part of the basename) plus the full + /// scoped name in the spec; `@other/pkg` and bare `pkg`, whose leaves + /// are identical, stay hosted. Both links unwind newest-first to the + /// registry line. + #[tokio::test] + async fn npm_bun_scoped_package_claims_the_re_redirect_chain_by_spec_and_leaf() { + const SCOPED: &str = "pkg:npm/%40scope/pkg@1.0.0"; + const OTHER_SCOPE: &str = "pkg:npm/%40other/pkg@1.0.0"; + const BARE: &str = "pkg:npm/pkg@1.0.0"; + let pristine = r#"{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "scoped-fixture", + "dependencies": { + "@other/pkg": "1.0.0", + "@scope/pkg": "1.0.0", + "pkg": "1.0.0", + }, + }, + }, + "packages": { + "@other/pkg": ["@other/pkg@1.0.0", "", {}, "sha512-o1=="], + + "@scope/pkg": ["@scope/pkg@1.0.0", "", {}, "sha512-s1=="], + + "pkg": ["pkg@1.0.0", "", {}, "sha512-p1=="], + } +} +"#; + let (tmp, mut state) = npm_redirected_fixture_multi( + "bun.lock", + pristine, + &[ + (SCOPED, npm_dep_for("@scope/pkg", "1.0.0")), + (OTHER_SCOPE, npm_dep_for("@other/pkg", "1.0.0")), + (BARE, npm_dep_for("pkg", "1.0.0")), + ], + ) + .await; + let root = tmp.path(); + let first = read_lock(root).await; + let first_scoped_line = bun_line(&first, "@scope/pkg"); + assert!( + first_scoped_line.contains("/@scope/pkg/1.0.0/"), + "{first_scoped_line}" + ); + + // Second redirect of ONLY @scope/pkg with a rotated artifact URL + // (same origin + leaf, different uuid path segment): the rewriter's + // prior-hosted match re-pins it and records the chain link. + let mut rotated = npm_dep_for("@scope/pkg", "1.0.0"); + rotated.artifact_url = rotated.artifact_url.replace("/6b7c/", "/7c8d/"); + let mut files: BTreeMap = BTreeMap::new(); + files.insert("bun.lock".into(), first.clone()); + let rewrite = crate::patch::redirect::rewrite_registry_redirect(&files, &[rotated]); + let second = rewrite + .files + .get("bun.lock") + .unwrap_or_else(|| panic!("re-redirect must rewrite: {:?}", rewrite.warnings)) + .clone(); + assert!( + bun_line(&second, "@scope/pkg").contains("/7c8d/"), + "{second}" + ); + tokio::fs::write(root.join("bun.lock"), &second) + .await + .unwrap(); + state.edits.extend(rewrite.edits); + assert_eq!(state.edits.len(), 4, "{:?}", state.edits); + let chain_link = state.edits.last().unwrap(); + assert!( + chain_link.original.as_ref().and_then(Value::as_str) + == Some(first_scoped_line.as_str()), + "the chain link's original is the PRIOR hosted line: {chain_link:?}" + ); + + revert_redirect_purl(root, &mut state, "pkg:npm/@scope/pkg@1.0.0", false) + .await + .expect("scoped takeover succeeds"); + let after = read_lock(root).await; + assert_eq!( + bun_line(&after, "@scope/pkg"), + bun_line(pristine, "@scope/pkg"), + "both chain links unwound to the registry tuple" + ); + assert_eq!( + bun_line(&after, "@other/pkg"), + bun_line(&first, "@other/pkg"), + "same-leaf scoped sibling stays hosted" + ); + assert_eq!( + bun_line(&after, "pkg"), + bun_line(&first, "pkg"), + "same-leaf bare sibling stays hosted" + ); + assert_eq!(state.edits.len(), 2, "{:?}", state.edits); + assert!( + state + .edits + .iter() + .all(|e| matches!(e.key.as_deref(), Some("@other/pkg") | Some("pkg"))), + "{:?}", + state.edits + ); + assert!( + !state.records.contains_key(SCOPED) + && state.records.contains_key(OTHER_SCOPE) + && state.records.contains_key(BARE), + "{:?}", + state.records.keys() + ); + } + + /// The claim rule in isolation: registry spec by exact version, hosted + /// URL spec by exact tarball leaf, full (scoped) name in every case; + /// anything else — sibling versions, local vendored paths, workspace + /// specs, URLs without a path — is not ours. + #[test] + fn bun_spec_names_discriminates_name_and_version() { + assert!(bun_spec_names("left-pad@1.3.0", "left-pad", "1.3.0")); + assert!(!bun_spec_names("left-pad@1.3.0-rc1", "left-pad", "1.3.0")); + assert!(!bun_spec_names("left-pad@11.3.0", "left-pad", "1.3.0")); + assert!(!bun_spec_names("left-pad@1.3.0", "other", "1.3.0")); + assert!(bun_spec_names( + &format!("left-pad@{NPM_URL}"), + "left-pad", + "1.3.0" + )); + assert!(!bun_spec_names( + "left-pad@http://127.0.0.1:5555/p/left-pad-11.3.0.tgz", + "left-pad", + "1.3.0" + )); + assert!(!bun_spec_names( + "left-pad@http://127.0.0.1:5555/p/left-pad-1.3.0-rc1.tgz", + "left-pad", + "1.3.0" + )); + // Vendored local path, workspace and origin-only specs are never + // hosted redirects. + assert!(!bun_spec_names( + "left-pad@.socket/vendor/npm/6b7c/left-pad-1.3.0.tgz", + "left-pad", + "1.3.0" + )); + assert!(!bun_spec_names( + "left-pad@workspace:packages/left-pad", + "left-pad", + "1.3.0" + )); + assert!(!bun_spec_names( + "left-pad@https://patch.socket.dev", + "left-pad", + "1.3.0" + )); + // Scoped: the leaf is the BARE basename whether the URL keeps the + // scope as a path level (production, test fixtures) or not; the + // full scoped name must match the spec's name. + assert!(bun_spec_names( + "@scope/pkg@https://patch.socket.dev/patch/npm/@scope/pkg/1.0.0/t/u/pkg-1.0.0.tgz", + "@scope/pkg", + "1.0.0" + )); + assert!(bun_spec_names( + "@scope/pkg@http://127.0.0.1:5555/patch/npm/@scope/pkg/1.0.0/tok/6b7c/@scope/pkg-1.0.0.tgz", + "@scope/pkg", + "1.0.0" + )); + assert!(!bun_spec_names( + "@other/pkg@https://h/patch/npm/@other/pkg/1.0.0/t/u/pkg-1.0.0.tgz", + "@scope/pkg", + "1.0.0" + )); + assert!(!bun_spec_names( + "pkg@https://h/patch/npm/pkg/1.0.0/t/u/pkg-1.0.0.tgz", + "@scope/pkg", + "1.0.0" + )); + assert!(bun_spec_names("@scope/pkg@1.0.0", "@scope/pkg", "1.0.0")); + } + + /// (c) Drift: the line was re-resolved by a third party since the + /// redirect (neither the hosted nor the registry line is present) — + /// the same fail-closed refusal the yarn/pnpm text kinds give, with the + /// file and ledger left exactly as found. + #[tokio::test] + async fn npm_bun_drifted_line_refuses_fail_closed() { + let (tmp, mut state) = npm_redirected_fixture("bun.lock", &bun_pristine()).await; + let root = tmp.path(); + let wired = read_lock(root).await; + let drifted = wired.replace( + &bun_line(&wired, "left-pad"), + " \"left-pad\": [\"left-pad@https://corp.example/mirror/left-pad-1.3.0.tgz\", {}, \"sha512-corp==\"],", + ); + assert_ne!(drifted, wired); + tokio::fs::write(root.join("bun.lock"), &drifted) + .await + .unwrap(); + let records_before = state.records.len(); + let edits_before = state.edits.len(); + + let err = revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) + .await + .expect_err("drifted line must refuse"); + assert!(err.contains("drifted"), "{err}"); + assert!(err.contains("bun.lock"), "{err}"); + assert_eq!(read_lock(root).await, drifted, "file untouched"); + assert_eq!(state.records.len(), records_before); + assert_eq!(state.edits.len(), edits_before); + } + + /// (d) CRLF lock: the recorded lines carry (or, for a rewriter that + /// normalized the rewritten line, lack) a trailing `\r`; the whole-line + /// replace restores the pristine CRLF bytes either way. + #[tokio::test] + async fn npm_bun_crlf_lock_round_trips_byte_exact() { + let pristine = bun_pristine().replace('\n', "\r\n"); + let (tmp, mut state) = npm_redirected_fixture("bun.lock", &pristine).await; + let root = tmp.path(); + let wired = read_lock(root).await; + assert!(wired.contains(NPM_URL), "{wired}"); + assert!( + wired.contains("\r\n"), + "CRLF preserved elsewhere: {wired:?}" + ); + + revert_redirect_purl(root, &mut state, NPM_PURL, false) + .await + .expect("CRLF takeover succeeds"); + assert_eq!( + read_lock(root).await, + pristine, + "CRLF bun.lock restored byte-identical" + ); + assert!(state.records.is_empty() && state.edits.is_empty()); + } + + /// (e) Two hosted records (different packages): the takeover of one + /// restores only its line and keeps the other purl's record + edit — + /// the state a scoped `rollback ` / `remove ` needs. #[tokio::test] - async fn npm_bun_lock_edit_is_a_fail_closed_refusal() { - // The bun revert is not implemented; a ledger claiming this purl via - // a bun.lock edit must refuse rather than drop the record while - // stranding the edit. + async fn npm_bun_two_hosted_records_takeover_of_one_leaves_the_other_hosted() { + const OTHER: &str = "pkg:npm/other@1.0.0"; + let (tmp, mut state) = npm_redirected_fixture_multi( + "bun.lock", + &bun_pristine(), + &[ + (NPM_PURL, npm_dep()), + (OTHER, npm_dep_for("other", "1.0.0")), + ], + ) + .await; + let root = tmp.path(); + let wired = read_lock(root).await; + let other_line = bun_line(&wired, "other"); + assert!(other_line.contains("other-1.0.0.tgz"), "{other_line}"); + + let out = revert_redirect_purl(root, &mut state, NPM_PURL, false) + .await + .expect("takeover succeeds"); + assert_eq!(out.reverted_files, vec!["bun.lock".to_string()]); + let after = read_lock(root).await; + assert_eq!( + bun_line(&after, "left-pad"), + bun_line(&bun_pristine(), "left-pad") + ); + assert_eq!(bun_line(&after, "other"), other_line, "other stays hosted"); + assert_eq!(state.edits.len(), 1); + assert_eq!(state.edits[0].key.as_deref(), Some("other")); + assert_eq!(state.records.len(), 1); + assert!(state.records.contains_key(OTHER)); + + // Taking over the second one finishes the job. + revert_redirect_purl(root, &mut state, OTHER, false) + .await + .expect("second takeover succeeds"); + assert_eq!(read_lock(root).await, bun_pristine()); + assert!(state.records.is_empty() && state.edits.is_empty()); + } + + /// bun's dry run mirrors the cargo/yarn contract: every inverse and + /// drift check resolves, nothing reaches disk, the in-memory ledger is + /// claimed, and the preview names the files a wet run rewrites. + #[tokio::test] + async fn npm_bun_dry_run_previews_without_touching_disk() { + let (tmp, mut state) = npm_redirected_fixture("bun.lock", &bun_pristine()).await; + let root = tmp.path(); + let wired = read_lock(root).await; + + let dry = revert_redirect_purl(root, &mut state, NPM_PURL, true) + .await + .expect("dry-run revert succeeds"); + assert_eq!(dry.reverted_files, vec!["bun.lock".to_string()]); + assert_eq!(read_lock(root).await, wired, "disk untouched"); + assert!(state.records.is_empty(), "record claimed in memory"); + assert!(state.edits.is_empty(), "edit claimed in memory"); + } + + /// A hand-edited ledger whose bun fragments mention the package but are + /// not entry lines cannot be attributed: refuse with the WORKING remedy + /// (the whole-ledger `rollback` replay) — never `bun install`, which + /// keeps a hosted URL tuple byte-identically — and keep the ledger. + #[tokio::test] + async fn npm_bun_unparseable_edit_mentioning_the_package_refuses_with_the_rollback_remedy() { let tmp = tempfile::tempdir().unwrap(); let mut state = RedirectState::new(); state.records.insert(NPM_PURL.to_string(), record()); state.edits.push(FileEdit { path: "bun.lock".into(), - kind: "redirect_bun_lock_package".into(), + kind: BUN_TEXT_KIND.into(), action: "rewritten".into(), key: Some("left-pad".into()), - original: Some(Value::String( - " \"left-pad\": [\"left-pad@1.3.0\", \"reg\", {}, \"sha512-p==\"],".into(), - )), - new: Some(Value::String(format!( - " \"left-pad\": [\"left-pad@{NPM_URL}\", {{}}, \"sha512-h==\"]," - ))), + original: Some(Value::String("\"left-pad@1.3.0\" (truncated".into())), + new: Some(Value::String(format!("\"left-pad@{NPM_URL}\" (truncated"))), }); let err = revert_npm_redirect_purl(tmp.path(), &mut state, NPM_PURL, false) .await - .expect_err("bun edits must refuse"); - assert!(err.contains("bun.lock"), "{err}"); + .expect_err("undecidable bun edit must refuse"); + assert!(err.contains("unscoped `socket-patch rollback`"), "{err}"); + assert!(err.contains("do not edit"), "{err}"); + assert!(!err.contains("bun install"), "{err}"); assert!(!state.records.is_empty(), "ledger keeps the record"); assert!(!state.edits.is_empty(), "ledger keeps the edit"); } + /// An alias install (`bun add alias@npm:left-pad@1.3.0`) keys the entry + /// by the alias; the rewriter matched it by spec, and so does the + /// claim. + #[tokio::test] + async fn npm_bun_alias_keyed_instance_is_claimed_by_spec() { + let pristine = r#"{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "alias-fixture", + "dependencies": { + "alias": "npm:left-pad@1.3.0", + }, + }, + }, + "packages": { + "alias": ["left-pad@1.3.0", "", {}, "sha512-XI5M=="], + } +} +"#; + let (tmp, mut state) = npm_redirected_fixture("bun.lock", pristine).await; + let root = tmp.path(); + assert_eq!(state.edits[0].key.as_deref(), Some("alias")); + revert_redirect_purl(root, &mut state, NPM_PURL, false) + .await + .expect("alias takeover succeeds"); + assert_eq!(read_lock(root).await, pristine); + assert!(state.records.is_empty() && state.edits.is_empty()); + } + + /// A user hand-restored bun.lock (git checkout): the revert is a clean + /// no-op that still drops the ledger entries. + #[tokio::test] + async fn npm_bun_hand_restored_lock_is_a_noop_that_drops_the_ledger() { + let (tmp, mut state) = npm_redirected_fixture("bun.lock", &bun_pristine()).await; + let root = tmp.path(); + tokio::fs::write(root.join("bun.lock"), bun_pristine()) + .await + .unwrap(); + let out = revert_redirect_purl(root, &mut state, NPM_PURL, false) + .await + .expect("revert succeeds"); + assert!(out.reverted_files.is_empty(), "{:?}", out.reverted_files); + assert_eq!(read_lock(root).await, bun_pristine()); + assert!(state.records.is_empty() && state.edits.is_empty()); + } + // ── refusal / degenerate arms of the fail-closed contract ──────────── #[tokio::test] @@ -2361,7 +3128,9 @@ mod tests { let cfg_before = tokio::fs::read_to_string(root.join(".cargo/config.toml")) .await .unwrap(); - tokio::fs::remove_file(root.join("Cargo.lock")).await.unwrap(); + tokio::fs::remove_file(root.join("Cargo.lock")) + .await + .unwrap(); let records_before = state.records.len(); let edits_before = state.edits.len(); @@ -2515,9 +3284,12 @@ mod tests { let wired_toml = tokio::fs::read_to_string(root.join("Cargo.toml")) .await .unwrap(); - tokio::fs::write(root.join("Cargo.toml"), format!("{wired_toml}{pinned_line}")) - .await - .unwrap(); + tokio::fs::write( + root.join("Cargo.toml"), + format!("{wired_toml}{pinned_line}"), + ) + .await + .unwrap(); let out = revert_cargo_redirect_purl(root, &mut state, PURL, false) .await @@ -2627,7 +3399,9 @@ mod tests { async fn npm_missing_text_lock_refuses_and_keeps_the_ledger() { let (tmp, mut state) = npm_redirected_fixture("yarn.lock", &classic_pristine()).await; let root = tmp.path(); - tokio::fs::remove_file(root.join("yarn.lock")).await.unwrap(); + tokio::fs::remove_file(root.join("yarn.lock")) + .await + .unwrap(); let records_before = state.records.len(); let edits_before = state.edits.len(); @@ -2821,7 +3595,10 @@ mod tests { let err = revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) .await .expect_err("vanished v2 tree must refuse"); - assert!(err.contains("no longer holds a `dependencies` tree"), "{err}"); + assert!( + err.contains("no longer holds a `dependencies` tree"), + "{err}" + ); let after = tokio::fs::read_to_string(root.join("package-lock.json")) .await .unwrap(); diff --git a/crates/socket-patch-core/src/utils/fs.rs b/crates/socket-patch-core/src/utils/fs.rs index e69ff91f..cdb77010 100644 --- a/crates/socket-patch-core/src/utils/fs.rs +++ b/crates/socket-patch-core/src/utils/fs.rs @@ -170,8 +170,34 @@ pub async fn is_symlink(path: &Path) -> bool { pub fn read_regular_to_string_sync(path: &Path) -> std::io::Result { use std::io::Read as _; + let (mut file, metadata) = open_regular_file_sync(path)?; + let mut content = String::with_capacity(metadata.len() as usize); + file.read_to_string(&mut content)?; + Ok(content) +} + +/// Raw-bytes twin of [`read_regular_to_string_sync`] for BINARY project +/// files the CLI captures verbatim (the hosted flow's pre-migration +/// `bun.lockb` snapshot): same non-blocking open + fstat regular-file check, +/// so a FIFO squatting the path fails fast with `InvalidInput` instead of +/// wedging in open(2) — and the caller can refuse BEFORE spawning a tool +/// that would block on the same FIFO. No UTF-8 decode, so a binary lock +/// never fails with `InvalidData`; every other error keeps its kind. +pub fn read_regular_to_bytes_sync(path: &Path) -> std::io::Result> { + use std::io::Read as _; + + let (mut file, metadata) = open_regular_file_sync(path)?; + let mut content = Vec::with_capacity(metadata.len() as usize); + file.read_to_end(&mut content)?; + Ok(content) +} + +/// Blocking twin of [`open_regular_file`]: `O_NONBLOCK` open on Unix, then +/// the handle-based regular-file check, so the two sync readers above share +/// one guard instead of re-declaring it. +fn open_regular_file_sync(path: &Path) -> std::io::Result<(std::fs::File, std::fs::Metadata)> { #[cfg(unix)] - let mut file = { + let file = { use std::os::unix::fs::OpenOptionsExt as _; std::fs::OpenOptions::new() .read(true) @@ -179,7 +205,7 @@ pub fn read_regular_to_string_sync(path: &Path) -> std::io::Result { .open(path)? }; #[cfg(not(unix))] - let mut file = std::fs::File::open(path)?; + let file = std::fs::File::open(path)?; let metadata = file.metadata()?; if !metadata.is_file() { @@ -188,9 +214,7 @@ pub fn read_regular_to_string_sync(path: &Path) -> std::io::Result { format!("{} is not a regular file", path.display()), )); } - let mut content = String::with_capacity(metadata.len() as usize); - file.read_to_string(&mut content)?; - Ok(content) + Ok((file, metadata)) } /// The first of `rels` (root-relative, in the caller's order) that is a @@ -917,6 +941,64 @@ mod tests { } } + /// The bytes twin returns a binary file VERBATIM (no UTF-8 decode, so + /// bytes that would be `InvalidData` for the string reader are fine), + /// keeps `NotFound` for an absent path and classifies a directory like + /// the string reader. + #[test] + fn read_regular_to_bytes_sync_returns_binary_verbatim_and_classifies_errors() { + let tmp = tempfile::tempdir().unwrap(); + let missing = read_regular_to_bytes_sync(&tmp.path().join("absent")).unwrap_err(); + assert_eq!(missing.kind(), std::io::ErrorKind::NotFound); + let dir = read_regular_to_bytes_sync(tmp.path()).unwrap_err(); + #[cfg(unix)] + assert_eq!(dir.kind(), std::io::ErrorKind::InvalidInput, "{dir}"); + #[cfg(not(unix))] + assert!( + matches!( + dir.kind(), + std::io::ErrorKind::InvalidInput | std::io::ErrorKind::PermissionDenied + ), + "{dir}" + ); + let lockb = tmp.path().join("bun.lockb"); + let bytes: Vec = vec![0x00, 0xff, 0xfe, b'b', b'u', b'n', 0x00, 0x80]; + std::fs::write(&lockb, &bytes).unwrap(); + assert_eq!(read_regular_to_bytes_sync(&lockb).unwrap(), bytes); + #[cfg(unix)] + { + let link = tmp.path().join("link.lockb"); + std::os::unix::fs::symlink("bun.lockb", &link).unwrap(); + assert_eq!(read_regular_to_bytes_sync(&link).unwrap(), bytes); + } + } + + /// A FIFO squatting `bun.lockb` must fail fast (`InvalidInput`), never + /// block in open(2): the hosted driver refuses the lockb migration on + /// this error BEFORE spawning bun (which would block on the same FIFO). + #[cfg(unix)] + #[test] + fn read_regular_to_bytes_sync_rejects_a_fifo_without_blocking() { + use std::os::unix::ffi::OsStrExt as _; + let tmp = tempfile::tempdir().unwrap(); + let fifo = tmp.path().join("bun.lockb"); + let c_path = std::ffi::CString::new(fifo.as_os_str().as_bytes()).unwrap(); + assert_eq!(unsafe { libc::mkfifo(c_path.as_ptr(), 0o644) }, 0); + let (tx, rx) = std::sync::mpsc::channel(); + let probe = fifo.clone(); + std::thread::spawn(move || { + let _ = tx.send(read_regular_to_bytes_sync(&probe).map_err(|e| e.kind())); + }); + match rx.recv_timeout(std::time::Duration::from_secs(5)) { + Ok(result) => assert_eq!(result, Err(std::io::ErrorKind::InvalidInput)), + Err(_) => { + // Release the wedged opener so the suite can fail cleanly. + let _ = std::fs::OpenOptions::new().write(true).open(&fifo); + panic!("the sync bytes reader wedged in open(2) on a FIFO"); + } + } + } + /// `first_symlink` reports the first LINK in iteration order, treats /// absent paths (files a rewrite would create) as non-links and does not /// follow the link to judge its target. diff --git a/crates/socket-patch-core/src/utils/pipenv.rs b/crates/socket-patch-core/src/utils/pipenv.rs index e0e50225..462ae593 100644 --- a/crates/socket-patch-core/src/utils/pipenv.rs +++ b/crates/socket-patch-core/src/utils/pipenv.rs @@ -44,60 +44,11 @@ fn parse_major(output: &str) -> Option { /// (`.`, an empty component) would execute a `pipenv` planted in the /// repository being scanned. On Windows every `PATHEXT` extension is tried, /// so `pipenv.exe` and the `pipenv.bat` / `pipenv.cmd` shims (pyenv-win, -/// hand-written wrappers) are both found. +/// hand-written wrappers) are both found. The rule lives in +/// [`crate::utils::process::resolve_tool_with`], shared with every other +/// tool the CLI spawns inside a project (`bun`). fn resolve_on_path(var: &impl Fn(&str) -> Option) -> Option { - let path = var("PATH")?; - let extensions: Vec = if cfg!(windows) { - var("PATHEXT") - .map(|value| { - value - .to_string_lossy() - .split(';') - .filter(|ext| !ext.is_empty()) - .map(|ext| ext.to_ascii_lowercase()) - .collect::>() - }) - .filter(|list| !list.is_empty()) - .unwrap_or_else(|| vec![".exe".into(), ".bat".into(), ".cmd".into()]) - } else { - vec![String::new()] - }; - for dir in std::env::split_paths(&path) { - if !dir.is_absolute() { - continue; - } - for ext in &extensions { - let candidate = dir.join(format!("pipenv{ext}")); - if candidate.is_file() && is_executable(&candidate) { - return Some(candidate); - } - } - } - None -} - -/// A plain file that cannot be executed (a stray `pipenv` data file on PATH) -/// is skipped in favour of the next entry, like execvp does; Windows has no -/// mode bits, PATHEXT is the executability rule there. -fn is_executable(path: &Path) -> bool { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::metadata(path).is_ok_and(|m| m.permissions().mode() & 0o111 != 0) - } - #[cfg(not(unix))] - { - let _ = path; - true - } -} - -fn is_batch_shim(program: &Path) -> bool { - cfg!(windows) - && program.extension().is_some_and(|ext| { - let ext = ext.to_string_lossy().to_ascii_lowercase(); - ext == "bat" || ext == "cmd" - }) + crate::utils::process::resolve_tool_with("pipenv", var) } /// The major of the `pipenv` on PATH (`11`, `2018`, `2026`, …), or `None` @@ -112,13 +63,10 @@ pub async fn installed_major(root: &Path) -> Option { return Some(forced); } let program = resolve_on_path(&|name| std::env::var_os(name))?; - let mut command = if is_batch_shim(&program) { - let mut command = tokio::process::Command::new("cmd.exe"); - command.arg("/C").arg(&program); - command - } else { - tokio::process::Command::new(&program) - }; + // The RESOLVED path is spawned; a Windows `.bat` / `.cmd` shim is run by + // `std` itself through cmd.exe with correct quoting (see the shared + // launcher's docs). + let mut command = tokio::process::Command::from(crate::utils::process::command_for(&program)); // The version banner does not depend on a project, so the probe runs in a // NEUTRAL directory: with the scanned repository as cwd, Pipenv would read // its `.env`, `Pipfile` and `.venv` pointer — committed, attacker-shaped @@ -159,12 +107,19 @@ mod tests { ), Some(2023) ); - assert_eq!(parse_major("Loading .env environment variables...\npipenv, version 2022.12.19"), Some(2022)); + assert_eq!( + parse_major("Loading .env environment variables...\npipenv, version 2022.12.19"), + Some(2022) + ); // …and a dotted number that is NOT the pipenv version is never taken. assert_eq!(parse_major("Python 3.12.0"), None); assert_eq!(parse_major("version"), None); assert_eq!(parse_major("version x.1"), None); - assert_eq!(parse_major("pipenv version 2024\n"), None, "no minor: not a version banner"); + assert_eq!( + parse_major("pipenv version 2024\n"), + None, + "no minor: not a version banner" + ); } #[test] @@ -172,12 +127,17 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let bin = tmp.path().join("bin"); std::fs::create_dir_all(&bin).unwrap(); - let leaf = if cfg!(windows) { "pipenv.exe" } else { "pipenv" }; + let leaf = if cfg!(windows) { + "pipenv.exe" + } else { + "pipenv" + }; std::fs::write(bin.join(leaf), b"").unwrap(); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(bin.join(leaf), std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::set_permissions(bin.join(leaf), std::fs::Permissions::from_mode(0o755)) + .unwrap(); } // A repo-planted `pipenv` under a RELATIVE entry must never win. let planted = tmp.path().join("planted"); @@ -193,7 +153,11 @@ mod tests { let var = |name: &str| (name == "PATH").then(|| joined.clone()); assert_eq!(resolve_on_path(&var), Some(bin.join(leaf))); - let only_relative = std::env::join_paths([std::path::PathBuf::from("."), std::path::PathBuf::from("planted")]).unwrap(); + let only_relative = std::env::join_paths([ + std::path::PathBuf::from("."), + std::path::PathBuf::from("planted"), + ]) + .unwrap(); let var = |name: &str| (name == "PATH").then(|| only_relative.clone()); assert_eq!(resolve_on_path(&var), None); let none = |_: &str| None::; @@ -210,9 +174,11 @@ mod tests { std::fs::create_dir_all(&data).unwrap(); std::fs::create_dir_all(&bin).unwrap(); std::fs::write(data.join("pipenv"), b"not a program").unwrap(); - std::fs::set_permissions(data.join("pipenv"), std::fs::Permissions::from_mode(0o644)).unwrap(); + std::fs::set_permissions(data.join("pipenv"), std::fs::Permissions::from_mode(0o644)) + .unwrap(); std::fs::write(bin.join("pipenv"), b"").unwrap(); - std::fs::set_permissions(bin.join("pipenv"), std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::set_permissions(bin.join("pipenv"), std::fs::Permissions::from_mode(0o755)) + .unwrap(); let joined = std::env::join_paths([data.clone(), bin.clone()]).unwrap(); let var = |name: &str| (name == "PATH").then(|| joined.clone()); assert_eq!(resolve_on_path(&var), Some(bin.join("pipenv"))); @@ -244,6 +210,16 @@ mod tests { }; let found = resolve_on_path(&var).unwrap(); assert_eq!(found, bin.join("pipenv.bat")); - assert!(is_batch_shim(&found)); + // Spawned directly: std runs the .bat through cmd.exe itself, and the + // banner parses like a real pipenv's. + let out = crate::utils::process::command_for(&found) + .arg("--version") + .output() + .expect("std spawns a .bat shim"); + assert!(out.status.success(), "{out:?}"); + assert_eq!( + parse_major(&String::from_utf8_lossy(&out.stdout)), + Some(2024) + ); } } diff --git a/crates/socket-patch-core/src/utils/process.rs b/crates/socket-patch-core/src/utils/process.rs index 68d8302e..8f6b7068 100644 --- a/crates/socket-patch-core/src/utils/process.rs +++ b/crates/socket-patch-core/src/utils/process.rs @@ -16,8 +16,110 @@ //! production callers either build the helper with the default //! runner or thread a singleton. +use std::ffi::OsString; +use std::path::{Path, PathBuf}; use std::process::Command; +/// The executable `name` on ABSOLUTE `PATH` entries only, or `None` when +/// no entry holds one. +/// +/// Shared by every tool the CLI spawns with the scanned project as its +/// working directory (`bun`, `pipenv`): a relative `PATH` component (`.`, +/// an empty string) resolves against the child's cwd, so a bare +/// `Command::new("bun")` would execute a `bun` planted in the repository +/// being scanned — and on macOS `posix_spawnp` can run BOTH the planted +/// file and the next absolute entry's binary for one spawn. Skipping +/// non-absolute entries closes that; callers must then spawn the RESOLVED +/// path, never the bare name. +/// +/// On Windows every `PATHEXT` extension is tried (falling back to +/// `.exe`/`.bat`/`.cmd` when the variable is unset or empty), so an npm-global +/// `bun.cmd` / pyenv-win `pipenv.bat` shim is found where Rust's own +/// `Command` resolution — which appends only `.exe` — would report NotFound. +/// A plain file without execute permission is skipped like execvp does. +pub fn resolve_tool(name: &str) -> Option { + resolve_tool_with(name, &|var| std::env::var_os(var)) +} + +/// [`resolve_tool`] over an injected environment reader (tests). +pub(crate) fn resolve_tool_with( + name: &str, + var: &impl Fn(&str) -> Option, +) -> Option { + let path = var("PATH")?; + let extensions: Vec = if cfg!(windows) { + var("PATHEXT") + .map(|value| { + value + .to_string_lossy() + .split(';') + .filter(|ext| !ext.is_empty()) + .map(|ext| ext.to_ascii_lowercase()) + .collect::>() + }) + .filter(|list| !list.is_empty()) + .unwrap_or_else(|| vec![".exe".into(), ".bat".into(), ".cmd".into()]) + } else { + vec![String::new()] + }; + for dir in std::env::split_paths(&path) { + if !dir.is_absolute() { + continue; + } + for ext in &extensions { + let candidate = dir.join(format!("{name}{ext}")); + if candidate.is_file() && is_executable(&candidate) { + return Some(candidate); + } + } + } + None +} + +/// A plain file that cannot be executed (a stray `bun` data file on PATH) +/// is skipped in favour of the next entry, like execvp does; Windows has no +/// mode bits, PATHEXT is the executability rule there. +fn is_executable(path: &Path) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::metadata(path).is_ok_and(|m| m.permissions().mode() & 0o111 != 0) + } + #[cfg(not(unix))] + { + let _ = path; + true + } +} + +/// A [`Command`] that launches the RESOLVED `program` — the absolute path +/// [`resolve_tool`] found, never the bare name. Callers add their own args / +/// cwd / env; `tokio::process::Command::from` lifts it into the async +/// runtime unchanged. +/// +/// A Windows `.bat` / `.cmd` shim (npm-global `bun.cmd`, pyenv-win +/// `pipenv.bat`) is spawned through this same path: since Rust 1.77.2 (the +/// BatBadBut fix) `std` detects the batch extension on the resolved program +/// and runs `%SystemRoot%\System32\cmd.exe /e:ON /v:OFF /d /c ""