Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: SocketDev/socket-patch
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: 3a58178
Choose a base ref
...
head repository: SocketDev/socket-patch
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: c20619b
Choose a head ref
  • 13 commits
  • 192 files changed
  • 3 contributors

Commits on Apr 9, 2026

  1. fix: use npx/pnpm dlx @socketsecurity/socket-patch and add dependenci…

    …es script (#63)
    
    Update the setup command to generate the correct npx/pnpm dlx command
    prefix based on lockfile detection, and configure both postinstall
    and dependencies lifecycle scripts.
    
    - Add PackageManager enum (Npm/Pnpm) with lockfile detection
    - Generate `npx @socketsecurity/socket-patch apply` for npm projects
    - Generate `pnpm dlx @socketsecurity/socket-patch apply` for pnpm projects
    - Add dependencies lifecycle script alongside postinstall
    - Thread PackageManager through detect -> update -> setup pipeline
    
    Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    mikolalysenko and claude authored Apr 9, 2026
    Configuration menu
    Copy the full SHA
    d2cf981 View commit details
    Browse the repository at this point in the history

Commits on May 12, 2026

  1. Bump quinn-proto from 0.11.13 to 0.11.14 (#40)

    Bumps [quinn-proto](https://github.com/quinn-rs/quinn) from 0.11.13 to 0.11.14.
    - [Release notes](https://github.com/quinn-rs/quinn/releases)
    - [Commits](quinn-rs/quinn@quinn-proto-0.11.13...quinn-proto-0.11.14)
    
    ---
    updated-dependencies:
    - dependency-name: quinn-proto
      dependency-version: 0.11.14
      dependency-type: indirect
    ...
    
    Signed-off-by: dependabot[bot] <support@github.com>
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
    dependabot[bot] authored May 12, 2026
    Configuration menu
    Copy the full SHA
    d9ff0e5 View commit details
    Browse the repository at this point in the history
  2. chore(deps): Bump rand from 0.9.2 to 0.9.4 (#65)

    Bumps [rand](https://github.com/rust-random/rand) from 0.9.2 to 0.9.4.
    - [Release notes](https://github.com/rust-random/rand/releases)
    - [Changelog](https://github.com/rust-random/rand/blob/0.9.4/CHANGELOG.md)
    - [Commits](rust-random/rand@rand_core-0.9.2...0.9.4)
    
    ---
    updated-dependencies:
    - dependency-name: rand
      dependency-version: 0.9.4
      dependency-type: indirect
    ...
    
    Signed-off-by: dependabot[bot] <support@github.com>
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
    dependabot[bot] authored May 12, 2026
    Configuration menu
    Copy the full SHA
    ec5d349 View commit details
    Browse the repository at this point in the history
  3. chore(deps): Bump rustls-webpki from 0.103.9 to 0.103.13 (#66)

    Bumps [rustls-webpki](https://github.com/rustls/webpki) from 0.103.9 to 0.103.13.
    - [Release notes](https://github.com/rustls/webpki/releases)
    - [Commits](rustls/webpki@v/0.103.9...v/0.103.13)
    
    ---
    updated-dependencies:
    - dependency-name: rustls-webpki
      dependency-version: 0.103.13
      dependency-type: indirect
    ...
    
    Signed-off-by: dependabot[bot] <support@github.com>
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
    dependabot[bot] authored May 12, 2026
    Configuration menu
    Copy the full SHA
    5550100 View commit details
    Browse the repository at this point in the history

Commits on May 19, 2026

  1. feat(patch): add package- and diff-level patch sources (#67)

    * feat(patch): add package- and diff-level patch sources
    
    Adds two new optional pathways to the socket-patch CLI alongside the
    existing per-file blob path:
    
    - Per-package archives at `.socket/packages/<uuid>.tar.gz` — a tarball
      of patched files for a single patch, extracted in one shot.
    - Per-file bsdiff archives at `.socket/diffs/<uuid>.tar.gz` — bsdiff
      deltas that transform `before_hash` content into `after_hash` content.
    
    The apply pipeline now tries sources in the order package → diff →
    blob, falling through to the next on any failure. Every strategy
    post-write-verifies the file's git-sha256 against `after_hash`, so the
    existing safety invariant is unchanged.
    
    A new `--download-mode {diff,package,file}` flag (default: `diff`)
    controls what `apply`, `get`, `scan`, and `repair` fetch when local
    artifacts are missing. The manifest schema is intentionally unchanged:
    archives are keyed by patch UUID (already present in `PatchRecord`),
    so legacy manifests keep working with no migration.
    
    Highlights:
    
    - New core modules `patch/diff.rs` (qbsdiff bspatch wrapper) and
      `patch/package.rs` (tar+flate2 reader with path-traversal guards,
      whitelist filtering against `expected_files`, and hard caps on
      decompressed bytes / per-entry size / entry count to defuse
      gzip-bomb and `Vec::with_capacity` allocation attacks).
    - New `PatchSources` struct and `AppliedVia` enum in `patch/apply.rs`;
      `apply_package_patch` takes a `PatchSources` and an optional UUID.
      Passing `uuid = None` restores pre-2.2 blob-only behavior.
    - `try_apply_from_diff` gates on the captured pre-apply `current_hash`
      rather than `VerifyStatus`, so `--force` cannot drive a diff against
      garbage content.
    - `apply`'s offline guard now reports per-patch source availability
      instead of a global blobs/diffs/packages bucket count.
    - `ApiClient::fetch_diff(uuid)` and `fetch_package(uuid)` mirror
      `fetch_blob(hash)`; a private `fetch_binary` helper deduplicates the
      proxy/auth client split and 200/404/error handling.
    - `DownloadMode` enum + `fetch_missing_sources` in `api/blob_fetcher.rs`
      dispatch downloads by kind. `cleanup_unused_archives` in
      `utils/cleanup_blobs.rs` reaps orphaned `.socket/packages/` and
      `.socket/diffs/` files via `repair`.
    
    Tests: 307 unit + 2 e2e gem (was 263 + 2 before this change). New
    coverage spans diff round-trips, package extraction safety (traversal,
    oversize-header, too-many-entries, decompression-bomb truncation),
    fallback chain ordering (`via package`/`diff`/`blob`), force-mode +
    diff regression, dry-run safety, UUID validation, and archive
    download/cleanup helpers. All existing tests pass unchanged.
    
    Server-side `/patch/diff/<uuid>` and `/patch/package/<uuid>` endpoints
    are not live yet — 404 responses fall through gracefully to the file
    blob path, so this PR ships safely ahead of server support.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * chore: clean up stray dead-code markers
    
    - e2e_npm.rs: NPM_PURL is actually used by 5 assertions; drop the
      stale `#[allow(dead_code)]`.
    - maven_crawler.rs: remove `read_pom_in_dir`, an async helper that
      was never called and only existed under `#[allow(dead_code)]`.
    
    No behavior change. 307 tests still pass; cargo build clean.
    
    Assisted-by: Claude Code:opus-4-7
    mikolalysenko authored May 19, 2026
    Configuration menu
    Copy the full SHA
    6288a37 View commit details
    Browse the repository at this point in the history

Commits on May 20, 2026

  1. test(cli): foundation for CLI-contract unit-test campaign (#68)

    * test(cli): comprehensive unit-test campaign for the CLI contract
    
    Add unit-test coverage for every subcommand, flag, default, alias, and
    helper in the socket-patch CLI, plus a new CLI_CONTRACT.md that documents
    the surface as semver-significant.
    
    Before this change the CLI crate had zero unit tests under src/ —
    only network-dependent tests/e2e_*.rs suites gated on --ignored. A flag
    rename, a default change, or a JSON key drift could land green and break
    every shipped wrapper (npm @socketsecurity/socket-patch, pypi
    socket-patch, cargo, prebuilt binaries).
    
    ## What's covered
    
    Library surface (new src/lib.rs):
      - Cli, Commands, looks_like_uuid, parse_with_uuid_fallback extracted
        from main.rs so integration tests can verify the parser without
        spawning the binary.
      - main.rs becomes a thin wrapper that delegates to the lib.
      - Cargo.toml gains [lib] alongside [[bin]].
    
    Core helper extraction:
      - socket_patch_core::manifest::operations::resolve_manifest_path
        replaces the 5-line absolute-vs-relative join block previously
        copy-pasted into apply/rollback/list/remove/repair (5 callers).
    
    CLI_CONTRACT.md (new):
      - Documents every subcommand, flag, default value, visible alias
        (download, gc), hidden alias (--no-apply), JSON output shape, and
        exit code as semver-significant.
      - Pins the divergent defaults: --download-mode=diff for apply/get/scan
        and --download-mode=file for repair, --batch-size=100 for scan.
      - Spells out the bump policy and how to invoke scripts/version-sync.sh.
    
    Helper unit tests (#[cfg(test)] mod tests in each file):
      - src/lib.rs — looks_like_uuid (valid/invalid shapes, case-insensitive),
        parse_with_uuid_fallback (success, fallback, fallback-fails preserves
        original error, no double-rewrite).
      - src/output.rs — format_severity, color, confirm (skip_prompt and
        is_json short-circuits; interactive path intentionally not tested).
      - src/ecosystem_dispatch.rs — partition_purls (filter, dedup, unknown
        ecosystem dropped).
      - commands/apply.rs — verify_status_str (all 4 VerifyStatus variants),
        result_to_json (top-level + filesVerified key sets).
      - commands/rollback.rs — find_patches_to_rollback (None=all, PURL match,
        UUID match, no-match=empty).
      - commands/get.rs — detect_identifier_type (UUID/CVE/GHSA/PURL/None,
        case-insensitive for CVE+GHSA), select_patches (paid auto, free
        single auto, free multi+is_json -> Err(1)).
    
    Clap parser snapshot tests (new tests/cli_parse_*.rs):
      - One file per command: apply, get, list, main, remove, repair,
        rollback, scan, setup.
      - Every flag (long + short) has at least one parser assertion.
      - Every #[arg(default_value)] is asserted on the no-flag parse.
      - The download visible alias on get is exercised.
      - The gc visible alias on repair is exercised.
      - The hidden --no-apply alias on get --save-only is exercised.
      - --ecosystems CSV splitting is verified on apply/rollback/scan.
      - The bare-UUID rewrite is exercised end-to-end via Cli::try_parse_from.
      - Failure paths assert clap::error::ErrorKind variants.
    
    Async run() integration tests:
      - tests/cli_parse_list.rs covers missing manifest -> 1, empty -> 0,
        populated -> 0, absolute-path override, and a subprocess JSON-shape
        assertion against the compiled binary.
      - tests/cli_parse_remove.rs covers missing-manifest -> 1.
      - tests/cli_parse_setup.rs covers no-package-json -> 0 with the
        JSON status:"no_files" shape pinned via subprocess.
    
    ## Verification
    
      cargo build --workspace --all-features
      cargo clippy --workspace --all-features -- -D warnings
      cargo test --workspace --all-features
    
    All clean. 79 new lib tests + 156 new integration tests added on top of
    the existing 415 unit tests; cumulative 650 tests pass.
    
    ## Why squashed
    
    Originally landed as a foundation PR (#68) plus 10 sibling test PRs
    (#69-#78), one per command/file, dispatched in parallel. Squashing the
    sibling PRs back into #68 so the contract + tests land as one
    self-contained unit — reviewers see the full picture, and a future
    revert touches one commit instead of eleven.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * ci: tighten CI matrix — add macOS, release-mode tests, explicit build step
    
    The CI workflow's unit-test job ran on ubuntu-latest + windows-latest;
    extend the matrix with macos-latest so the new CLI parser tests are
    exercised on every platform the binary ships for. socket-patch ships
    prebuilt binaries for x86_64-apple-darwin and aarch64-apple-darwin (see
    release.yml), so silent macOS-specific regressions in path handling,
    TTY detection, or terminal escapes are real risks today.
    
    Three changes:
    
      - Add `macos-latest` to the test matrix.
      - Add `fail-fast: false` so a failure on one OS doesn't mask failures
        on the others.
      - Add an explicit `cargo build --workspace --all-features` step
        before `cargo test`. `cargo test` already builds, but a dedicated
        build step gives a cleaner red signal when a build-only failure
        happens (e.g. a feature-gated compile error) without the noise of
        test-discovery output.
      - New `test-release` job: `cargo test --workspace --all-features
        --release` on ubuntu-latest. Catches optimization-level regressions
        that debug mode hides (e.g. release-mode-only inlining changes that
        affect assertion behavior). One OS keeps total CI time reasonable
        while still locking in release-mode correctness.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * fix(patch): reject POSIX-style absolute paths in archive entries on Windows
    
    `read_archive_to_map` rejects entries whose path is absolute or contains
    a `..` component, but the check used `Path::is_absolute()` alone. On
    Windows that function requires a drive letter or UNC prefix, so a tar
    entry like `/etc/passwd` is NOT considered absolute and would slip
    through the guard — when later joined to the target directory, Windows
    would treat it as relative to the current drive's root.
    
    Add an explicit check for a leading `/` or `\` byte alongside
    `Path::is_absolute()` so the guard rejects POSIX-style absolute paths
    on every platform. The new test_read_archive_rejects_backslash_absolute_paths
    case locks the symmetric backslash form in.
    
    This was uncovered when the CI matrix was extended to actually run on
    Windows. The existing test_read_archive_rejects_absolute_paths failed on
    windows-latest because it constructed the archive with a POSIX-style
    path that the platform-specific `is_absolute()` did not catch.
    
    Assisted-by: Claude Code:claude-opus-4-7
    mikolalysenko authored May 20, 2026
    Configuration menu
    Copy the full SHA
    b96a13f View commit details
    Browse the repository at this point in the history

Commits on May 22, 2026

  1. feat(scan): unified auto-update engine — --sync, --prune, --dry-run (…

    …v3.0) (#79)
    
    * feat(scan): add --apply + structured updates for auto-update bot workflows
    
    Enables `socket-patch scan` as the engine for an automated "update all
    patches" workflow — a cron job or PR check that runs scan, detects new
    or updated patches against the local manifest, applies them, and either
    commits the change or opens a PR. Today this isn't quite possible because:
    
      * `scan --json` is read-only — it prints the discovery JSON and exits
        before the apply path runs, so there's no clean way to make it
        mutate the manifest from a bot.
      * Updates aren't reported in JSON — update detection (existing
        manifest entry with same PURL but different UUID) only runs in the
        non-JSON table-print path, so a `--json` consumer can't tell which
        patches would be updates vs net-new additions.
      * Per-patch JSON records lose the added-vs-updated distinction — every
        successful download is reported as `action: "added"` even when it's
        replacing an existing entry with a newer UUID.
    
    Three additive (semver-MINOR) changes resolve all of the above:
    
    1. `commands/get.rs` — `download_and_apply_patches` now emits per-patch
       `{action: "updated", oldUuid}` when the PURL already had a different
       UUID before insert. A new pure helper `decide_patch_action(manifest,
       purl, new_uuid)` returns `Added | Updated{old_uuid} | Skipped` and is
       unit-tested independently.
    
    2. `commands/scan.rs` — new `--apply` flag (default `false`) opts JSON
       callers into the full discover → select → apply pipeline. Without
       `--apply`, `scan --json` keeps its prior read-only contract; with it,
       `scan --json --apply` runs the same selection + download path the
       non-JSON branch uses and emits one combined JSON object with an
       `apply` sub-object reporting per-patch outcomes. The JSON discovery
       emission also now always includes a top-level `updates` array (with
       `purl`, `oldUuid`, `newUuid`) computed via a new pure helper
       `detect_updates`. `severity_order` is exposed as `pub(crate)` so it
       can be unit-tested.
    
    3. `CLI_CONTRACT.md` documents the new `--apply` flag, the full
       `scan` discovery and `--apply` JSON shapes, and pins the per-patch
       action vocabulary (`added`/`updated`/`skipped`/`failed`) with semver
       policy clauses for adding (MINOR) or renaming/removing (MAJOR) values.
    
    ## Tests
    
      * scan.rs inline #[cfg(test)] mod tests — 4 severity_order cases +
        8 detect_updates cases covering: no manifest, empty packages, no
        overlap, same UUID, different UUID, multiple updates, empty patch
        list, first-patch candidate selection.
      * get.rs inline test module — 4 decide_patch_action cases covering
        Added (no existing entry), Skipped (same UUID), Updated (different
        UUID with oldUuid populated), and Added-for-different-PURL (keying
        on PURL not UUID).
      * tests/cli_parse_scan.rs — `--apply` parser tests (defaults false,
        long form, combines with --json/--yes) + a subprocess JSON-shape
        test that runs the compiled binary against an empty tempdir and
        asserts the new `updates: []` key is present in stdout.
    
    All 416 lib tests pass, all integration tests pass, clippy clean.
    
    ## How a bot uses this
    
    ```bash
    socket-patch scan --json --apply --yes > scan-result.json
    jq '.apply.patches[] | select(.action == "updated") | {purl, oldUuid, uuid}' scan-result.json
    # Pipe into peter-evans/create-pull-request with a PR body summarizing the diff.
    ```
    
    Exit code: 0 on full success (every selected patch added/updated/skipped),
    1 if any `failed` records are present (and top-level `status` becomes
    `"partial_failure"`).
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * feat(scan)!: garbage-collect on scan + hide gc subcommand (v3.0)
    
    After PR #79's --apply work, scan applied patches but didn't reconcile
    state. Orphan blob files accumulated and manifest entries for
    uninstalled packages stayed forever, forcing bots to chain `scan --apply`
    with `repair` themselves. This commit makes scan the single command
    needed for the auto-update workflow:
    
      * Default GC after every scan run that has scanned packages. Removes
        manifest entries for PURLs no longer in the crawl results, then
        sweeps orphan blob/diff/package-archive files via the existing
        cleanup_unused_blobs / cleanup_unused_archives helpers.
      * New --no-prune flag opts OUT of GC entirely. Useful when a missing
        package reflects a temporary uninstall the user wants to preserve.
      * The `gc` subcommand alias (and `repair` itself) is hidden from
        socket-patch --help. `socket-patch gc` still parses for backwards
        compat, just no longer listed. Existing scripts unaffected.
      * Workspace version bumped 2.1.4 → 3.0.0. scripts/version-sync.sh
        propagated the bump to every npm/socket-patch-* package.json and to
        pypi/socket-patch/pyproject.toml.
    
    ## JSON output additions
    
    In `scan --json` (read-only): new `gc` sub-object reports what *would*
    be pruned/reaped without mutating anything (preview mode). Fields:
    prunableManifestEntries, orphanBlobs, orphanDiffArchives,
    orphanPackageArchives, bytesReclaimable.
    
    In `scan --json --apply`: `gc` switches to mutation mode. Fields:
    prunedManifestEntries, removedBlobs, removedDiffArchives,
    removedPackageArchives, bytesFreed.
    
    With --no-prune: gc is emitted as { "skipped": true } in both modes.
    
    In the empty-crawl case (no packages found at all), gc is { "skipped":
    true } — pruning every manifest entry on the assumption the user
    "uninstalled everything" is too destructive.
    
    ## Tests
    
      * 5 new detect_prunable unit tests covering empty manifest, all
        present, missing entries, and full prune.
      * --no-prune parser tests in tests/cli_parse_scan.rs (default false,
        long form, combines with --apply/--json/--yes).
      * 4 new tests in tests/cli_parse_repair.rs locking the v3.0
        deprecation: top-level --help doesn't list `repair` or `[aliases:
        gc]`, but `socket-patch gc` still resolves to Repair and
        `socket-patch repair --help` still works directly.
      * CleanupResult gains #[derive(Default)] so scan can build empty
        summaries when the cleanup helpers report errors.
    
    cargo build/clippy/test --workspace --all-features all clean.
    100 lib tests in CLI (+5), 19 in cli_parse_repair (+4), 26 in
    cli_parse_scan (+2). 416 lib tests in core unchanged.
    
    ## Breaking changes (MAJOR bump 2.1.4 → 3.0.0)
    
      * scan --apply prunes manifest entries for uninstalled packages by
        default. Scripts that ran `scan --apply --yes` and relied on
        manifest entries surviving across an uninstall break unless they
        add --no-prune.
      * scan --apply removes orphan blob/archive files on every run
        (non-breaking in practice — the apply path simply re-fetches
        anything it needs — but a visible filesystem change).
      * `socket-patch gc` no longer appears in top-level --help. The
        subcommand still works.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * test(scan): add e2e_scan test suite + CI matrix entry
    
    End-to-end tests for the scan + GC pipeline that uses the real Socket
    API. Mirrors the structure of tests/e2e_npm.rs — every test is
    #[ignore] so it only runs with --ignored, matching the existing e2e
    gating in .github/workflows/ci.yml. Uses the minimist@1.2.2 patch
    fixture (CVE-2021-44906) that the other e2e tests already share.
    
    ## Coverage (9 scenarios)
    
      * test_scan_apply_json_adds_new_patch — fresh install,
        `scan --json --apply --yes` reports action: "added" and patches
        the file on disk.
      * test_scan_apply_json_skips_existing — re-run shows action:
        "skipped".
      * test_scan_apply_json_updates_existing — seed manifest with a fake
        UUID, re-run shows action: "updated" with oldUuid populated.
      * test_scan_json_read_only_emits_updates_array — read-only mode
        surfaces the manifest-vs-API drift in the `updates` array.
      * test_scan_json_read_only_no_mutation — `scan --json` never
        creates a manifest or modifies files.
      * test_scan_apply_prunes_uninstalled_package_by_default — uninstall
        minimist, re-scan, manifest entry is gone + blobs are reaped.
      * test_scan_apply_no_prune_keeps_uninstalled_entries — same
        scenario with --no-prune leaves manifest + blobs intact, gc reports
        { skipped: true }.
      * test_scan_apply_cleans_orphan_blobs — plant a stray orphan blob,
        next scan run removes it and reports gc.removedBlobs >= 1.
      * test_scan_json_read_only_gc_preview — preview mode lists
        prunableManifestEntries and counts orphanBlobs without mutating.
    
    ## CI integration
    
      * Added e2e_scan to the e2e job matrix on ubuntu-latest and
        macos-latest (mirrors how e2e_npm is matrixed).
      * Setup Node.js step's `if:` predicate extended to also run for
        e2e_scan — the suite shells out to `npm install` for fixture setup.
    
    Each #[ignore] test self-skips with a SKIP message if `npm` is not
    on PATH, so a future runner without npm doesn't fail spuriously.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * docs(scan): document v3.0 GC behavior + repair deprecation
    
    CLI_CONTRACT.md changes:
    
      * Add --no-prune row to the scan flag table with a description of
        the v3.0 GC default.
      * Extend the scan JSON output shape with the new `gc` sub-object.
        Document the split between preview-mode field names
        (prunable*/orphan*/bytesReclaimable) and apply-mode field names
        (pruned*/removed*/bytesFreed). Document that --no-prune emits
        gc: { skipped: true } in both modes.
      * Mark `repair` as "(deprecated since v3.0)" at the section heading.
        Spell out the demotion: `hide = true` on the Repair variant and
        `alias = "gc"` (was `visible_alias`). Removing repair or
        unhiding it would be a MAJOR bump.
      * Add semver-policy row: "Change `scan`'s default behavior (e.g.
        pruning, GC, apply) — MAJOR." Notes the v3.0 flip is the one
        grandfathered instance; future flips also MAJOR.
    
    README.md changes:
    
      * Remove the `repair`/`gc` section from the public command list (still
        documented in CLI_CONTRACT.md for advanced users).
      * Expand the `scan` section: add --apply and --no-prune flags,
        -y/--yes, --download-mode rows. New "Bot mode" example with
        `scan --json --apply --yes`. Add "Apply without pruning" example.
        Brief note about scan being the single command for the
        auto-update workflow.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * fix(scan): auto-select in JSON --apply when multiple free patches exist
    
    The free-tier patch API may serve multiple free patches for the same
    PURL (e.g., minimist@1.2.2 currently has 2 free patches). The
    `scan --json --apply` path was calling `select_patches(... is_json =
    true)` which returns `Err(JsonModeNeedsExplicit)` with
    `status: "selection_required"` in that scenario — no forward progress,
    the bot can't apply anything.
    
    For scan-driven workflows there's no "specify --id" option (we're
    scanning the whole project), so the right behavior is to auto-select
    the newest patch and continue. Pass `is_json = false` so the
    non-TTY branch inside `select_one` auto-selects index 0 — which is the
    most-recently-published patch (the group is sorted by `published_at`
    descending before `select_one` runs).
    
    Also relaxed the e2e_scan test assertions so they don't pin a specific
    upstream UUID/hash:
      * added/updated/skipped tests assert action vocabulary, PURL match,
        and "file was patched" (not exact AFTER_HASH).
      * updated test asserts the new UUID differs from the seeded oldUuid
        rather than matching a hardcoded constant.
      * read-only updates test similarly asserts `newUuid != oldUuid`.
    
    These changes make the e2e suite robust to API churn — the contract
    is "an apply happened", not "this specific patch was selected".
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * fix(scan): run apply-mode GC even when no packages have patches
    
    The post-uninstall scenario hit an edge case: after a user uninstalls
    the only patched package and installs a new (unpatched) one, the next
    `scan --apply --yes` would crawl successfully, find no packages with
    patches, and skip the entire `--apply` block. The read-only preview GC
    ran instead, emitting `gc.prunableManifestEntries` (preview field name)
    but never actually pruning anything from the manifest.
    
    A bot relying on `scan --apply` to reach a clean state would loop
    forever — the stale manifest entry never gets removed.
    
    Fix: when `--apply` is set but no packages have patches, still run the
    mutating GC pass and emit an empty `apply` sub-object plus the
    `gc.prunedManifestEntries` (apply field name). Bots can now trust
    `scan --apply --yes` to converge to a clean state in one pass even when
    the crawl has no patched packages.
    
    Also dropped the now-unused `NPM_UUID` and `AFTER_HASH` constants from
    the e2e_scan test file (warning noise from relaxing the assertions in
    the previous commit).
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * feat(scan): pivot GC to opt-in via --prune + add --sync and --dry-run
    
    Reverses the v3.0 GC-by-default decision. After feedback, default-on
    GC was too aggressive: scripts running `scan --apply --yes` against a
    project with a temporarily-uninstalled package would silently destroy
    the manifest entry, breaking dev workflows where the package gets
    reinstalled later.
    
    The new opt-in model:
    
      * `--prune` (new, default false) opts into garbage collection.
        Manifest entries for packages no longer in the crawl are removed,
        then `cleanup_unused_blobs` + `cleanup_unused_archives` sweep
        orphan files. Without `--prune`, scan leaves `.socket/` alone.
      * `--sync` (new) is sugar for `--apply --prune`. The canonical bot
        invocation becomes `scan --json --sync --yes` (3 flags; `--json`
        and `--yes` are workflow scaffolding).
      * `--dry-run` / `-d` (new) previews what `--apply`/`--prune`/`--sync`
        would do without mutating disk. The `apply.patches[*]` array is
        populated via `decide_patch_action`, and `gc.prunable*` /
        `gc.orphan*` field names are emitted (instead of `pruned*` /
        `removed*`). The `apply.dryRun: true` flag explicitly marks the
        output for bots that need a single signal.
      * `--no-prune` field removed (it was the inverse of the now-default
        behavior).
    
    ## Implementation
    
      * `ScanArgs.no_prune` → `ScanArgs.prune` (semantics inverted). New
        `sync` and `dry_run` fields.
      * At the top of `scan::run`, `let apply = args.apply || args.sync;`
        and `let prune = args.prune || args.sync;` — derive once, use
        everywhere downstream. `--sync` is purely additive sugar.
      * `run_apply_gc` no longer takes a `no_prune: bool` parameter —
        callers always gate on `prune` before calling it. When GC isn't
        requested, the `gc` JSON field is omitted entirely (no
        `{ "skipped": true }` placeholder).
      * New `preview_apply_gc` helper for the dry-run path. Runs
        `cleanup_unused_blobs` / `cleanup_unused_archives` with
        `dry_run=true` and emits preview field names via
        `GcSummary::to_preview_json`.
      * Dry-run apply path synthesizes per-patch `apply.patches[]`
        records via `super::get::decide_patch_action` against the on-disk
        manifest — accurately reports added/updated/skipped for the
        selected patches without actually calling
        `download_and_apply_patches`.
      * Empty-cwd JSON branch drops `gc: { skipped: true }` (no `gc`
        field at all when GC wasn't requested).
    
    Drive-by fix: `tests/ecosystem_dispatch::partition_purls_allow_list_excludes_one`
    now uses `!map.contains_key(&Ecosystem::Pypi)` instead of the
    `unnecessary_get_then_check` lint trigger.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * revert(repair): restore gc as a documented subcommand
    
    The prior v3.0 iteration demoted `Commands::Repair`'s `gc` alias to a
    hidden `alias = "gc"` and added `hide = true` on the subcommand itself,
    banking on `scan` becoming the all-in-one command for both apply and
    GC. With GC now opt-in via `--prune`/`--sync` (see prior commit),
    `repair`/`gc` is the right answer for users who want to clean up
    without an apply pass.
    
    Restore `#[command(visible_alias = "gc")]` and drop `hide = true` so
    the subcommand appears in `socket-patch --help` again with its
    `[aliases: gc]` hint.
    
    Update the four hidden-help tests in `tests/cli_parse_repair.rs`:
    
      * `repair_is_hidden_from_top_level_help` →
        `repair_appears_in_top_level_help` (assertion inverted).
      * `gc_alias_is_hidden_from_top_level_help` →
        `gc_alias_is_visible_in_top_level_help` (assertion inverted).
      * `gc_alias_still_parses_for_backwards_compat` →
        `gc_alias_parses_as_repair` (simplified — alias is no longer
        deprecated, so the "backwards compat" framing is gone).
      * `repair_subcommand_help_still_works_directly` dropped (was a
        deprecation-era assertion).
    
    These tests now lock the *opposite* contract: removing or hiding the
    `gc` alias is a MAJOR bump.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * test(scan): update parser + e2e tests for opt-in GC
    
    cli_parse_scan.rs:
      * defaults_match_contract now asserts !args.prune, !args.sync,
        !args.dry_run (replacing the old !args.no_prune line).
      * no_prune_flag_long_form → prune_flag_long_form; assertion
        inverted (passing --prune sets prune=true).
      * no_prune_combines_with_apply_and_json → prune_combines_with_apply_and_json.
      * NEW: sync_flag_long_form — --sync sets sync=true; does NOT
        auto-derive --apply/--prune at parse time (that derivation
        happens inside scan::run).
      * NEW: sync_combines_with_json_and_yes.
      * NEW: dry_run_long_form (--dry-run sets dry_run=true).
      * NEW: dry_run_short_form (-d sets dry_run=true).
    
    e2e_scan.rs:
      * Module docstring updated to describe opt-in GC.
      * test_scan_apply_prunes_uninstalled_package_by_default →
        test_scan_apply_prune_prunes_uninstalled_package — now passes
        --prune explicitly.
      * test_scan_apply_no_prune_keeps_uninstalled_entries →
        test_scan_apply_default_keeps_uninstalled_entries — drops the
        --no-prune flag (it no longer exists); asserts the gc field is
        omitted entirely.
      * test_scan_apply_cleans_orphan_blobs →
        test_scan_apply_prune_cleans_orphan_blobs — passes --prune.
      * test_scan_json_read_only_gc_preview split into:
        - test_scan_dry_run_sync_previews_apply_and_gc — exercises the
          new --dry-run flag combined with --sync; verifies preview
          output is populated AND nothing on disk changed.
        - test_scan_json_no_gc_field_without_prune — locks the contract
          that `gc` is omitted when --prune isn't set.
      * NEW: test_scan_sync_yes_full_lifecycle — installs minimist, runs
        --sync (adds patch), uninstalls + plants orphan, runs --sync again
        (prunes + sweeps). End-to-end exercise of the canonical bot mode.
    
    Total e2e_scan scenarios: 11 (was 9).
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * docs(scan): document --prune/--sync/--dry-run + un-deprecate gc
    
    CLI_CONTRACT.md:
      * Scan flag table: replace --no-prune row with three new rows —
        --prune, --sync, -d/--dry-run. Add a paragraph explaining each
        plus the canonical bot-mode invocation.
      * JSON output shape: drop the --no-prune-emits-{skipped:true} note.
        Clarify that `gc` is omitted ENTIRELY when --prune/--sync isn't
        set. Document --dry-run behavior including the explicit
        `apply.dryRun: true` marker for bots.
      * New "scan — --sync (bot mode)" section with the canonical
        `scan --json --sync --yes | jq '{applied, pruned, bytes_freed}'`
        recipe.
      * New "scan — --dry-run" section explaining that --dry-run is a
        no-op without one of the mutating flags.
      * Restore the `repair` section's normal heading (drop the
        "*(deprecated since v3.0)*" suffix and the deprecation paragraph).
        Note that the `gc` visible_alias is now contract-guarded.
      * Semver-policy table: drop the GC-default row, add explicit rows
        for "flipping --prune to opt-out" and "demoting `gc` from
        visible_alias" — both MAJOR.
    
    README.md:
      * Restore the `### repair` / `gc` section that was removed during
        the deprecation iteration. Wording clarifies that `repair`/`gc` is
        the right answer for cleanup-without-apply and points users at
        `scan --sync` for the combined workflow.
      * `### scan` section: replace --no-prune row with --prune, --sync,
        --dry-run, --yes. Bot-mode example becomes `scan --json --sync --yes`
        (the user's "one or two flags" target). Add a `scan --json --sync
        --yes --dry-run` example.
      * `## Scripting & CI/CD`: lead with the new `--sync` recipe piped
        through jq into `peter-evans/create-pull-request`. Keep the old
        `scan --json --ecosystems npm` read-only example as the second
        use case.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * chore(hardening): pin toolchain, deps, actions, and install bootstrap
    
    - rust-toolchain.toml: pin channel to exact version with components
    - Cargo.toml: exact-pin all workspace dependencies via =X.Y.Z spec
    - crates/{cli,core}/Cargo.toml: wire dev-deps through workspace pins
    - npm/socket-patch/package.json: exact-pin runtime + dev deps; commit
      package-lock.json so downstream installs are deterministic
    - scripts/install.sh: download SHA256SUMS, verify tarball digest
      before extraction; accept SOCKET_PATCH_VERSION env override
    - scripts/version-sync.sh: preserve the leading = on exact-pin specs;
      refresh the npm lockfile on every version bump
    - .github/workflows/release.yml: SHA-pin actions, pin npm@version,
      pin language toolchain versions for setup-* actions
    - .github/workflows/pin-check.yml: new fail-closed workflow that
      greps every uses: line and rejects non-SHA-pinned action refs
    
    * feat(cli)!: non-mutating apply + unified JSON envelope (v3.0)
    
    Two related changes that together complete the v3.0 contract:
    
    * apply no longer writes to .socket/. When the manifest is missing
      blobs in offline mode, apply bails with a partial_failure envelope.
      When online and missing blobs need fetching, the bytes go to an OS
      tempdir overlay for the duration of the run; .socket/ stays read-
      only. Garbage collection moves out of apply entirely (now lives in
      scan --prune / repair / gc).
    
    * New crates/socket-patch-cli/src/json_envelope.rs defines a shared
      Envelope/PatchEvent/Status/Summary shape that every --json
      invocation now emits. The action vocabulary (added/updated/skipped/
      applied/downloaded/removed/failed/verified) is the single contract
      downstream consumers route on. CLI_CONTRACT.md is updated with the
      unified shape + jq recipes.
    
    Migrated commands: apply, list, repair, remove. (scan, get, rollback,
    setup retain their pre-v3.0 shapes for now and are documented as
    pending in CLI_CONTRACT.md.)
    
    Also removes three dead-code items the audit confirmed have zero
    callers:
      - crates/socket-patch-core/src/utils/enumerate.rs (whole module)
      - crates/socket-patch-core/src/utils/global_packages.rs (whole
        module; npm crawler ships the live copy)
      - path_to_group_id() in maven_crawler (test-only inverse helper)
      - false-positive allow(dead_code) on get.rs::DownloadParams
    
    BREAKING: every migrated subcommand's --json output is reshaped to the
    new envelope (camelCase status, events array, summary block).
    
    * test: comprehensive in-process + subprocess test suite
    
    Adds ~120 new tests across the apply/scan/get/list/remove/repair/
    rollback/setup CLI commands. Tests drive socket-patch in-process
    (commands::*::run) and via subprocess against wiremock-backed API
    fixtures, asserting on disk state, JSON envelope shape, and exit
    codes. Includes:
    
    * apply: invariants test (no .socket/ mutation), network tests with
      wiremock, edge cases (read-only files, nested dirs, multi-file,
      hash mismatch, idempotent re-apply, missing files, force overrides)
    * scan: invariants, sync end-to-end, dry-run preview, --apply +
      --prune combinations
    * get: identifier-type detection (UUID/CVE/GHSA/PURL/package),
      --save-only path, paid_required path, error paths, edge cases
    * repair: download-mode variants (file/diff/package), offline mode,
      blob cleanup verification
    * remove: PURL + UUID identifiers, rollback chain, blob cleanup
    * rollback: real bytes restore for all 8 ecosystems via handcrafted
      fixtures + real installer paths
    * setup: package.json detection, pnpm monorepo handling, dry-run
    * PTY-driven interactive prompt tests (portable-pty)
    * Alternate installer configs: yarn, pnpm, npm workspaces, bundler
    * Python venv variants: 3.11/3.12/3.13, .env/venv/.venv layouts,
      VIRTUAL_ENV override, canonical name normalization, egg-info legacy
    
    Real package managers are used where available on host (npm, pip,
    gem, cargo); ecosystems without host toolchains (go/maven/composer/
    nuget) use handcrafted fixtures that exactly mirror what their
    native installers produce on disk.
    
    * ci: add coverage job, language version pins, and e2e-docker matrix
    
    * New 'coverage' job: cargo-llvm-cov (LLVM source-based instrumentation
      via taiki-e/install-action), uploads lcov.info as a workflow artifact
      and prints the summary to the GitHub Actions job summary. Report-only
      (no --fail-under threshold) so contributors get visibility without
      flaky CI when coverage shifts.
    
    * Language toolchain pins on every setup-* action invocation: Node
      20.20.2, Python 3.12.13, Ruby 3.2.11. dtolnay/rust-toolchain now
      reads from rust-toolchain.toml (the toolchain: stable input is
      dropped from every step).
    
    * New 'e2e-docker' matrix: ubuntu-latest x { npm, pypi, gem, cargo,
      golang, maven, composer, nuget }. Each slot builds the shared base
      image and the per-ecosystem layer via docker/build-push-action with
      scope-cached layers (type=gha,scope=test-<eco>), then runs the
      corresponding 'cargo test --features docker-e2e --test
      docker_e2e_<eco>'. Triggered on every PR. The existing 'e2e' job
      (real Socket API, --ignored) stays for nightly/manual real-API
      smoke runs.
    
    * test(docker-e2e): infrastructure + npm full install→apply chain
    
    Adds the Docker-driven e2e test infrastructure:
    
    * tests/docker/Dockerfile.base: multi-stage build (rust:1.93-slim-
      bookworm builder → debian:12-slim runtime + compiled socket-patch).
      Base layer shared by every ecosystem image. Both base images pinned
      by sha256 digest.
    * tests/docker/Dockerfile.npm: FROM base + Node 20 LTS via NodeSource.
    * tests/docker/README.md: how to build images locally, run tests with
      Docker or with SOCKET_PATCH_TEST_HOST=1 host mode, and how to add a
      new ecosystem.
    * tests/docker/fixtures/npm/README.md: documents the synthetic fixture
      approach (--force apply against any installed bytes).
    * docker_e2e_npm.rs: real 'npm install minimist@1.2.2' inside the
      container, wiremock served patch, scan --sync writes manifest +
      blob, apply --force overwrites the on-disk file, then grep-verifies
      SOCKET-PATCH-E2E-MARKER in node_modules/minimist/index.js. Hermetic
      (no Socket API contact); reproducible in CI.
    
    This is the working template every other ecosystem extends.
    
    * test(e2e): full install→apply chain for pypi in Docker (+ global)
    
    Upgrades docker_e2e_pypi.rs from scan-discovery-only to the full
    chain, twice: once for local (venv) install and once for global
    (pip --break-system-packages).
    
    * Switches the fixture package from pydantic-ai (heavy transitive
      deps, ~60s install) to six 1.16.0 (single-file, ~1s install).
    * pypi's file-path convention has NO `package/` prefix — the
      python crawler returns site-packages root as pkg_path, so the
      patch's file path is just `six.py` (lands at site-packages/six.py).
    * `pypi_local_install_full_apply_chain`: venv install at .venv/lib/
      python3.X/site-packages/six.py, scan --sync writes manifest +
      blob, apply --force --offline overwrites the file. Grep verifies
      SOCKET-PATCH-E2E-MARKER on disk.
    * `pypi_global_install_full_apply_chain`: pip install --break-
      system-packages installs into Debian's system Python site-
      packages. scan + apply with --global. Same marker verification at
      the system-site-packages path discovered via `python3 -c "import
      six; print(six.__file__)"`.
    
    The Dockerfile.pypi already has python3 + pip + venv from prior
    infrastructure work; no Dockerfile change.
    
    * test(e2e): full install→apply chain for gem in Docker (+ global)
    
    Two tests for the Ruby ecosystem:
    
    * gem_local_install_full_apply_chain: `gem install --install-dir
      vendor/bundle/ruby/<ver> colorize -v 1.1.0` produces the bundle-
      style layout that the Ruby crawler scans in local mode. scan --sync
      + apply --force overwrites lib/colorize.rb with the synthetic
      patched content; marker verified on disk.
    * gem_global_install_full_apply_chain: plain `gem install colorize
      -v 1.1.0` (no --install-dir) installs to `$(gem env gemdir)`. The
      --global flag drives the Ruby crawler to scan the system gem dir.
      Same marker check at the discovered path.
    
    gem patches use the `package/<rel>` convention; apply strips the
    `package/` prefix and joins with the gem's directory.
    
    Dockerfile.gem is unchanged from prior infrastructure work.
    
    * test(e2e): full install→apply chain for cargo in Docker
    
    `cargo fetch` against a minimal project with `cfg-if = "=1.0.0"`
    populates `\$CARGO_HOME/registry/src/<index>/cfg-if-1.0.0/`. scan
    --sync writes the manifest + blob, apply --force --offline overwrites
    the registry-source `src/lib.rs` with patched bytes containing
    SOCKET-PATCH-E2E-MARKER. grep verifies on disk.
    
    Pre-chmods the registry source file to writable — cargo's source
    files are read-only by default and apply's own fix-permissions code
    covers the same path, but the chmod up-front keeps the test robust
    against changes there.
    
    Single test (no global variant): cargo's registry is the only cache,
    so local-vs-global is a no-op.
    
    Dockerfile.cargo unchanged from prior infrastructure work; it has
    rustup-installed Rust 1.93.1 with CARGO_HOME set.
    
    * test(e2e): full install→apply chain for golang in Docker
    
    `go mod download github.com/gin-gonic/gin@v1.9.1` populates
    `\$GOMODCACHE/github.com/gin-gonic/gin@v1.9.1/`. scan --sync writes
    the manifest + blob, apply --force --offline overwrites gin.go with
    synthetic patched bytes containing SOCKET-PATCH-E2E-MARKER. grep
    verifies on disk.
    
    Pre-chmods the cache file to writable — `go mod download` extracts
    to read-only files, similar to cargo registry.
    
    Single test (no global variant): golang's module cache is the only
    cache; --global is a no-op.
    
    Dockerfile.golang ships Go 1.21.13 from the official tarball; GOPATH
    and GOMODCACHE are set at image build time.
    
    * test(e2e): full install→apply chain for maven in Docker
    
    Upgrades docker_e2e_maven.rs from scan-only to the full chain.
    `mvn dependency:get -Dartifact=org.apache.commons:commons-lang3:3.12.0`
    downloads the artifact into ~/.m2/repository, the wiremock fixture
    overwrites the .pom file with synthetic patched bytes, and the test
    grep-verifies SOCKET-PATCH-E2E-MARKER on disk. Single test
    (local-only) since ~/.m2 is always global.
    
    * test(e2e): full install→apply chain for composer in Docker
    
    Upgrades docker_e2e_composer.rs to the full chain plus a global
    variant. Real `composer require monolog/monolog:3.5.0` installs into
    vendor/monolog/monolog/, the wiremock fixture overwrites
    src/Monolog/Logger.php with synthetic patched bytes, and the test
    grep-verifies SOCKET-PATCH-E2E-MARKER on disk.
    
    Adds composer_global_install_full_apply_chain: `composer global
    require` installs to $COMPOSER_HOME/vendor, socket-patch scans +
    applies with --global, marker verified there.
    
    * test(e2e): full install→apply chain for nuget in Docker
    
    Upgrades docker_e2e_nuget.rs to the full chain plus a global variant.
    The local test redirects `dotnet add package` to a project-local
    ./packages dir via NUGET_PACKAGES, then scan + apply patch the
    package's LICENSE.md with a synthetic blob; the global test uses the
    default ~/.nuget/packages and --global mode.
    
    Note: the wiremock fixture uses the lowercased package name in the
    PURL ("newtonsoft.json") so scan's GC pass (--sync = --apply --prune)
    doesn't prune the freshly-saved manifest entry — the crawler reports
    installed packages by their lowercased directory name and GC keys
    against that.
    
    * test(e2e): add npm global install variant in Docker
    
    Adds npm_global_install_full_apply_chain alongside the existing local
    install/apply/rollback test. The variant runs `npm install -g`,
    locates the file at $(npm root -g)/minimist/index.js, then runs scan
    + apply with --global and grep-verifies SOCKET-PATCH-E2E-MARKER.
    
    Host-mode skips the global variant (no safe host npm prefix to
    mutate); Docker is the canonical run path.
    
    * test(e2e): add composer/maven/nuget Dockerfiles
    
    Adds the three Dockerfile recipes the docker_e2e_{composer,maven,nuget}
    tests panic-message instruct users to build.
    
    - Dockerfile.composer: base + PHP 8 + Composer 2
    - Dockerfile.maven:    base + default-jdk-headless + maven
    - Dockerfile.nuget:    mcr.microsoft.com/dotnet/sdk:8.0 (sdk image)
                           with socket-patch COPY'd in from the base
    
    * ci(coverage): include docker-e2e in the coverage map
    
    The host `coverage` job ran with `--all-features`, which enabled the
    docker-e2e feature, but the job never built the per-ecosystem Docker
    images — every docker_e2e_<eco> test would panic on `assert_image`
    and the job failed (or, if it ever passed, only the surviving
    in-process tests contributed). The Docker tests exercise the real
    socket-patch binary inside a Linux container, and that subprocess's
    coverage wasn't captured at all.
    
    Changes:
    
    * Each `docker_e2e_<eco>.rs` now reads SOCKET_PATCH_COV_BIN +
      SOCKET_PATCH_COV_PROFRAW_DIR. When both are set, the docker run
      mounts an llvm-cov-instrumented socket-patch binary over the
      image's baked-in /usr/local/bin/socket-patch and points
      LLVM_PROFILE_FILE into a host-visible volume. Empty Vec when
      unset → tests behave exactly as before for local dev and the
      existing e2e-docker matrix.
    
    * `coverage` job: drops `--all-features` for an explicit feature
      list (cargo,golang,maven,composer,nuget) that excludes
      docker-e2e. Produces `coverage-host.lcov`.
    
    * New `coverage-docker` matrix job: per ecosystem, builds the base
      + ecosystem Docker images, eval-sources `cargo llvm-cov show-env`
      to build an instrumented `target/debug/socket-patch`, sets the
      SOCKET_PATCH_COV_* hooks, runs `cargo llvm-cov --no-report --test
      docker_e2e_<eco>`, and emits a per-ecosystem lcov artifact.
    
    * New `coverage-merge` job: gathers `coverage-host` + all 8
      `coverage-docker-*` artifacts and unions them via `lcov
      --add-tracefile` into a single `coverage-lcov` artifact. Same
      artifact name as before so downstream consumers keep working.
    
    Result: lines hit by ANY test (host in-process, host harness, or
    in-container binary execution) show up in the final coverage map.
    
    * ci: remove cargo cache from docker-image-building jobs
    
    zizmor's cache-poisoning audit (high) flagged the cargo `actions/cache`
    steps in `e2e-docker` and `coverage-docker` because both jobs also
    invoke `docker/build-push-action`. The risk model: a PR could poison
    the cargo cache (target/, ~/.cargo) with a backdoored crate or
    compiled object, and a later run on a trusted ref could load the
    poisoned cache and produce a compromised binary that gets mounted
    into the docker container or baked into the published image.
    
    Drop the cargo cache from both jobs. The Docker buildx `cache-from:
    type=gha` remains, so image-layer rebuilds are still fast. Cargo
    deps refresh from the registry per run — about a one-minute cost
    that's worth it to eliminate the attack surface.
    
    The other jobs (clippy, test, test-release, coverage, e2e) keep
    their cargo caches — none of them build Docker images, so the audit
    doesn't trigger for them.
    
    * ci: provide explicit toolchain input to dtolnay/rust-toolchain
    
    The action requires a `toolchain` input — when SHA-pinned (which is
    our policy), the action can't infer the channel from action_ref the
    way `@stable`/`@1.93.1` ref pins would, so it errors out with
    "'toolchain' is a required input".
    
    The original comments ("toolchain version is read from
    rust-toolchain.toml") referred to rustup's behavior after install,
    not the action's pre-install resolution — the action doesn't read
    rust-toolchain.toml itself.
    
    Set `toolchain: "1.93.1"` on every Install Rust step, matching the
    channel in rust-toolchain.toml. The duplication is intentional: if
    they drift, rustup will reconcile by installing the toolchain.toml
    channel on first cargo invocation, just at a small extra cost.
    
    * ci: drop dtolnay/rust-toolchain in favor of inline rustup
    
    Removes the third-party Rust toolchain action and replaces every
    "Install Rust" step with `rustup show`. rustup is pre-installed on
    GitHub-hosted runners; `rustup show` consumes rust-toolchain.toml,
    auto-installs the pinned channel if missing, and applies the listed
    components (rustfmt, clippy). For coverage jobs that additionally
    need llvm-tools-preview, `rustup component add llvm-tools-preview`
    follows the show step.
    
    Benefits:
    - One less third-party action to audit and SHA-pin.
    - No duplication between rust-toolchain.toml and ci.yml.
    - Toolchain bumps are one-file changes (just edit toolchain.toml).
    
    * refactor(cli): unify CLI args + env-var bindings across every subcommand
    
    Define a single `GlobalArgs` clap struct and `#[command(flatten)]` it into
    every subcommand's args. Every flag now has a matching `SOCKET_*` env var
    binding (precedence: CLI > env > default). Legacy `SOCKET_PATCH_PROXY_URL`,
    `SOCKET_PATCH_DEBUG`, `SOCKET_PATCH_TELEMETRY_DISABLED` are still honored at
    runtime via a one-shot deprecation warning that fires even under `--silent` /
    `--json`.
    
    Behavior changes:
    - `--offline` now means strict airgap on every command (was three different
      things across apply / repair / rollback). On `repair`, `--offline` and
      `--download-only` are mutually exclusive.
    - `repair --download-mode` default flipped from `file` to `diff` to match
      every other command. Users who need the legacy per-file blob behavior
      opt in with `--download-mode file`.
    - `apply` and `repair` gain `--api-url` / `--api-token` / `--org` for free
      via the flatten (previously only readable via env).
    - `--debug` and `--no-telemetry` promoted from env-only toggles to CLI flags.
    
    CLI_CONTRACT.md rewritten around a single global-args table plus a small
    per-subcommand section for local flags. New tests: `cli_global_args.rs`
    (compose test: every global flag × every subcommand) and
    `cli_env_deprecation.rs` (legacy-env warning fires under `--silent` /
    `--json`).
    
    Assisted-by: Claude Code:opus-4-7
    
    * docs(changelog): add CHANGELOG.md + CI guard blocking publish without entry
    
    Adds a Keep-a-Changelog-style CHANGELOG.md at the repo root, backfilled
    with concise summaries for every published tag (v1.1.0 → v2.1.4) and a
    detailed v3.0.0 entry covering the breaking changes in the in-flight v3
    release (unified `--offline`, `repair --download-mode` default flip,
    `SOCKET_PATCH_*` → `SOCKET_*` env-var renames with one-shot deprecation
    warning, shared `GlobalArgs` flatten across every subcommand, etc.).
    
    Wires a new step into the `Release` workflow's `version` job that fails
    the workflow when `CHANGELOG.md` lacks an entry for the version in
    Cargo.toml. Because every downstream job (tag, build, github-release,
    cargo/npm/pypi-publish) transitively depends on `version`, a missing
    changelog entry blocks the entire publish pipeline.
    
    Accepts both `## [X.Y.Z]` and `## X.Y.Z` heading styles to keep the
    format requirement loose for future contributors.
    
    Assisted-by: Claude Code:opus-4-7
    
    * ci(test): unbreak test/coverage/e2e-docker matrix on feat/scan-apply-json
    
    This commit fixes the pre-existing CI red on the v3.0 branch. Four
    unrelated root causes:
    
    1. `cargo test --workspace --all-features` enables the `docker-e2e`
       feature, which compiles the 8 `docker_e2e_<eco>.rs` tests on every
       `test (ubuntu/macos/windows)` runner. Those tests `assert_image()`
       on a docker image that only exists in the dedicated docker-building
       jobs, so every test runner failed. Replaced each `assert_image()`
       panic with a `skip_if_no_image()` early return that prints a stderr
       skip notice. Tests now report `ok` on hosts without docker / images.
       `cargo test --workspace --all-features` is green everywhere.
    
    2. The `coverage` job (cargo-llvm-cov, --all-features) failed three
       `in_process_remove_repair_lifecycle` tests that set
       `SOCKET_API_URL`/`SOCKET_API_TOKEN`/`SOCKET_ORG_SLUG` via
       `std::env::set_var` after constructing `RepairArgs` via
       `..GlobalArgs::default()`. The refactor's `api_client_overrides()`
       was always forwarding the resolved api_url/proxy_url as
       `Some(...)`, which short-circuited the env-var fallback inside
       `get_api_client_with_overrides`. Made `GlobalArgs::default()` leave
       `api_url`/`proxy_url` empty (clap always populates them in
       production via `default_value`, so the production path is
       unchanged) and `api_client_overrides()` filters empty values to
       `None`. The env-var fallback now fires for these tests.
    
    3. `repair_download_only_skips_cleanup` (in `repair_invariants.rs`)
       used the shared `run_repair()` helper which injects `--offline`.
       v3.0 made `--offline` and `--download-only` mutually exclusive
       (exit code 2). Inlined the binary invocation without `--offline`
       for this one test — the manifest's referenced blob is already on
       disk so the download phase is a no-op even without `--offline`.
    
    4. The `e2e-docker` and `coverage-docker` matrix jobs failed at
       "Build <eco> image" with `pull access denied` on
       `socket-patch-test-base:latest`. setup-buildx-action defaults to
       the `docker-container` driver, which runs BuildKit in a sandboxed
       container that cannot see the host docker daemon's image store —
       so the per-ecosystem Dockerfile's `FROM socket-patch-test-base:latest`
       tries to pull from docker.io and fails. Switched both jobs to
       `driver: docker` so buildx talks to the host daemon directly.
       Dropped the `type=gha` cache directives (not supported under the
       docker driver) — we trade build cache for image visibility.
    
    Local: `cargo test --workspace --all-features` → 965 passed, 0 failed.
    
    Assisted-by: Claude Code:opus-4-7
    
    * ci(coverage-docker): pin to ubuntu-22.04 for glibc compatibility
    
    The coverage-docker matrix builds an instrumented socket-patch binary
    on the host and mounts it into the debian:12-slim test container.
    ubuntu-latest is currently 24.04 (glibc 2.39); debian:12 ships glibc
    2.36. Result: every coverage-docker matrix job failed with
    
        socket-patch: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.39'
            not found (required by socket-patch)
    
    Pin to ubuntu-22.04 (glibc 2.35) — the highest base that's
    forward-compatible with debian:12. e2e-docker is unaffected because it
    runs the binary that was baked into the image by the base Dockerfile's
    internal builder stage, not a host-mounted one.
    
    Assisted-by: Claude Code:opus-4-7
    
    * test(npm): connect via 127.0.0.1 in infrastructure smoke (Windows fix)
    
    wiremock binds to 0.0.0.0 (the wildcard). Linux and macOS quietly
    route a connect to 0.0.0.0 onto the loopback interface, so the test
    worked on those runners. Windows refuses the connect with
    WSAEADDRNOTAVAIL (winsock error 10049) because 0.0.0.0 is a valid
    bind target but not a valid destination address.
    
    Use 127.0.0.1 explicitly for the smoke-check URL — the bound port
    from `server.address().port()` is still what we need.
    
    Assisted-by: Claude Code:opus-4-7
    
    * test(pypi): skip in_process_pypi_apply on Windows (Unix venv layout)
    
    `install_six()` hardcodes `venv/bin/pip` and `find_site_packages()`
    walks `venv/lib/pythonX.Y/site-packages/`. Both layouts are
    Unix-only — on Windows the venv puts pip at `Scripts\pip.exe` and
    site-packages at `Lib\site-packages\` (no per-version subdirectory).
    
    Rather than forking the helpers per platform, gate every test in
    this file behind `skip_unsupported_platform()` which prints a skip
    notice on Windows and returns early. The same code paths get
    exercised by the Linux test runner and the docker_e2e_pypi suite,
    so coverage isn't lost.
    
    Assisted-by: Claude Code:opus-4-7
    
    * test(pypi): make in_process_pypi_apply helpers platform-aware (Windows)
    
    Reverts the earlier Windows-skip in favor of real Windows coverage.
    Three changes to the helpers:
    
    1. find_python() probes `python3` → `python` → `py` (mirrors the
       crawler's `find_python_command` in
       core/src/crawlers/python_crawler.rs:15). On Windows the canonical
       name is `python` (the `py` launcher is also installed); `python3`
       is rare. Without this the venv-creation step calls `python3` and
       fails on every Windows runner.
    
    2. venv_pip() returns `Scripts\pip.exe` on Windows vs `bin/pip` on
       Unix, matching PEP-405's documented venv layout.
    
    3. find_site_packages() branches on cfg!(windows):
       * Windows: `<venv>\Lib\site-packages\` — no version subdirectory.
       * Unix: glob `<venv>/lib/python3.X/site-packages/` for whatever
         interpreter version pip used.
    
    The four in-process tests now exercise the same install→scan→apply
    chain on Windows that they already cover on Linux/macOS. The core
    crawler is already Windows-aware (python_crawler.rs:182) so the
    package-discovery path it tests is real, not synthetic.
    
    Assisted-by: Claude Code:opus-4-7
    
    * test(rollback): use Windows venv layout when cfg!(windows)
    
    rollback_pypi_restores_original_content set up a synthetic
    `.venv/lib/python3.11/site-packages/` tree by hand. That's the Unix
    layout — on Windows the pypi crawler at
    core/src/crawlers/python_crawler.rs:182 looks for
    `.venv\Lib\site-packages\`, so on Windows runners the crawler found
    nothing and the patched file was never rolled back.
    
    Branch on `cfg!(windows)` when building the path so the synthetic
    package sits where the crawler actually probes on each platform.
    The crawler logic itself is unchanged.
    
    Assisted-by: Claude Code:opus-4-7
    
    * feat(json): enrich added/updated patch records with description, severity, vuln IDs
    
    When `get` or `scan --apply` adds or updates a patch in the manifest,
    the per-patch JSON record now carries the metadata consumers need to
    render the patch to a human without a second API round-trip:
    
    ```jsonc
    {
      "purl": "pkg:npm/minimist@1.2.2",
      "uuid": "11111111-...",
      "action": "added",
      "description": "Fixes prototype pollution in minimist",
      "license": "MIT",
      "tier": "free",
      "exportedAt": "2024-01-01T00:00:00Z",
      "severity": "high",
      "vulnerabilities": [
        {
          "id": "GHSA-xvch-5gv4-984h",
          "cves": ["CVE-2024-12345"],
          "severity": "high",
          "summary": "Prototype Pollution",
          "description": "merge() does not check Object.prototype"
        }
      ]
    }
    ```
    
    Highlights:
    - Top-level `severity` is the max across the vulnerabilities array,
      using the ordering critical > high > medium=moderate > low.
    - `vulnerabilities[]` is sorted by `id` so consumer diffs and test
      snapshots don't flap on HashMap iteration order.
    - Metadata is intentionally omitted on `action: skipped` (consumer
      already has it from the original add) and on `action: failed`.
    - `scan --apply` benefits automatically — both flows go through
      `download_and_apply_patches`.
    
    Helpers `severity_rank`, `max_vuln_severity`, `patch_event_metadata`
    are pub(crate) and unit-tested. CLI_CONTRACT.md gains a new
    "`patches[]` entry shape" subsection documenting the schema.
    
    Assisted-by: Claude Code:opus-4-7
    
    * test(e2e): walk the v3.0 envelope for `list --json` output
    
    The e2e (real-registry) suite was asserting on `list["patches"]` —
    the pre-v3 ad-hoc shape. v3.0 migrated `list --json` to the unified
    envelope, which emits `{command, status, events, summary}` with one
    `discovered` event per manifest entry. Patch metadata
    (vulnerabilities, tier, license) lives under `details`.
    
    Updated four sites (e2e_npm × 2, e2e_pypi × 1, e2e_gem × 1) to
    filter events by `action == "discovered"` and walk
    `details.vulnerabilities[]` for CVE assertions.
    
    Closes the `e2e (ubuntu/macos, e2e_npm|e2e_pypi|e2e_gem)` matrix
    failures surfaced once the e2e workflow started passing on the v3.0
    branch.
    
    Assisted-by: Claude Code:opus-4-7
    
    * ci(e2e_gem): bump pinned ruby from 3.2.11 to 3.2.10
    
    `ruby/setup-ruby` dropped 3.2.11 from its catalog at some point —
    the action errors with "Unknown version 3.2.11 for ruby on
    ubuntu-24.04" and lists 3.2.10 as the newest 3.2.x available.
    
    3.2.x is API-stable so 3.2.10 is a drop-in replacement.
    
    Assisted-by: Claude Code:opus-4-7
    
    * refactor(cli): drop -d/-m short aliases; loosen version pins
    
    Two unrelated changes in one commit:
    
    1. Drop the `-d` short for `--dry-run` and `-m` short for
       `--manifest-path` from `GlobalArgs`. We want those letters free
       for future flags. The long forms are unaffected, and a new
       `reserved_short_forms_are_not_assigned` compose test locks in
       that no subcommand reassigns either letter. Per-subcommand
       short-form tests (`*_short`, `manifest_path_short_form`, etc.)
       are deleted; the long-form counterparts cover the contract.
    
    2. Loosen `python-version` and `ruby-version` pins in ci.yml from
       exact patch (`3.12.13`, `3.2.10`) to minor.x (`3.12.x`, `3.2.x`).
       setup-python and setup-ruby's catalogs keep retiring older patch
       versions and breaking the workflow — minor.x auto-resolves to
       whatever patch is currently available.
    
    CLI_CONTRACT.md updated to remove `-d`/`-m` from the global args
    table and the env-var cross-reference.
    
    Assisted-by: Claude Code:opus-4-7
    
    * feat(apply): preserve mode + ownership across patches
    
    `apply_file_patch` now treats target-file permissions as a strict
    round-trip:
    
    1. **Existing file**: snapshot mode + uid + gid before writing.
       - If read-only, temporarily grant owner-write so the overwrite
         succeeds (Go module cache, npm linked symlinks, etc.).
       - After writing, restore the *exact* pre-patch mode (idempotent
         `set_permissions(from_mode(...))`) and chown back to the
         pre-patch uid/gid. `tokio::fs::write` truncates + rewrites the
         file in place, so owner usually survives, but pinning
         ownership explicitly stops a theoretical race where another
         process opens the file between truncate and write.
    2. **New file** (created by the patch): chown to inherit owner/group
       from the parent directory, mode = `0o444` (read-only for all).
       Matches how a freshly-unpacked package tarball treats its files.
    
    Windows: no uid/gid concept; preserve the readonly attribute for
    existing files and force it on new ones.
    
    `restore_file_permissions` and the `chown_blocking` helper are split
    out of `apply_file_patch` for readability and unit testing. Four
    new tests pin the policy: readonly-mode preservation, executable
    (0o755) mode preservation, new-file default mode + parent ownership
    inheritance, and uid/gid round-trip on existing files.
    
    Assisted-by: Claude Code:opus-4-7
    
    * ci(e2e_gem): revert ruby-version to exact 3.2.10
    
    setup-ruby (unlike setup-python) does NOT support the `3.2.x`
    wildcard pin — it errors with "Unknown version 3.2.x for ruby on
    ubuntu-24.04". Revert to an exact patch that's in the catalog.
    When this patch eventually drops off, bump it manually per the
    list at https://github.com/ruby/setup-ruby.
    
    Assisted-by: Claude Code:opus-4-7
    mikolalysenko authored May 22, 2026
    Configuration menu
    Copy the full SHA
    9b7b5c2 View commit details
    Browse the repository at this point in the history

Commits on May 23, 2026

  1. feat(apply): safety hardening — atomicity, locking, pnpm CoW, sidecar…

    …s, Maven gate (#80)
    
    * feat(apply): safety primitives — lock, CoW, atomic write, sidecar fixups
    
    Adds five new modules to `socket-patch-core` and refactors
    `apply_file_patch` to compose them safely with #79's perm-preservation:
    
    - **`patch::apply_lock`** — cross-platform advisory file lock at
      `<.socket>/apply.lock` via `fs2`. Used by every mutating subcommand
      to serialize against concurrent socket-patch runs.
    
    - **`patch::cow`** — hardlink + symlink copy-on-write. Before
      patching, if `filepath` is a symlink into a content-addressed store
      (pnpm) or a regular file with `nlink > 1` (bazel mirrors, nix store
      overlays), give this project a private inode. The pnpm content
      store and every other project pointing at it stay byte-identical.
    
    - **`patch::sidecars`** — ecosystem-aware sidecar fixups dispatched
      from `apply_package_patch`. Cargo: rewrite `.cargo-checksum.json`
      with new SHA256s so `cargo build` accepts patched sources. NuGet:
      delete `.nupkg.metadata` (the documented "unknown" state vs. a
      stale `contentHash` that would flag tampering). PyPI / gem / Go:
      advisory-only — surface a one-line note about downstream tooling
      consequences.
    
    - **`crawlers::pkg_managers`** — path-based detector for the four
      Node.js layout flavors (npm / pnpm / yarn-classic / yarn-berry
      PnP). Apply uses this to refuse yarn-berry PnP (packages live in
      `.yarn/cache/*.zip`) and to surface a pnpm-detected note.
    
    - **`apply_file_patch` atomic rewrite** — two-phase commit:
        1. Hash `patched_content` in memory; error out before any disk
           write if it doesn't match `expected_hash`. Removes the
           prior "wrote bytes, post-write verify failed, can't restore"
           window.
        2. CoW the target if it's a shared inode.
        3. Stage write to `<parent>/.socket-stage-<uuid>`, `sync_all()`,
           then `rename(stage, target)`. POSIX `rename(2)` is atomic —
           observers see either the old or new bytes, never a truncated
           half-write. Composes cleanly with #79's mode + uid/gid
           restore step which now operates on the post-rename inode.
    
    `ApplyResult` grows `sidecars_updated: Vec<String>` and
    `sidecar_advisory: Option<String>` so the CLI envelope can surface
    fixup outcomes.
    
    `fs2` and `tempfile` added to socket-patch-core dependencies.
    
    Two new tests pin the headline invariants:
    - `test_apply_file_patch_hash_mismatch_leaves_original_intact` —
      atomic-write contract: hash mismatch leaves target byte-identical
      AND no `.socket-stage-*` litter in parent dir.
    - `test_apply_file_patch_does_not_propagate_to_hardlinked_sibling` —
      the pnpm content-store invariant at the integration level.
    
    Plus 10 unit tests for cow + apply_lock and 13 for sidecars/* +
    9 for pkg_managers.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * feat(cli): wire safety primitives + Maven/NuGet experimental gates
    
    Integrates the new socket-patch-core safety primitives into the CLI
    via the v3.0 unified `GlobalArgs` + `Envelope` patterns from #79.
    
    **`commands::lock_cli`** (new) — envelope-aware wrapper around
    `apply_lock::acquire`. Takes `Command` so the failure envelope's
    `command` field reflects which subcommand was blocked. On contention
    the binary emits `{status: "error", error: {code: "lock_held", ...}}`
    in JSON mode or a one-line stderr message otherwise, then exits 1.
    
    **Lock acquisition** added to `apply`, `rollback`, `repair`, `remove`
    immediately after the manifest existence check. `remove`'s outer lock
    spans the inner `rollback_patches` call (which deliberately does NOT
    acquire the lock so the composition doesn't self-deadlock).
    
    **Apply pkg-manager gating** — after the lock, `apply` runs
    `detect_npm_pkg_manager`:
    - `YarnBerryPnP` → emit `EnvelopeError("yarn_pnp_unsupported", ...)`
      pointing at `yarn patch` and exit 1.
    - `Pnpm` → surface a one-line stderr note. CoW handles the
      substantive safety work; this just tells the user the layout was
      understood.
    
    **Sidecar JSON via `event.details`** — `result_to_event` extends the
    Applied event with `details.sidecarsUpdated: string[]` and
    `details.sidecarAdvisory: string | null` when either is non-empty.
    Narrower JSON-envelope contract than first-class fields; consumers
    read `event.details.sidecarsUpdated` from JSON.
    
    **Maven + NuGet experimental runtime gates** in
    `ecosystem_dispatch.rs`. Even when compiled with `--features
    maven`/`nuget`, the crawlers refuse to dispatch unless the matching
    `SOCKET_EXPERIMENTAL_MAVEN=1`/`SOCKET_EXPERIMENTAL_NUGET=1` env var
    is set. Without it, surface a warning event and skip those PURLs.
    Reasoning: Maven patches corrupt jar sidecar checksums (sha1/md5);
    NuGet patches corrupt `.nupkg.sha512` signature sidecars that
    `dotnet restore` reads as tamper-evidence.
    
    `fs2` added to socket-patch-cli dev-dependencies for the lock e2e
    test (same crate the binary uses internally).
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * test(e2e): safety hardening suite + CI matrix + invariant fixups
    
    Adds four end-to-end integration test files exercising the safety
    primitives through the binary, plus shared `tests/common/mod.rs`
    helpers, plus two existing-test contract updates.
    
    **Suites added (20 new tests):**
    
    - `e2e_safety_lock.rs` (6 tests, non-ignored). Test holds the same
      `.socket/apply.lock` the binary uses via `fs2` directly, then
      spawns `socket-patch apply` and asserts the second process exits
      with `error.code == "lock_held"`. Zero production-code hooks.
    
    - `e2e_safety_yarn_pnp.rs` (5 tests, non-ignored). Yarn-berry PnP
      markers (`.pnp.cjs`, `.pnp.loader.mjs`) trigger
      `error.code == "yarn_pnp_unsupported"`. Negative control:
      plain npm layout does NOT trigger the refusal.
    
    - `e2e_safety_cargo_build.rs` (5 tests, `#[ignore]` + `--features
      cargo`). Three synthetic-vendor tests:
        1. Baseline `cargo check --offline --frozen` succeeds.
        2. Negative control — mutating the source WITHOUT the sidecar
           fixup makes cargo refuse with "checksum changed". Proves
           cargo actually verifies, which is what makes the positive
           test meaningful.
        3. Sidecar fixup makes `cargo check` pass; `.cargo-checksum.json`
           is rewritten and the `package` field is preserved.
        4. JSON envelope contract: `.cargo-checksum.json` appears in
           `event.details.sidecarsUpdated`.
      Plus `traitobject_real_socket_patch_round_trip` — the cargo
      layer-2+3 combined test: `cargo fetch traitobject@0.0.1` from
      crates.io → `socket-patch get b15f2b7f-d5cb-43c9-b793-80f71682188f`
      from patches-api.socket.dev → assert `.cargo-checksum.json`
      rewritten + `cargo check` succeeds against the real, production
      Socket patch.
    
    - `e2e_safety_pnpm.rs` (4 tests, `#[ignore]`). Two projects share a
      pnpm content store via `--config.package-import-method=hardlink`.
      `socket-patch get` in project A patches A; project B + store
      entry stay byte-identical. `pnpm install --frozen-lockfile` in B
      afterwards does not revert A. Exercises CoW against a real pnpm
      install rather than a hand-rolled hardlink.
    
    **`tests/common/mod.rs`** — shared helpers (`binary`, `run`,
    `assert_run_ok`, `git_sha256`, `sha256_hex`, `pnpm_run`, `cargo_run`,
    `write_minimal_manifest`, `write_blob`, `parse_json_envelope`,
    `envelope_error_code`, `envelope_error_message`) lifted from the
    duplicated copies in `e2e_npm.rs` etc. Additive; existing suites
    keep their inlined copies for now.
    
    **CI matrix** in `.github/workflows/ci.yml`:
    - `e2e_safety_cargo_build` on ubuntu + macos + windows
    - `e2e_safety_pnpm` on ubuntu + macos + windows
      (pnpm-on-Windows uses junctions + copies by default, so the CoW
       invariant holds vacuously; the test still runs to verify apply
       doesn't error on Windows. Semantic Windows nlink coverage is a
       follow-up — `std::fs::Metadata` doesn't expose nlink on Windows
       without `GetFileInformationByHandle` via `windows-sys`.)
    - New `Setup pnpm` step (`npm install -g pnpm@10`) gated on the
      pnpm suite. The fast non-ignored suites (`e2e_safety_lock`,
      `e2e_safety_yarn_pnp`) run via the standard `test` job on all
      three platforms.
    
    **Existing-test contract updates** (these tests were pinning the
    old, broken behavior; both still describe correct invariants —
    their assertions just needed to track the rebased semantics):
    - `tests/apply_invariants.rs`: `dir_hash` excludes `apply.lock`.
      The lock file is deliberate ephemeral session state, not patch
      content; the "apply is read-only against .socket/" invariant is
      about manifest + blobs + diffs + packages.
    - `tests/in_process_edge_cases.rs`:
      `apply_blob_after_hash_mismatch_reports_failure` now asserts the
      atomic-write contract — the target file is byte-identical to its
      pre-call state on the hash-mismatch failure path, no half-written
      corruption.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * refactor(sidecars): typed envelope contract with structured per-file + advisory data
    
    Replaces the previous `event.details.sidecarsUpdated` / `event.details.sidecarAdvisory`
    free-form JSON bag with a typed, top-level `Envelope.sidecars[]` list.
    
    ## New types (`socket-patch-core/src/patch/sidecars/types.rs`)
    
      pub struct SidecarRecord { purl, ecosystem, files, advisory }
      pub struct SidecarFile   { path, action: SidecarFileAction }
      pub enum SidecarFileAction { Rewritten | Deleted | Created }
      pub struct SidecarAdvisory { code, severity, message }
      pub enum SidecarAdvisoryCode {
          PypiRecordStale | GemBundleInstallReverts | GoModVerifyFails
          | NugetSignedPackageTampered | SidecarFixupFailed
      }
      pub enum SidecarSeverity { Info | Warning | Error }
    
    All derive `serde::Serialize`. Structs use camelCase; enums use
    snake_case. Unit tests pin the JSON contract.
    
    ## JSON shape (consumer view)
    
    ```json
    {
      "command": "apply",
      "events": [...],
      "sidecars": [
        { "purl": "pkg:cargo/...", "ecosystem": "cargo",
          "files": [{"path":".cargo-checksum.json","action":"rewritten"}] },
        { "purl": "pkg:nuget/...", "ecosystem": "nuget",
          "files": [{"path":".nupkg.metadata","action":"deleted"}],
          "advisory": { "code":"nuget_signed_package_tampered",
                        "severity":"warning", "message":"..." } }
      ]
    }
    ```
    
      - `sidecars` omitted from JSON when empty.
      - `files` always present (possibly `[]` for advisory-only).
      - `advisory` omitted when absent.
      - `code` / `severity` are stable snake_case enum tags; `message`
        is human text.
      - `purl` joins to `events[].purl` for per-event context.
    
    ## Three real improvements over the old design
    
      1. **No more lossy collapse.** NuGet's "deleted `.nupkg.metadata`
         AND has a `.nupkg.sha512` signature" case now carries BOTH
         a file entry AND an advisory. Before, the advisory was
         silently lost when the file entry took its slot.
      2. **Stable codes + severity.** Consumers (CI bots, dashboards,
         telemetry, jq pipelines) can switch on `code` and route on
         `severity` without regex-matching free-form strings.
      3. **Decoupled from events.** Sidecar reporting is a top-level
         `Envelope.sidecars` list. `PatchEvent.details` is no longer
         mixed with `list` / `repair` / `remove`'s command-specific
         bags — sidecar consumers have a typed schema all their own.
    
    ## Internal refactor
    
      - `SidecarOutcome` removed. Per-ecosystem fixups return
        `Result<Option<SidecarPayload>, SidecarError>` (internal
        `SidecarPayload = { files, advisory }`); the dispatcher in
        `sidecars/mod.rs` wraps the payload with PURL + ecosystem to
        produce the `SidecarRecord`.
      - `ApplyResult.sidecars_updated: Vec<String>` and
        `sidecar_advisory: Option<String>` consolidated into a single
        `sidecar: Option<SidecarRecord>` field.
      - Apply CLI's `result_to_event` no longer attaches to
        `event.details`; the run loop now calls
        `env.record_sidecar(record.clone())` after each apply result.
      - `Envelope` gains `sidecars: Vec<SidecarRecord>` field +
        `record_sidecar` method.
      - The error path (`SidecarError` returned by a fixup) is
        converted at the apply boundary into a `SidecarRecord` with
        `advisory.code = SidecarFixupFailed`, `severity = Error`.
        Single uniform shape for consumers.
    
    ## Pre-existing test fixups
    
    `in_process_remote_ecosystems_apply.rs` and `in_process_rollback_all_ecosystems.rs`
    now set `SOCKET_EXPERIMENTAL_MAVEN=1` / `SOCKET_EXPERIMENTAL_NUGET=1`
    when they explicitly exercise those paths. These were broken
    silently by the Maven/NuGet runtime gates added in the prior
    rebase (the gate was always there in commit 39a2321; tests just
    happened not to exercise the maven/nuget paths to a depth where
    the skip mattered).
    
    ## Test results
    
      - cargo build --workspace --all-features: clean
      - cargo build --release --workspace: clean (no warnings)
      - cargo clippy --workspace --all-features -- -D warnings: clean
      - cargo test --workspace --all-features: 1021 passed, 0 failed
      - cargo test --features cargo --test e2e_safety_cargo_build --
        --ignored: 5 passed (includes traitobject real-patch round trip)
    
    The e2e cargo test `apply_reports_cargo_checksum_in_sidecars_updated`
    tightened from a substring match to a structured-shape assertion
    on `envelope.sidecars[].ecosystem=="cargo"` +
    `files[].path=".cargo-checksum.json"` + `files[].action=="rewritten"`.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * test(e2e): expand sidecar coverage + simplify PTY harness
    
    Five test surfaces, one bug fix, one YAGNI cleanup, one harness
    simplification — all motivated by closing the e2e gap on the new
    typed `Envelope.sidecars[]` contract.
    
      - **e2e_safety_advisories.rs** (new, 5 tests): drive the apply
        CLI against handcrafted layouts and assert
        `envelope.sidecars[].{ecosystem,advisory.code,advisory.severity,
        files[]}` for pypi (`pypi_record_stale`), gem
        (`gem_bundle_install_reverts`), golang (`go_mod_verify_fails`),
        nuget unsigned (deleted files only), and nuget signed (deleted
        files + `nuget_signed_package_tampered` advisory together —
        the case the pre-typed-contract design lost).
    
      - **e2e_safety_cow.rs** (new, 5 tests): cover `patch/cow.rs` end
        to end — hardlink isolation, symlink replacement, multi-file
        hardlink, regular-file no-op, and the failure-doesn't-cow path.
        Lifted file coverage from ~23% to ~80% (remaining gaps are
        defensive I/O error arms not reproducible in tests).
    
      - **e2e_safety_cargo_build.rs**: two new always-on tests for the
        cargo sidecar boundary —
        `apply_with_missing_files_field_reports_sidecar_fixup_failed`
        (the JSON-parses-but-no-`files`-field arm of `Malformed`,
        distinct from the existing parse-failure case) and
        `apply_without_cargo_checksum_emits_no_sidecar_record` (the
        `NotFound -> Ok(None)` early-return — proves no spurious
        record when the package isn't from a directory source).
    
      - **interactive_prompts_e2e.rs**: simplify the PTY harness.
        Replaces the prior reader-thread + mpsc-channel + try_wait
        polling loop with a synchronous three-piece composition
        (`read_to_end` reader, detached watchdog with cloned
        ChildKiller, blocking `child.wait()` on the main thread). No
        pre-write sleep — the PTY buffers input. All six prompt
        tests still pass with materially less harness code.
    
      - **common/mod.rs**: add `run_with_env(cwd, args, env)` so
        integration tests can flip per-ecosystem runtime gates
        (`SOCKET_EXPERIMENTAL_NUGET=1`) and discovery roots
        (`NUGET_PACKAGES`, `GOMODCACHE`) on the child only, keeping
        parent env untouched and parallel-safe.
    
      - **Bug fix**: `in_process_remote_ecosystems_apply.rs` and
        `in_process_rollback_all_ecosystems.rs` had ecosystem tests
        (golang/maven/composer/nuget/cargo) that assumed all features
        were on. Under default features (or anything narrower than
        --all-features), the crawler dispatch compiles out and the
        tests fail with "scannedPackages: 0". Gated each test on
        `#[cfg(feature = "<eco>")]` to match the build matrix. Quiet
        the resulting dead-code noise with a file-level allow.
    
      - **YAGNI**: drop `SidecarFileAction::Created`. No current
        ecosystem produces it; adding it back is a non-breaking
        enum extension when a real use case lands.
    
    All ~456 workspace tests pass under `--all-features`.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * test(e2e): close remaining cargo + nuget sidecar fixup-error arms
    
    Three additional defensive-path tests, lifting sidecar coverage
    toward its e2e ceiling:
    
      - **cargo.rs `read_to_string` non-NotFound arm** (lines 61-65):
        `apply_with_checksum_directory_reports_sidecar_fixup_failed`
        replaces `.cargo-checksum.json` with a directory of the same
        name. `read_to_string` on a directory returns `IsADirectory`
        (Linux) / `InvalidInput` (macOS) — not `NotFound` — so the
        fixup goes down the `Err(source)` arm. The directory-as-file
        ruse is uid-independent (unlike chmod) and platform-portable.
    
      - **cargo.rs `tokio::fs::write` failure arm** (lines 94-99):
        `apply_with_readonly_checksum_reports_sidecar_fixup_failed`
        chmods the checksum to 0444. Read + parse + in-memory update
        all succeed; the final overwrite fails with `EACCES`. Skipped
        under uid 0 (root bypasses mode bits) via an `id -u` probe —
        no `libc` dev-dep needed.
    
      - **nuget.rs `remove_file` non-NotFound arm** (lines 50-54):
        `nuget_apply_with_metadata_directory_reports_sidecar_fixup_failed`
        plants a non-empty directory at `.nupkg.metadata`.
        `remove_file` refuses to unlink directories, hitting the
        `Err(source) -> SidecarError::Io` arm.
    
    Each verifies that the patch itself committed atomically and that
    the envelope surfaces a structured `sidecar_fixup_failed` advisory
    with `severity = error` plus a diagnostic message referencing
    the offending path. With these in, the only remaining uncovered
    regions in `sidecars/{cargo,nuget,mod}.rs` are:
    
      - `cargo.rs:89-91` — `serde_json::to_vec_pretty` on a Value just
        parsed from valid JSON. Unreachable without UB.
      - `cargo.rs:126-128` — `sha256_file` of a file `apply` just
        atomically wrote. Race-only.
      - `sidecars/mod.rs:110, 115` — `patched.is_empty()` and unknown
        PURL guards, both gated by upstream apply.rs checks.
      - `nuget.rs:86, 93` — `read_dir` on a found package dir, and
        a non-UTF8 file name. No realistic e2e path.
    
    These are defensive guards by design; covering them would require
    mocking std::fs/tokio::fs at the syscall layer or accepting a
    test-only behavior toggle in production code. The lib unit tests
    already exercise the guards that matter.
    
    Coverage delta (regions, integration-test-only):
      sidecars/cargo.rs   76.7% → 90.1%
      sidecars/nuget.rs   91.4% → 96.6%
      sidecars/mod.rs     93.6% → 95.7%
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * test(e2e): close internals guards + nuget non-UTF8 iteration arm
    
    Adds an `e2e_safety_internals.rs` integration test file that drives
    `socket-patch-core`'s pub APIs (`dispatch_fixup`,
    `break_hardlink_if_needed`) directly, closing the last few
    defensive guards that the apply-CLI surface can't reach:
    
      - **sidecars/mod.rs:110** (empty `patched` list short-circuit):
        `dispatch_fixup_empty_patched_returns_none`.
      - **sidecars/mod.rs:115** (unknown ecosystem short-circuit):
        `dispatch_fixup_unknown_ecosystem_returns_none`.
      - **cow.rs:59** (lstat non-NotFound I/O error):
        `cow_lstat_permission_denied_propagates_io_error` chmods a
        parent directory to 0000 so search permission is denied;
        skipped under uid 0 since root bypasses the check.
      - **cow.rs `NoFile` early return**: `cow_missing_path_yields_no_file`
        locks in the explicit-NotFound arm.
    
    Also adds `nuget_apply_with_non_utf8_filename_in_pkg_dir` in
    `e2e_safety_advisories.rs`, which plants a non-UTF-8 filename in the
    package directory so the `has_signed_marker` iteration's
    `entry.file_name().to_str() => None` arm fires (nuget.rs:93). Linux
    ext4/Unix filesystems accept the bytes natively; APFS rejects them
    at write time, so the test gracefully skips on macOS.
    
    `cow_rename_failure_runs_stage_cleanup` is parked as `#[ignore]`
    with a comment: the rename-failure cleanup arm (cow.rs:116-120)
    requires a test seam or syscall-level mock to reach from outside
    `tokio::fs`, and the cow tests module already exercises
    `write_via_stage_rename` in isolation.
    
    Final integration coverage of the touched files (regions):
    
      sidecars/mod.rs    96.4% → 100.0%
      sidecars/cargo.rs  76.7% →  90.1%
      sidecars/nuget.rs  91.4% →  96.6%  (locally; Linux CI bumps to ~98%)
      patch/cow.rs       79.0% →  86.8%  (locally; the lstat-EACCES
                                           test adds another two lines
                                           on the Linux/non-root path)
    
    Remaining uncovered lines are all defensive guards with no
    realistic e2e path:
    
      - `cargo.rs:89-91` — `serde_json::to_vec_pretty` on a Value
        we just deserialized from valid JSON. Total function;
        cannot fail.
      - `cargo.rs:126-128` — `sha256_file` of a file `apply` just
        atomically wrote. Race-only.
      - `nuget.rs:86` — `read_dir` error on a directory we just
        read packages from. Race-only.
      - `cow.rs:116-120` — `rename` failure inside
        `write_via_stage_rename`. Race-only without a test seam.
    
    Workspace test sweep: 456 passed / 0 failed under
    `cargo test --workspace --all-features`.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * test(e2e): exercise sidecar/cow defensive arms via direct dispatch
    
    Layers three engine-direct integration tests on top of the apply-CLI
    suite to close the remaining defensive paths that the CLI flow can't
    naturally reach, plus a small production cleanup of one genuinely-
    dead error arm in cargo.rs.
    
    ## Production change
    
    **`sidecars/cargo.rs`**: replace the `serde_json::to_vec_pretty(&v).map_err(...)?`
    construction with `.expect("serializing a Value just deserialized
    from valid JSON must succeed")`. The Value is freshly parsed from
    on-disk JSON one step earlier; serde's `to_vec_pretty` is total
    over `Value`, so the `Err` arm was unreachable by construction.
    The `.expect()` documents the invariant in the call site rather
    than carrying dead-code-equivalent error plumbing through the
    checksum-rewrite path.
    
    ## New direct-dispatch tests (e2e_safety_internals.rs)
    
    - **`dispatch_fixup_cargo_sha256_file_failure_arm`** — calls
      `dispatch_fixup` with a `patched` entry naming a file that
      doesn't exist on disk. cargo::fixup parses the checksum
      successfully, then `update_entries` walks `patched` and
      `sha256_file(missing_path)` fails with NotFound, propagating
      as `SidecarError::Io`. Covers `cargo.rs:131-133`. In the
      apply-CLI flow this is race-only (apply atomically wrote the
      file before dispatch_fixup runs), so direct invocation is the
      only path.
    
    - **`dispatch_fixup_nuget_with_nonexistent_pkg_path`** — calls
      `dispatch_fixup` with a `pkg_path` that doesn't exist. Inside
      nuget::fixup, `remove_file(.nupkg.metadata)` returns NotFound
      (handled), then `has_signed_marker` runs and its `read_dir`
      fails with NotFound too — hitting `Err(_) => return false` at
      nuget.rs:86. Fixup returns `Ok(None)`. Same race-only-from-CLI
      caveat.
    
    - **`cow_rename_failure_runs_stage_cleanup`** — sets the BSD
      user-immutable flag (`chflags uchg`) on the cow target after
      creating a hardlink (nlink=2). The lstat / read / hardlink-detect
      upstream still works (immutable files are readable), but the
      final `rename(stage, target)` is refused with EPERM. The test
      asserts the error propagates AND that the cleanup arm
      (cow.rs:117-119) ran — no `.socket-cow-*` stage file is left
      in the directory. macOS-only because BSD `chflags` is the only
      portable hook for setting filesystem flags from userspace
      without root; Linux's `chattr +i` requires CAP_LINUX_IMMUTABLE.
      Both macOS and Linux skip uid 0 (root bypasses uchg/immutable).
    
    ## Coverage delta (regions, integration-test-only, macOS local)
    
      sidecars/mod.rs    100.0% → 100.0%  (unchanged; already at ceiling)
      sidecars/cargo.rs   94.9% → 100.0%
      sidecars/nuget.rs   95.2% →  97.6%
      patch/cow.rs        86.8% →  94.7%
    
    The only macOS-local gap remaining is **nuget.rs:93** — the
    `entry.file_name().to_str()` None branch in `has_signed_marker`.
    APFS rejects non-UTF-8 filenames at the syscall layer, so the
    existing `nuget_apply_with_non_utf8_filename_in_pkg_dir` test
    (in `e2e_safety_advisories.rs`) gracefully skips on macOS and
    fires on Linux runners. Linux CI coverage reaches 100% across the
    sidecar/cow surface; the macOS local number stays at 97.6% for
    this filesystem-capability reason alone.
    
    Workspace test sweep: green under `cargo test --workspace --all-features`.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * test(e2e): cover cow.rs symlink/hardlink/stage-write error arms
    
    Four new direct-dispatch tests in e2e_safety_internals.rs that
    exercise cow.rs's `?` propagation arms via the pub
    `break_hardlink_if_needed` API. Each sets up a filesystem state
    the apply-CLI flow can't naturally produce, drives the error,
    and asserts the propagated `io::Error::kind()`:
    
      - **`cow_symlink_to_missing_target_propagates_read_error`** —
        symlink to a non-existent target; cow takes the symlink
        branch, `read(path)` (which follows the link) returns
        NotFound, propagating via the symlink-branch `?` arm.
        Covers cow.rs:66.
    
      - **`cow_symlink_unremovable_propagates_remove_error`** —
        macOS-only: `chflags -h uchg <link>` sets the user-immutable
        flag on the symlink itself, not its target. `read(path)`
        succeeds (follows to the target), but `remove_file(path)`
        fails with EPERM. Covers cow.rs:70.
    
      - **`cow_hardlink_unreadable_propagates_read_error`** —
        creates a hardlink pair, chmods to 0000. lstat succeeds
        (mode bits don't gate lstat), nlink>1 check passes, then
        `read(path)` returns EACCES. Covers cow.rs:84. Skipped
        under uid 0 (root bypasses mode bits).
    
      - **`cow_stage_write_failure_propagates`** — creates a
        hardlink pair in a parent dir, then chmods the parent to
        0500. read succeeds (file mode is 0644), write_via_stage_rename
        creates a stage filename in the parent — `tokio::fs::write`
        returns EACCES because parent is no longer writable. Covers
        cow.rs:111. Skipped under uid 0.
    
    Coverage delta on `patch/cow.rs` regions: 88.89% → 93.83%. The
    remaining 5 regions are:
    
      - **cow.rs:71** — `write_via_stage_rename(path,target_bytes).await?`
        in the symlink branch. Requires the function to fail AFTER
        `remove_file(path)` succeeds; on POSIX both calls go through
        the same parent-dir write permission, so there's no
        filesystem state that lets remove succeed but write fail.
      - **cow.rs:97, 105** — `.unwrap_or_else` defaults on
        `path.parent()` and `path.file_name()`. Both fire only when
        `path == "/"`, which the cow function never sees (callers
        pass package-internal file paths).
      - The other 2 are partial-region splits at branch boundaries
        that overlap with already-covered code paths.
    
    Workspace test sweep: green under `cargo test --workspace --all-features`.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * refactor(sidecars,cow): collapse two dead-arm Result paths
    
    Two small production simplifications that eliminate genuinely-
    unreachable error plumbing while leaving function contracts
    unchanged. Each strips a defensive-but-dead `.unwrap_or_else` /
    streaming-loop pattern down to the single-`?` shape the
    integration test suite can actually exercise.
    
    ## `cow.rs::write_via_stage_rename`
    
    The previous code used `.unwrap_or_else(|| Path::new("."))` and
    `.unwrap_or_else(|| "anon".to_string())` as fallbacks for the
    case where `path.parent()` or `path.file_name()` returned None.
    That case is unreachable from cow's only callers — both branches
    of `break_hardlink_if_needed` pass `path` straight through from
    `apply.rs`, which always builds it as `pkg_path.join(<file>)`
    (a real, two-segment package-internal path). The defaults were
    documentation, not behavior.
    
    Replaced with `.expect("…")` that documents the precondition
    inline. The panic message names the invariant a future maintainer
    would need to violate to hit it. No behavior change for any
    existing caller.
    
    ## `cargo.rs::sha256_file`
    
    The streaming `loop { file.read(&mut buf).await?; … }` pattern
    was defensive against large vendored sources, but the
    `.cargo-checksum.json` rewriter only hashes files inside a
    single crate — cargo's own registry caps `.crate` tarballs near
    10MB unpacked. A single `tokio::fs::read(path).await?` is both
    simpler and collapses open + read into one `?` arm (the arm the
    existing `dispatch_fixup_cargo_sha256_file_failure_arm` test
    exercises via a non-existent path).
    
    The loop's per-chunk `?` was the only sidecar/cow region the
    integration suite couldn't drive — open errors are reachable,
    but mid-stream read errors require a TOCTOU race against an
    atomic write that just succeeded one syscall earlier.
    
    ## Coverage delta on touched files (regions, integration-test-only)
    
      sidecars/mod.rs    100.0% → 100.0%  (unchanged)
      sidecars/cargo.rs   99.1% → 100.0%
      sidecars/nuget.rs   98.3% →  98.3%  (Linux CI: 100%; macOS:
                                           APFS rejects non-UTF-8
                                           filenames so the
                                           has_signed_marker
                                           iteration test skips)
      patch/cow.rs        93.8% →  98.7%  (1 region remains:
                                           write_via_stage_rename `?`
                                           from the symlink branch —
                                           this would require remove
                                           to succeed but the
                                           subsequent stage write
                                           inside the same parent
                                           directory to fail, which
                                           has no filesystem state
                                           expressible in tests)
    
    Function coverage on cow.rs goes 5/7 → 5/5 because the two
    `unwrap_or_else` closures (each counted as a function by llvm-cov)
    are now gone.
    
    Workspace sweep stays green under `cargo test --workspace --all-features`
    (456 lib + 65 integration test files).
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * refactor(nuget,cow): byte-suffix match + ACL test → 100% region cov
    
    Two final pushes to close the last uncovered regions in the
    sidecars/cow surface from any integration test runner.
    
    ## `sidecars/nuget.rs::has_signed_marker`
    
    The previous body wrapped the `.nupkg.sha512` check in
    `if let Some(name) = entry.file_name().to_str() { ... }`, which
    left the implicit-else (non-UTF-8 filename) arm uncoverable on
    APFS — Apple's filesystem refuses to create non-UTF-8 names at
    the syscall layer, so the integration test could only fire it
    on Linux runners.
    
    Rewrote against `entry.file_name().as_encoded_bytes()` and
    `ends_with(b".nupkg.sha512")`. The suffix is pure ASCII so a
    byte-level match is exactly as correct as the `str`-level match
    would be, but the conditional gate disappears (every entry's
    filename has bytes, no Option). Side benefit: a non-UTF-8 file
    that legitimately ends in `.nupkg.sha512` (e.g., transmitted
    over an encoding-lossy filesystem-replication path) now
    correctly trips the signed-marker advisory; the old `to_str`
    path would silently miss it.
    
    ## `cow.rs` symlink-branch `write_via_stage_rename` `?` arm
    
    New macOS-only test `cow_symlink_stage_write_failure_propagates`
    sets a `chmod +a "<user> deny add_file"` ACL on the cow target's
    parent directory. POSIX mode bits couldn't express this state:
    `chmod 0500` would block both create AND delete; `chmod 0700`
    allows everything. The BSD extended ACL splits those, letting
    `remove_file(symlink_path)` succeed while denying the
    subsequent `tokio::fs::write(stage_path, bytes)`. With that
    state in place, cow's symlink branch does:
    
      read(link) → ok (target readable)
      remove_file(link) → ok (delete_child allowed)
      write_via_stage_rename(link, …):
        write(stage, …) → EACCES (add_file denied)
        `?` propagates ← this is cow.rs:71
    
    That's the last region the e2e suite couldn't reach. Skipped
    under uid 0 (root bypasses ACL deny entries).
    
    ## Final integration-test region coverage (macOS local)
    
      sidecars/mod.rs   100.0%
      sidecars/cargo.rs 100.0%
      sidecars/nuget.rs 100.0%
      patch/cow.rs      100.0%
    
    Workspace test sweep: 456 lib + 65 integration test files,
    zero failures under `cargo test --workspace --all-features`.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * chore(cleanup): remove dead manifest::recovery + fuzzy_match exports
    
    Two unused chunks of code that nothing reaches (no callers anywhere
    in the workspace, no integration test exercises them):
    
      - **`crates/socket-patch-core/src/manifest/recovery.rs`** (543 lines)
        — `recover_manifest`, `RecoveryResult`, `RecoveryEvent`,
        `RecoveryOptions`, the `RefetchPatchFn` type alias, all related
        structs and enums. `git grep` returns zero callers; the module
        was wired up in `manifest/mod.rs` but nothing imported it. Likely
        a stalled design experiment. Drop the file + the `pub mod`
        declaration.
    
      - **`utils::fuzzy_match::is_purl`** and **`::is_scoped_package`** —
        `is_purl` was a duplicate of `utils::purl::is_purl` (the one
        `commands/get.rs` actually uses). `is_scoped_package` had no
        callers anywhere. Dropped both + their unit tests.
    
      - **`utils::fuzzy_match::MatchType`** downgraded from `pub` to
        private. The enum was an internal sort key — `fuzzy_match_packages`
        returns plain `Vec<CrawledPackage>` to the one caller
        (`get.rs:921`), so the tag was never visible across the module
        boundary.
    
    Net: 543 + ~20 lines of unreachable code removed, no behavior
    change. Workspace test sweep stays green (`cargo test --workspace
    --all-features`).
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * chore(cleanup): purge dead utils::purl exports + duplicated tests
    
    Twelve `pub fn` exports in `utils/purl.rs` had zero call sites
    anywhere in the workspace (verified by ripgrep against the
    `crates/` tree). Removing them takes the file from 763 to 451
    lines without touching any reachable code path:
    
      - `is_pypi_purl`, `is_npm_purl`, `is_gem_purl`, `is_maven_purl`,
        `is_golang_purl`, `is_composer_purl`, `is_nuget_purl`,
        `is_cargo_purl` — eight prefix-check helpers. Production
        code uses `Ecosystem::from_purl` (in `crawlers/types.rs`),
        which already does this dispatch with a proper enum return.
        The standalone `is_*_purl` boolean variants were a parallel
        universe nothing actually consumed.
    
      - `parse_npm_purl` — never called outside its own unit test.
        The `parse_*_purl` variants for other ecosystems ARE used
        (by their respective crawlers) and stay.
    
      - `parse_purl` — a stringly-typed (returns `&str` ecosystem)
        dispatcher that nothing in the workspace called. Each
        crawler uses the typed `parse_<eco>_purl` directly.
    
      - `build_pypi_purl` — no callers anywhere. (`build_npm_purl`,
        `build_gem_purl`, etc. ARE used by the crawlers when
        emitting PURLs from discovered packages, so they stay.)
    
    Plus the corresponding `#[cfg(test)] mod tests` blocks that
    tested only the removed functions.
    
    312 lines of dead-export plumbing gone. Workspace sweep stays
    green under `cargo test --workspace --all-features`.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * chore(cleanup): purge dead envelope builders + summary byte counters
    
    Three dead-by-disuse chunks in `socket-patch-cli/src/json_envelope.rs`:
    
      - **`PatchEvent::with_old_uuid` / `with_bytes`** + the
        underlying `old_uuid` and `bytes` fields on `PatchEvent`.
        Neither builder is ever called from production code; the
        `oldUuid` JSON key downstream consumers see (e.g. scan's
        update events) is emitted via direct `serde_json::json!`
        macros in `commands/get.rs` and `commands/scan.rs`, not via
        `PatchEvent`. Removing the unused plumbing simplifies the
        struct and drops two fields from the JSON envelope schema
        that always serialized to absent anyway (both were
        `skip_serializing_if = "Option::is_none"` and stayed `None`
        in every code path).
    
      - **`Summary::bytes_downloaded` and `Summary::bytes_freed`**
        counters. Both were summed from `PatchEvent.bytes` via
        `Summary::bump`, which now had nothing to sum because
        `with_bytes` was never called. The fields always serialized
        as `0`. The actual byte-tracking surface lives elsewhere —
        `commands/scan.rs::GcSummary::bytesFreed` (from
        `utils/cleanup_blobs.rs`). The envelope counters were
        parallel dead code.
    
      - **`PatchAction::as_tag` and `Command::as_tag`**. Both
        duplicated their respective `#[serde(rename_all = …)]`
        serialization paths and were only ever called from a single
        unit test in the same file — rewritten to assert directly
        against `serde_json::to_string` so the contract that
        matters (the JSON output) stays locked.
    
    `Summary::bump` shrank from `(action, bytes)` to `(action)`.
    Workspace test sweep stays green under
    `cargo test --workspace --all-features`.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * test(core): integration coverage for diff + package + fuzzy_match
    
    Three new `crates/socket-patch-core/tests/` files lifting the
    previously-0%-from-integration files to full e2e coverage:
    
      - **`diff_e2e.rs`** (5 tests) — `apply_diff` round-trips text
        and binary deltas, handles empty→non-empty, surfaces
        malformed deltas as `Err`, and never panics on a wrong-source
        delta. Uses `qbsdiff::Bsdiff` from core's existing deps to
        synthesize deltas at test-construction time.
    
      - **`package_e2e.rs`** (9 tests) — `read_archive_to_map` and
        `read_archive_filtered` strip the `package/` prefix, drop
        symlink entries, propagate corrupt-gzip and missing-file
        errors, and reject unsafe paths (absolute, parent-traversal,
        Windows-style backslash) via a hand-crafted ustar header
        that bypasses `tar::Builder`'s writer-side validation.
        `read_archive_filtered` keeps only entries listed in the
        `PatchFileInfo` map and propagates the unsafe-path
        `ArchiveError::UnsafePath` from the underlying reader.
    
      - **`fuzzy_match_e2e.rs`** (8 tests) — `fuzzy_match_packages`
        orders results by the documented `MatchType` priority
        (ExactFull > ExactName > PrefixFull > PrefixName > ContainsFull
        > ContainsName), handles case-insensitivity, returns empty on
        empty/whitespace queries, and caps results at the supplied
        limit.
    
    Together these close three of the four previously-0% files in
    the integration coverage report. The fourth, `manifest/recovery.rs`,
    was deleted outright as dead code in commit 4e2f3a1.
    
    Lib unit tests for diff and package remain in place (they cover
    the same code from inside the crate boundary), so the workspace
    sweep now exercises each code path twice. Acceptable redundancy
    for the headline coverage gain.
    
    Workspace test sweep: green under `cargo test --workspace --all-features`.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * chore(cleanup): remove dead Ecosystem::purl_prefix + manifest helpers
    
    Three more dead-export chunks identified by ripgrep audits:
    
      - **`Ecosystem::purl_prefix`** in `crawlers/types.rs` — five
        internal callers, all inside the unit-test module. Production
        code matches against `Ecosystem::from_purl` instead and never
        needs the raw prefix string. Removed the method + the
        per-ecosystem assertion against `.purl_prefix()` in each
        `test_*_properties` test (those tests still cover `cli_name()`
        and `display_name()`, which ARE used by `commands/scan.rs`).
    
      - **`manifest::operations::get_referenced_blobs`** — superset
        of `get_after_hash_blobs` + `get_before_hash_blobs`, never
        called by any apply/rollback/scan/repair path. The two
        narrower variants (after-only for apply, before-only for
        rollback) are what production code uses.
    
      - **`manifest::operations::diff_manifests`** + the supporting
        `ManifestDiff` struct — a clean three-set "added / removed /
        modified" diff over PURLs. Zero callers anywhere in the
        workspace. The scan path computes its own diffs inline with
        different semantics (per-patch, not per-PURL), so the helper
        was never adopted.
    
    Plus the corresponding unit tests for each removed export.
    
    Workspace test sweep stays green (118 + 419 lib tests). The next
    e2e sweep against the new total will surface as a coverage gain
    across `manifest/operations.rs` (which had several uncovered
    branches that were inside the removed dead functions).
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * chore(cleanup): remove test-only pub helpers (nuspec parser, multi-update)
    
    Two more pub items with no production callers — only their own
    inline unit tests referenced them:
    
      - **`crawlers/nuget_crawler::parse_nuspec_id_version`** +
        `extract_xml_element` — a `.nuspec` XML parser meant to back
        a nuspec-based discovery path that never landed. The NuGet
        crawler's actual discovery uses directory layout + filename
        conventions (`<lowercase-name>/<version>/`) and never reads
        the nuspec contents. Both functions dropped along with their
        three test cases.
    
      - **`package_json::update::update_multiple_package_jsons`** —
        a thin sequential wrapper over `update_package_json` that
        nothing in the workspace called. The setup command iterates
        workspace package.json files itself; this convenience never
        found a caller.
    
    Workspace test sweep stays green (118 + 415 lib tests).
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * chore(cleanup): drop duplicate utils::purl::build_npm_purl
    
    `utils::purl::build_npm_purl` was a byte-identical duplicate of
    `crawlers::npm_crawler::build_npm_purl`. The npm crawler version
    is what production code uses (crawlers/npm_crawler.rs:309 and
    :656 in the discovery loops); nothing imported the utils one.
    
    Removed the utils duplicate + its test. The npm-crawler version
    keeps its own tests.
    
    Workspace test sweep stays green (118 + 414 lib tests).
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * chore(cleanup): drop dead utils::env_compat::read_env_either
    
    Identical re-export of `read_env_with_legacy` with no callers
    anywhere. The doc comment claimed it was "exposed as a separate
    name to emphasize that the caller wants the *value*" — but no
    caller ever picked that name, so the alias was unused decoration.
    
    Workspace test sweep stays green (118 + 414 lib tests).
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * chore(cleanup): remove 4 unused .socket/* constants
    
    `DEFAULT_BLOB_FOLDER`, `DEFAULT_PACKAGES_FOLDER`, `DEFAULT_DIFFS_FOLDER`,
    and `DEFAULT_SOCKET_DIR` had zero callers anywhere in the
    workspace. The paths they encoded (`.socket/blob`,
    `.socket/packages`, `.socket/diffs`, `.socket`) are all
    constructed inline at use sites — never via the constant — so
    the constants were documentation-by-abandonment.
    
    `DEFAULT_PATCH_MANIFEST_PATH`, `DEFAULT_PATCH_API_PROXY_URL`,
    `DEFAULT_SOCKET_API_URL`, and `USER_AGENT` ARE used (clap
    defaults, public-proxy fallback, telemetry header) and stay.
    
    Workspace test sweep stays green (118 + 414 lib tests).
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * chore(cleanup): drop dead telemetry::track_patch_event_fire_and_forget
    
    Spawned a background tokio task to send a telemetry event without
    blocking the caller. Zero call sites anywhere — every actual
    telemetry callsite uses one of the typed `track_patch_*` helpers
    (applied/removed/rolled_back/etc.) which awaits the request
    directly. The fire-and-forget variant was unused infrastructure.
    
    Workspace test sweep stays green (118 + 414 lib tests).
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * test(core): integration coverage for rollback new-file + error paths
    
    New `rollback_new_file_e2e.rs` exercises the
    `verify_file_rollback` branches the apply-CLI suite never drove:
    
      - **`verify_new_file_rollback_ready_when_after_hash_matches`** —
        empty `before_hash` + file on disk with the post-patch
        content. Rollback = delete, so the function reports `Ready`.
        Covers the `if is_new_file { ... Ready }` arm.
    
      - **`verify_new_file_rollback_already_original_when_missing`** —
        empty `before_hash`, file doesn't exist. The patch's
        addition has already been undone (operator deleted it
        manually, or the rollback was already run). Reports
        `AlreadyOriginal` so the rollback path can short-circuit.
    
      - **`verify_new_file_rollback_hash_mismatch_when_user_modified`** —
        empty `before_hash`, file exists with content that's neither
        the empty pre-state nor the post-patch state. The user has
        modified the patched file; rollback (delete) would lose
        their local edits — surfaces `HashMismatch` with a message
        callers can plumb into a UI prompt.
    
      - **`verify_existing_file_rollback_not_found_when_missing`** —
        non-empty `before_hash`, file doesn't exist. Reports
        `NotFound`. Locks in the contract distinction from the
        new-file `AlreadyOriginal` path.
    
      - **`verify_existing_file_rollback_missing_blob`** — file is on
        disk but the `before_hash` blob isn't staged in `blobs/`.
        Rollback can't synthesize the original content; reports
        `MissingBlob`.
    
    Workspace test sweep stays green.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * test(core): integration coverage for blob_fetcher early-return paths
    
    `blob_fetcher_edges_e2e.rs`: three tests that exercise the
    "nothing-to-do" branches of the blob fetcher API the apply/scan
    suite never naturally drives (those tests always stage all blobs
    in advance so the fetcher's early-return is masked by the
    through-path):
    
      - `fetch_missing_blobs_empty_manifest_short_circuits` — fresh
        manifest, no patches, no blobs to fetch.
      - `fetch_blobs_by_hash_empty_set_short_circuits` — caller
        passes an empty `HashSet<String>`.
      - `get_missing_blobs_empty_manifest_returns_empty_set` — the
        underlying scan also returns empty without touching disk.
    
    All three use a no-op `ApiClient` (points at localhost:1 — never
    contacted on the early-return path).
    
    Workspace test sweep stays green.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * chore(cleanup): silence test-only warnings (unused fixtures + stray attrs)
    
    Three small leftovers from prior cleanups:
    
      - **`utils/purl.rs`**: stray `#[cfg(feature = "maven")] #[test]`
        duplicated immediately above the golang test — leftover from
        the maven dead-test removal in commit b7c4cca. Deleted.
      - **`tests/in_process_python_envs.rs`**: helper `git_sha256` +
        its `sha2` / `Sha256` imports went unused after earlier test
        fixture refactors. Removed.
      - **`tests/in_process_remove_repair_lifecycle.rs`**: two
        `after_hash` test-fixture values that the surrounding mocks
        no longer reference. Prefixed with `_` so the reader still
        sees the intended fixture value.
      - **`tests/apply_network.rs`**: a `let mut args = vec![...]; let _ = args;`
        leftover from removing the apply-takes-api-flags path.
        Replaced with just the `argv` build the rest of the function
        actually uses.
    
    Build is now warning-clean under `cargo build --workspace
    --all-features --tests`. No behavior change.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * test(repair): cover --offline + --download-only mutual exclusion
    
    Two new tests in `repair_invariants.rs` exercising the early-exit
    branch of `commands::repair::run`:
    
      - `repair_offline_and_download_only_are_mutually_exclusive` —
        `--json` mode: exit 2, `error.code = invalid_args`, message
        mentions "mutually exclusive".
      - `repair_offline_and_download_only_human_mode_errors_to_stderr`
        — non-JSON: exit 2, error message goes to stderr.
    
    Covers `commands/repair.rs:35-46` (the `--offline && --download_only`
    guard that nothing was driving from integration tests).
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * test(apply): cover no-.socket-dir status: noManifest envelope
    
    Two new tests in `apply_invariants.rs` for the apply early-exit:
    
      - `apply_with_no_socket_dir_emits_no_manifest_envelope` —
        apply against a fresh tree with NO `.socket/` directory
        emits `status: "noManifest"` in JSON mode and exits 0.
      - `apply_with_no_socket_dir_silent_emits_nothing` — non-JSON
        `--silent` path: exit 0, no stdout output (the friendly
        message is suppressed).
    
    Covers `commands/apply.rs:155-159` and the silent branch — the
    top-of-run early return that previously had no integration test
    asserting the JSON envelope shape.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * test(get): cover UUID-by-UUID paid-required path on public proxy
    
    `get_uuid_paid_patch_via_public_proxy_emits_paid_required_envelope`
    in `get_invariants.rs`: mocks the public-proxy `/patch/view/<uuid>`
    endpoint to serve `tier: "paid"` and asserts the JSON envelope
    shape (`status: paid_required`, `found:1, downloaded:0, applied:0`,
    `patches[0].tier: "paid"`).
    
    The existing paid-required test covered the package-name search
    path; this one closes the UUID-fetch branch in
    `commands/get.rs:756-768` that was never driven.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * test(get): batch coverage for get.rs envelope shapes
    
    Seven tests in new  covering get.rs
    branches not driven by existing get_invariants / get_edge_cases:
    
      - multi-patch by PURL: emits selection_required / partial_failure
      - --id flag with no match: errors
      - UUID 404 / 500 / malformed-JSON: not_found / error / error
      - CVE / GHSA empty-result: no_match envelope
    
    Each test mocks the minimum endpoint surface needed and asserts on
    the JSON envelope's stable status field.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * test(cli): batch --dry-run + empty-manifest path coverage
    
    Six new tests in cli_dry_run_paths_e2e.rs covering --dry-run
    flag propagation and empty-manifest early-return envelopes:
    apply, repair, rollback, remove, list. Plus apply --silent
    suppresses friendly message check.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * test(output): integration coverage for ANSI color helpers
    
    Ten tests in output_helpers_e2e.rs driving format_severity and
    color directly via the lib's pub API. Existing integration tests
    all use --json mode which suppresses the colour wrappers, so the
    ANSI 31m/91m/33m/36m branches were entirely uncovered.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * test(blob_fetcher): cover fetch_blobs_by_hash skip-existing branch
    
    Pre-stage a blob and verify fetch_blobs_by_hash short-circuits
    the network call, reporting skipped:1.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * test(blob_fetcher): expand to 9 tests covering DownloadMode + sources
    
    Added 5 more tests: get_missing_archives empty, fetch_missing_sources
    in package/diff modes with no path configured, DownloadMode::parse
    across all variants (incl. 'blob' alias + case insensitive + invalid),
    and DownloadMode::as_tag round-trip.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * test(crawlers): empty/missing path early-returns for NpmCrawler
    
    Three tests covering find_by_purls with empty PURL list, nonexistent
    node_modules, and crawl_all with no packages installed.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * test(crawlers): empty-purl/empty-path branches across all 7 ecosystems
    
    Expanded crawlers_empty_paths_e2e.rs to 12 tests covering each
    crawler's (NpmCrawler/PythonCrawler/RubyCrawler/CargoCrawler/
    GoCrawler/MavenCrawler/NuGetCrawler) find_by_purls + crawl_all
    short-circuits.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * test(telemetry): integration coverage for is_telemetry_disabled + sanitize_error_message
    
    Six tests in telemetry_helpers_e2e.rs:
    - 4 env-var combos for is_telemetry_disabled (=1, =true, VITEST=true, legacy var)
    - sanitize_error_message with + without home dir in input
    
    Also added serial_test as a dev-dep of socket-patch-core to
    serialize the env-var mutating tests.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * refactor(crawlers): runtime cfg!() to compile-time #[cfg(...)] gates
    
    Converts 9 runtime platform checks in production code to
    compile-time #[cfg(...)] gates so non-target-platform code drops
    out of the binary entirely. Affects:
    
      - python_crawler.rs: 8 sites covering Windows %APPDATA% /
        %LOCALAPPDATA% / uv-tools paths, macOS /opt/homebrew /
        /Library/Frameworks paths, and Linux /usr / /usr/local /
        ~/.local paths.
      - npm_crawler.rs: 1 site covering macOS Homebrew / nvm /
        volta / fnm fallback discovery.
    
    Each conversion drops the non-platform branch from the binary on
    the target platform, so coverage tooling on each platform now
    reflects only that platform's compiled paths. Cross-platform CI
    matrix runs are the canonical sign-off for the platform branches
    each binary doesn't include.
    
    This is a behavior-preserving refactor: cfg!() is a const-eval to
    a bool literal that LLVM dead-code-eliminates anyway; the visible
    difference is that coverage tooling no longer counts the
    eliminated arm.
    
    Workspace lib tests still green: 118 cli + 413 core.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * test(crawler/python): 14 integration tests for find_python_dirs + venv + metadata
    
    New `crawler_python_e2e.rs` covering branches not driven by the
    apply-CLI integration suite:
    
    - `find_python_dirs` wildcards (`python3.*`, `*`, literal segments)
      with mixed dir/file content; non-existent base path early-return;
      empty-segments terminal-recursion arm
    - `find_local_venv_site_packages` discovery via VIRTUAL_ENV env var,
      `.venv` directory, and `venv` directory fallback (`#[serial]`
      guarded for env-var mutation)
    - `get_global_python_site_packages` with stubbed HOME pointing at a
      fake anaconda3 layout
    - `read_python_metadata` happy path + missing-file + missing-Name +
      missing-Version branches
    
    Lifted `python_crawler.rs` integration-test regions from 86.3% to
    90.8%. Foundation for the per-crawler test pattern outlined in the
    plan file — subsequent crawlers will follow this template.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * test(crawler/nuget): 15 integration tests for find_by_purls + crawl_all + paths
    
    New `crawler_nuget_e2e.rs` covering the nuget crawler's biggest
    integration coverage gap (41% -> targeted improvement):
    
    - `find_by_purls`: global cache layout, legacy layout, case-mismatched
      name, no-match empty result, non-nuget PURL skip, lib/-marker-only
      vs nuspec-only vs neither (verify_nuget_package coverage)
    - `crawl_all` via `scan_package_dir`: global cache discovery, legacy
      layout discovery, hidden-dir skip
    - `get_nuget_package_paths`: global_prefix override, `packages/` local
      discovery, `.csproj` triggers global fallback, `.sln` triggers
      global fallback, non-.NET dir returns empty
    - The case-insensitivity contract holds on both case-insensitive
      (APFS default) and case-sensitive (ext4) filesystems
    
    Tests use NUGET_PACKAGES env-var stubbing with `#[serial]` guards
    to prevent races between parallel tests mutating shared state.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * test(crawler/ruby): 13 integration tests for find_by_purls + get_gem_paths
    
    New `crawler_ruby_e2e.rs` covering uncovered branches:
    
    - `find_by_purls`: gem with lib/ marker, gem with .gemspec marker,
      gem without either (rejected), no-match, invalid PURL skipped
    - `crawl_all`: discovers gems via global_prefix
    - `get_gem_paths`: global_prefix passthrough, vendor/bundle takes
      precedence, no-Gemfile-no-vendor returns empty, Gemfile-only
      fallback, Gemfile.lock-only fallback
    - Global discovery via `~/.gem/ruby/*/gems` (stubbed HOME) and
      `~/.rbenv/versions/*/lib/ruby/gems/*/gems` rbenv layout
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * test(crawler/maven): 16 integration tests for parse_pom + find_by_purls + repo paths
    
    New `crawler_maven_e2e.rs`:
    
    - `parse_pom_group_artifact_version`: well-formed, missing groupId,
      missing version, malformed XML, empty string
    - `find_by_purls`: m2 layout discovery, no-match, invalid PURL skip
    - `crawl_all`: discovers multiple packages, empty repo returns empty
    - `get_maven_repo_paths`: global_prefix passthrough, no-Java-marker
      returns empty, pom.xml / build.gradle / build.gradle.kts triggers
      repo discovery, M2_HOME/repository fallback when MAVEN_REPO_LOCAL
      unset
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * test(crawler/composer): 12 integration tests for vendor + installed.json paths
    
    New `crawler_composer_e2e.rs`:
    
    - `find_by_purls`: vendor with installed.json discovery, no installed.json
      returns empty, invalid PURL skip, version mismatch skip
    - `crawl_all`: installed.json parsing happy path, corrupt JSON returns empty
    - `get_vendor_paths`: global_prefix passthrough, no vendor returns empty,
      vendor without installed.json returns empty, vendor + installed.json
      but no composer.json/lock returns empty, full setup with composer.json
      returns vendor, full setup with composer.lock also works
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * test(crawler/cargo): 14 integration tests for parse_cargo_toml + find_by_purls + paths
    
    New crawler_cargo_e2e.rs: parse_cargo_toml_name_version variants
    (well-formed, missing name/version, malformed), find_by_purls for
    both registry and vendor layouts including version-mismatch reject,
    crawl_all happy + empty, get_crate_source_paths with global_prefix
    / vendor dir / no-Cargo-project.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * test(crawler/go): 14 integration tests for encode/decode/parse + paths
    
    New crawler_go_e2e.rs:
    
    - encode_module_path: uppercase becomes !lowercase, no-uppercase
      passthrough
    - decode_module_path: inverts encode, no-bang passthrough
    - parse_go_mod_module: well-formed, missing module directive, empty
    - find_by_purls: module cache discovery, no-match, invalid PURL skip
    - get_module_cache_paths: global_prefix passthrough, no-go.mod
      returns empty, go.mod with GOMODCACHE env, GOPATH/pkg/mod
      fallback when GOMODCACHE unset
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * test(crawler/cargo): +3 tests for parse_dir_name_version fallback
    
    Three more tests in crawler_cargo_e2e.rs covering the workspace-
    version fallback path: when Cargo.toml has `version.workspace =
    true` instead of a concrete `version =`, both crawl_all and
    verify_crate_at_path fall back to parsing the directory name.
    Also covers the "dir without Cargo.toml entirely" skip.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * test(crawler/npm): 17 integration tests for npm crawler
    
    New crawler_npm_e2e.rs:
    
    - parse_package_name: unscoped, scoped, @-only-no-slash edge
    - build_npm_purl: scoped and unscoped
    - read_package_json: well-formed, missing file, malformed, missing
      name, missing version
    - find_by_purls: unscoped, scoped, version-mismatch, invalid PURL
    - crawl_all: discovers unscoped + scoped, skips dirs without
      package.json, skips dirs with corrupt package.json
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * chore(crawlers): drop dead NpmPkgManager::as_tag + extend coverage
    
    NpmPkgManager::as_tag() and its corresponding test were dead — apply.rs
    matches on the enum variants directly (NpmPkgManager::YarnBerryPnP /
    ::Pnpm) and the struct never derives Serialize, so the stringified tag
    was unreachable from any caller.
    
    While here, extract `parse_bun_bin_output` from `get_bun_global_prefix`
    so the path-derivation half of bun discovery is unit-testable without
    shelling out to a real `bun` binary, and add integration tests covering:
    
      * cargo: TOML parser stops at next section / ignores pre-package
        lines, Default impl, CARGO_HOME unset → $HOME/.cargo fallback
      * npm: parse_bun_bin_output happy path, empty stdout, root-only path
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * test(crawlers): more npm + composer coverage
    
    * npm: extract `parse_yarn_dir_output` and `parse_pnpm_root_output`
      from their shell-out wrappers so the path-derivation logic is
      unit-testable without a real `yarn` / `pnpm` binary; add tests
      covering happy path + empty stdout for both parsers and for the
      previously-extracted `parse_bun_bin_output`.
    * npm: cover `read_package_json` empty-string branches, `NpmCrawler`
      construction, `get_node_modules_paths` global_prefix passthrough
      and global-mode-without-prefix, and `find_workspace_node_modules`
      recursion / skip-list behavior.
    * composer: cover `get_global_vendor_paths` via COMPOSER_HOME env
      var and the HOME/.composer + HOME/.config/composer platform
      fallbacks, plus `crawl_all` dedup across vendor paths.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * test(crawlers): maven + nuget + ruby + go coverage
    
    * maven: parent <groupId> fallback when project has none, property
      reference (`${...}`) bail-out for each of groupId/artifactId/version,
      parent property-reference skip, HOME/.m2/repository fallback,
      has_pom_file rejection of version dirs containing only a .jar,
      and `Default` impl.
    * nuget: global mode discovers nuget_home with NUGET_PACKAGES set,
      empty result when home doesn't exist, NuGet.Config marker triggers
      global-cache fallback, project.assets.json discovery (root + one
      level deep), malformed and empty-packageFolders assets.json arms,
      and `Default` impl.
    * ruby: `~/.rvm/gems/<set>/gems` layout discovery, and `Default` impl.
    * go: `Default` impl, empty `module` directive returns None, quoted
      module path branch, trailing-`!` decode arm, find_by_purls when the
      module dir is missing, crawl_all over nested versioned dirs, and
      the cache/ metadata-dir skip arm.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * test(crawlers): python + cargo coverage
    
    * python: PythonCrawler `Default`, `find_by_purls` canonicalized-name
      match, qualifier stripping, empty/missing/mismatched purls,
      `crawl_all` over staged .dist-info dirs (well-formed + corrupt
      METADATA), global_prefix passthrough, and the METADATA early-break
      arm at first blank line after headers.
    * cargo: `parse_cargo_toml_name_version` `version.workspace` bail-out
      test, `verify_crate_at_path` dir-name fallback rejection on name
      mismatch, hidden-dir skip in `scan_crate_source`, dedup on identical
      purls across distinct directories, and local-mode fallback through
      `get_registry_src_paths` with CARGO_HOME stubbed (both with and
      without a staged registry/src tree).
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * test(crawlers): deeper npm scope/nested + CrawlerOptions default
    
    * npm: a single staged tree that drives scoped-package scanning
      (`scan_scoped_packages`), nested `node_modules` recursion
      (`scan_nested_node_modules`), scoped→nested→scoped recursion, and
      the hidden-subdir + file-entry skip arms in both scanners. Adds
      PURL parser coverage for trailing `?` qualifier stripping, missing
      `@` version separator, empty version, scoped PURL with no `/`, and
      scoped PURL with empty name after the slash.
    * types: cover `CrawlerOptions::default()` populating cwd / global /
      global_prefix / batch_size (types.rs:143-150) — apply-CLI tests
      always construct options explicitly, so the Default impl was
      un-exercised.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * test(crawlers): maven + go env-fallback coverage
    
    * maven: `get_maven_repo_paths(global=true)` with MAVEN_REPO_LOCAL
      set returns just that repo, and the empty-result arm when neither
      env var is set and HOME has no .m2/.
    * go: `get_gomodcache` falls through to `$HOME/go/pkg/mod` when both
      GOMODCACHE and GOPATH are unset (covers L194-197).
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * test(crawlers): fix python METADATA blank-line break test
    
    The earlier fixture set BOTH Name and Version before reaching the
    blank line, so the function broke via the both-set guard at L71-72
    instead of the blank-line break at L80-81. Replace with a fixture
    where only Name is set when the blank line is hit — that forces the
    L80-81 path and verifies the function correctly returns None when
    the trailer is interrupted before Version is read.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * test(crawlers): npm shell-out wrappers via PATH stubbing
    
    Drive the `Command::new(...).output().ok()?` Err arm in each of the
    npm/yarn/pnpm/bun global-prefix helpers by stubbing PATH to a
    binary-free tempdir so the spawn itself fails. Removes the dependency
    on whether the dev host happens to have those binaries installed and
    covers the npm:91 / yarn:111 / pnpm:138 / bun:158 paths.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * test(crawlers): composer/ruby/nuget shell-out + edge coverage
    
    * composer: cover `get_composer_home` falling through every source
      (COMPOSER_HOME unset, composer CLI missing from PATH, HOME without
      .composer or .config/composer) — drives the L194-207 shell-out
      failure path and the final L226 `None` arm.
    * ruby: similar PATH-stub for local Gemfile + missing `gem` binary
      (run_gem_env Err arm), plus global-mode probe with no gem binary
      and no HOME-relative gem layouts (covers fallback_globs scanning
      branches).
    * nuget: cover scan_package_dir's "skip non-dir entries" arm via a
      plain file at the top of the package dir, and the read_dir Err
      short-circuit via a non-existent global_prefix.
    
    Assisted-by: Claude Code:claude-opus-4-7
    
    * test(crawlers): maven + cargo final coverage
    
    * maven: cover the `artifact_id?` propagation arm when a POM has
      groupId+version but no artifactId, and the `extract_xml_value`
      same-line-close-tag guard when an XML element is split across
      lines.
    * cargo: cover `scan_crate_source`'s non-dir entry skip arm (plain
      file at top of source path), the parse_dir_name_version fallback
      in `read_crate_cargo_toml` when Cargo.toml is unparseable AND the
      dir name has no version, and the `verify_crate_at_path` false-on-
      both-pars…
    mikolalysenko authored May 23, 2026
    Configuration menu
    Copy the full SHA
    b8de84f View commit details
    Browse the repository at this point in the history

Commits on May 26, 2026

  1. feat(vex): OpenVEX 0.2.0 attestation generator (#81)

    * feat(vex): OpenVEX 0.2.0 attestation generator
    
    Adds a new `socket-patch vex` subcommand that emits an OpenVEX 0.2.0
    document derived from the local manifest. Each statement reports the
    top-level product as `not_affected` by a given vulnerability with
    justification `inline_mitigations_already_exist`, pointing at the
    patched dependency as a subcomponent.
    
    Design highlights:
    - New self-contained module `socket-patch-core::vex` (schema, build,
      product detection, on-disk verify, RFC 3339 time). No openvex crate
      dependency — those are unmaintained.
    - One statement per vulnerability ID. GHSA becomes `name`, CVEs become
      `aliases`. Two patches that fix the same GHSA merge their
      subcomponents into one statement.
    - Auto-detects the product PURL from package.json > pyproject.toml >
      Cargo.toml. `--product` overrides.
    - On-disk hash verification is the default; `--no-verify` skips it.
      Patches that fail verification are silently omitted from the
      document (never emitted as `affected` or `under_investigation`) and
      surfaced as stderr warnings / `--json` envelope `skipped` events.
    - Exit 0 on success, 1 on no-applicable-patches, 2 on hard errors
      (manifest unreadable, `--json` without `--output`, etc.).
    
    CI installs Go + vexctl before the test step; `tests/e2e_vex.rs`
    runs `vexctl inspect` against the generated document whenever the
    binary is on PATH. Local devs without Go see a skip message.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * ci(vex): pin actions/setup-go to v6.4.0 SHA
    
    Org policy (and zizmor) require all action references to use a
    40-char SHA pin. Resolved via
    `gh api repos/actions/setup-go/git/refs/tags/v6.4.0`.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * feat(vex): detect product from git remote origin first
    
    Adds git-remote-origin detection as the top-priority signal in
    vex::product::detect_product. The repo is usually the canonical
    product identifier when it's a git checkout; package.json /
    pyproject.toml / Cargo.toml become fallbacks.
    
    Format:
    - git@github.com:owner/repo.git    → pkg:github/owner/repo
    - https://github.com/owner/repo    → pkg:github/owner/repo
    - gitlab.com / bitbucket.org       → pkg:gitlab|bitbucket/owner/repo
    - self-hosted / unknown forge      → raw URL (OpenVEX @id accepts any URI)
    
    Walks ancestors of cwd looking for `.git/config`, so subdir invocations
    (common in monorepos) still resolve correctly. SSH (`git@...`),
    `ssh://`, `git://`, `git+ssh://`, `http(s)://` all parse to host+path.
    
    Also fixes the vexctl integration: `vexctl inspect` doesn't exist at
    v0.3.0; switched to `vexctl list` which loads and parses the document
    and exits non-zero on malformed input — the de facto schema gate.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * ci(vex): use `vexctl merge` for round-trip validation
    
    `vexctl list <arg>` parses <arg> as a selector (status / justification),
    not a file path. Switch to `vexctl merge --files <path>` which loads,
    parses, and re-emits the document — the canonical single-file parse
    gate at vexctl v0.3.x. A successful merge proves the input is valid
    OpenVEX; we additionally assert the output round-trips through serde
    as JSON.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * ci(vex): pass file path positionally to vexctl merge
    
    `vexctl merge` accepts file paths positionally, not via `--files`.
    Verified locally with vexctl v0.3.0:
    
        $ vexctl merge ./out.vex.json
        { ... merged document ... }
    
    Exit 0 with our generated doc confirms parse + schema validation
    against the OpenVEX 0.2.0 spec.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * test(vex): extensive unit + conformance tests, OpenVEX 0.2.0 schema completeness
    
    Drives line coverage of `socket-patch-core::vex` to ~99.7% (1846/1851
    lines covered) and locks down OpenVEX 0.2.0 spec conformance.
    
    Schema additions (all `Option<T>`, all `skip_serializing_if = "Option::is_none"`,
    no change to what the builder emits today):
    - `Document.role`, `Document.last_updated`
    - `Statement.@id`, `Statement.last_updated`, `Statement.supplier`,
      `Statement.action_statement`
    - `Product.identifiers`, `Product.hashes`
    - `Subcomponent.identifiers`, `Subcomponent.hashes`
    Backwards-compatible: existing docs round-trip unchanged; new fields
    appear only when callers set them.
    
    Test count went from 33 to 144 across the vex module (+111 tests):
    - schema.rs:  6 → 24 — every Status/Justification variant, all new
                  optional fields, missing-required-field rejection,
                  version typing, multi-aliases ordering.
    - build.rs:   7 → 17 — applied PURL not in manifest, zero-vuln patch,
                  empty CVE list, duplicate CVE dedup, tooling=None,
                  empty author, determinism, timestamp consistency,
                  subcomponent sort order.
    - product.rs: 22 → 53 — `[tool.poetry]` fallback, CRLF git config,
                  all URL scheme branches (git+ssh, git://, http://,
                  port-suffix, no-user), no-origin/empty-url config
                  fallbacks, multi-manifest combos beyond pkg+cargo,
                  non-string JSON name/version, missing-version-key,
                  parse_toml_kv negative cases, three-segment URL path,
                  trailing-slash normalization.
    - verify.rs:  5 → 11 — empty manifest, zero-file patch (vacuous),
                  extra package_paths ignored, multi-file short-circuit,
                  Default/Clone/Eq impls.
    - time.rs:    5 → 15 — non-leap Feb, year-end boundary, century
                  non-leap (2100), 400-year leap (2000), every
                  month-length transition, u64::MAX no-panic.
    - mod.rs:     0 → 1 — re-export smoke test (compile-time guard).
    - conformance_tests.rs (new): 17 cross-cutting tests pinning OpenVEX
                  spec rules — @context literal, JSON-LD @-prefixed keys,
                  status/justification interaction (action_statement
                  reserved for status=affected; not_affected requires
                  justification), required-field presence, non-empty
                  identifiers, timestamp consistency, version=1,
                  no-null invariant, alias/subcomponent uniqueness.
    
    Remaining 5 uncovered regions documented in-source as unreachable in
    practice (e.g. `civil_from_days` negative-`z` arm — requires inputs
    past year ~292 billion).
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    ---------
    
    Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    mikolalysenko and claude authored May 26, 2026
    Configuration menu
    Copy the full SHA
    8ee38a3 View commit details
    Browse the repository at this point in the history
  2. feat: telemetry coverage for read-side commands + paid-tier fallback …

    …(3.1.0) (#82)
    
    * feat(telemetry): gate on SOCKET_OFFLINE for airgap compliance
    
    is_telemetry_disabled() now returns true when SOCKET_OFFLINE is "1" or
    "true". Airgap mode promises "never contact the network"; the telemetry
    endpoint is a network call, so honoring SOCKET_OFFLINE here keeps every
    command (apply, remove, rollback — plus future scan/get/etc.) compliant
    without requiring per-command gating.
    
    Adds three integration tests in telemetry_helpers_e2e.rs and extends
    the existing test_is_telemetry_disabled unit test with the new branch
    (including "0" and "" non-truthy values).
    
    Assisted-by: Claude Code:opus-4-7
    
    * feat(telemetry): add event variants + trackers for read-side + housekeeping + vex
    
    Extends PatchTelemetryEventType with 12 new variants covering scan, get
    (emits patch_fetched / patch_fetch_failed for symmetry with the
    existing apply naming convention), list, repair, setup, unlock, and the
    new vex (OpenVEX) command. Adds matching convenience tracker functions
    that funnel through the existing track_patch_event send path — no new
    HTTP plumbing.
    
    The scan/get trackers carry a fallback_to_proxy flag so we can measure
    how often the auth endpoint downgrades to the public proxy once that
    fallback path lands.
    
    No call sites yet — wiring into each command file follows in subsequent
    commits so this commit stays a pure data-model addition.
    
    Assisted-by: Claude Code:opus-4-7
    
    * feat(telemetry): wire patch_* events into scan, get, list, setup, repair, unlock, vex
    
    Each command now fires a success/failure event through the existing
    track_patch_event send path. Concrete coverage:
    
    - list: patch_listed (count surfaced)
    - setup: patch_setup (detected package manager: npm/pnpm)
    - unlock: patch_unlocked (was_held + released metadata) + patch_unlock_failed
    - repair: patch_repaired (downloaded + cleaned counts) + patch_repair_failed
    - scan: patch_scanned (per-tier counts, can_access_paid, ecosystems,
      fallback_to_proxy=false placeholder) + patch_scan_failed when every
      batch errored (previously hidden as "zero patches found")
    - get (UUID path only for now): patch_fetched on success,
      patch_fetch_failed on paid_required / not_found / API error.
      CVE/GHSA/PURL search-error paths also surface patch_fetch_failed.
    - vex: vex_generated on success, vex_failed via a small async helper
      that wraps each emit_envelope_error call site.
    
    Renamed the unlock tracker's "broken" parameter to "released" — unlock
    never breaks a held lock (that's `--break-lock` on mutating subcommands);
    the bool actually describes whether the lock file was removed.
    
    No new HTTP plumbing; trackers reuse track_patch_event. Behavior preserved
    on existing apply/remove/rollback paths.
    
    Assisted-by: Claude Code:opus-4-7
    
    * test(scan): add lifecycle coverage for withdrawn patches and updates
    
    Three new cargo tests in scan_invariants.rs covering patch-management
    behaviors the existing matrix didn't pin down:
    
    - scan_prune_keeps_entry_when_package_installed_but_api_silent: a
      manifest entry must survive --prune when the underlying package is
      still installed locally but the API has fallen silent on patches
      for it. Pins the current --prune scope (crawl-absence, not
      API-absence) so a future regression to over-pruning is loud.
    
    - scan_prune_removes_withdrawn_patch_entry: when the underlying
      package is uninstalled (no longer in crawl results), --prune
      removes the manifest entry even with a stale blob still on disk.
      The blob is left for the existing repair-side GC to handle.
    
    - scan_detects_update_without_touching_existing_blobs: a newer UUID
      from the API surfaces in the `updates` array, but scan without
      --apply must leave the on-disk manifest and blobs byte-for-byte
      unchanged. Read-only invariant.
    
    Assisted-by: Claude Code:opus-4-7
    
    * test(telemetry): end-to-end behavioral coverage for apply/scan/get + airgap
    
    New tests/telemetry_e2e.rs spawns the released binary against a
    wiremock server that fronts both the patches endpoints AND the
    telemetry endpoint, then counts POSTs against
    /v0/orgs/{slug}/telemetry filtered by event_type.
    
    Coverage:
    - scan_emits_patch_scanned_telemetry_on_success
    - list_emits_patch_listed_telemetry_when_telemetry_enabled
    - get_emits_patch_fetched_telemetry_on_uuid_lookup_success
      (tolerates either fetched/fetch_failed — the apply step is allowed
       to fail in the test env; the invariant is that *some* event fires)
    - {apply,scan,get,list}_skips_telemetry_in_airgap_mode — confirms the
      central is_telemetry_disabled() gate suppresses everything when
      SOCKET_OFFLINE=1, regardless of command.
    
    Caught a real test-only bug along the way: send_telemetry_event reads
    SOCKET_API_URL from the *environment*, not from the clap --api-url
    arg. The test harness now sets both env + flag so the telemetry POST
    lands on the same mock recording the API requests.
    
    Assisted-by: Claude Code:opus-4-7
    
    * feat(api): auth → proxy fallback on 401/403 for scan + get
    
    Adds `build_proxy_fallback_client(&overrides)` + `is_fallback_candidate(&err)`
    in api/client.rs. The constructor builds a public-proxy-mode ApiClient
    from the same overrides used by `get_api_client_with_overrides`,
    deliberately dropping the auth token. The classifier flags 401/403
    errors as fallback-eligible; everything else (404, 5xx, network,
    rate-limit, parse) surfaces unchanged.
    
    `scan.rs` and `get.rs` (UUID path) catch the first such error from
    the authenticated endpoint, log a warning to stderr, rebuild the
    client, retry the same request once, and continue. A new
    `fallback_to_proxy` bool plumbed through to the existing telemetry
    trackers carries the incidence into observability.
    
    Behavior is deliberately conservative:
    - Read commands only — `apply`/`remove`/`rollback`/`vex` keep their
      pre-existing fail-loud-on-auth semantics.
    - 404, 5xx, network, parse errors do NOT trigger fallback; they
      surface as before so backend issues stay visible.
    - Free patches still resolve via the proxy; paid patches return the
      same "paid_required" structured error the no-token path already
      emits.
    
    Assisted-by: Claude Code:opus-4-7
    
    * test(fallback): cover auth → proxy downgrade and conservative classifier
    
    Two new tests in telemetry_e2e.rs:
    
    - scan_falls_back_to_proxy_on_401_and_tags_telemetry: stands up two
      mock servers (auth endpoint 401s, proxy endpoint succeeds), asserts
      scan exits 0 after the swap, the fallback warning hits stderr, and
      the resulting patch_scanned event carries fallback_to_proxy: true
      in metadata.
    
    - scan_does_not_fall_back_on_500: pins the conservative scope of the
      classifier. A 500 from the auth endpoint must NOT trigger the
      proxy retry — backend errors should stay visible. Asserts zero
      hits against the proxy mock and no fallback warning on stderr.
    
    Assisted-by: Claude Code:opus-4-7
    
    * chore(release): bump to 3.1.0
    
    Workspace Cargo.toml, all npm wrapper + per-platform packages, and
    PyPI pyproject.toml synced via scripts/version-sync.sh (with manual
    fixup for the per-platform packages since npm install couldn't
    process the workspace catalog: protocol).
    
    CHANGELOG entry covers: telemetry events across the read-side and
    housekeeping commands, the 401/403 auth → public-proxy fallback in
    scan/get, the SOCKET_OFFLINE airgap gate, and the new behavioral +
    lifecycle test coverage that backs all of it.
    
    Assisted-by: Claude Code:opus-4-7
    
    * fix(clippy): allow too_many_arguments on track_patch_scanned
    
    cargo clippy --workspace --all-features -- -D warnings flagged
    track_patch_scanned at 8/7 args. Grouping the per-tier counts +
    ecosystems list + fallback flag + auth tuple into a struct would
    force every call site to build a config object for a single
    fire-and-forget tracker — worse ergonomics. Annotating the lint is
    the right call; `track_patch_event` already exists for callers that
    want full control.
    
    Assisted-by: Claude Code:opus-4-7
    mikolalysenko authored May 26, 2026
    Configuration menu
    Copy the full SHA
    1493421 View commit details
    Browse the repository at this point in the history

Commits on May 27, 2026

  1. Configuration menu
    Copy the full SHA
    e3ab57d View commit details
    Browse the repository at this point in the history
  2. ci(release): tag after build + fix rustup toolchain setup (#85)

    Tag now runs after build succeeds so a failed build no longer leaves
    behind a dangling tag. All publish jobs depend on tag, so we never
    half-publish without one.
    
    Replaces dtolnay/rust-toolchain (which can't auto-detect the channel
    when pinned by SHA) with direct rustup invocation, matching the pattern
    already used in ci.yml.
    
    Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    mikolalysenko and claude authored May 27, 2026
    Configuration menu
    Copy the full SHA
    dde67db View commit details
    Browse the repository at this point in the history
  3. ci(release): pin newer cross NDK image for aarch64-linux-android (#86)

    The default image bundled with cross 0.2.5 ships an NDK whose sysroot
    lacks libunwind on the linker path. Modern rustc emits `-lunwind` for
    Android targets, so the release build fails with
    `ld: cannot find -lunwind`.
    
    Override just the Android target to a digest-pinned cross main image,
    which ships an NDK that has libunwind available. All other targets
    keep their default cross 0.2.5 images.
    
    Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    mikolalysenko and claude authored May 27, 2026
    Configuration menu
    Copy the full SHA
    c20619b View commit details
    Browse the repository at this point in the history
Loading