Skip to content

feat(scan): unified auto-update engine — --sync, --prune, --dry-run (v3.0)#79

Merged
Mikola Lysenko (mikolalysenko) merged 42 commits into
mainfrom
feat/scan-apply-json
May 22, 2026
Merged

feat(scan): unified auto-update engine — --sync, --prune, --dry-run (v3.0)#79
Mikola Lysenko (mikolalysenko) merged 42 commits into
mainfrom
feat/scan-apply-json

Conversation

@mikolalysenko
Copy link
Copy Markdown
Contributor

@mikolalysenko Mikola Lysenko (mikolalysenko) commented May 20, 2026

Summary

Makes socket-patch scan the single-command auto-update engine for bots / cron jobs / CI workflows. MAJOR bump 2.1.4 → 3.0.0 driven by new flags + the per-patch "updated" action vocabulary.

socket-patch scan --json --sync --yes | jq '{
  applied:     [.apply.patches[] | select(.action == "added" or .action == "updated") | .purl],
  pruned:      .gc.prunedManifestEntries,
  bytes_freed: .gc.bytesFreed
}'
# Pipe into peter-evans/create-pull-request to open a PR with the changes.

What this PR adds

Discovery + apply (existing):

  • --apply flag opts JSON mode into the full discover → select → apply pipeline.
  • Top-level updates array always lists PURLs whose UUID would change vs the local manifest.
  • Per-patch action vocabulary: "added", "updated" (with oldUuid), "skipped", "failed".
  • decide_patch_action / detect_updates pure helpers + 12 unit tests.

Opt-in GC (new in v3.0):

  • --prune opts into garbage collection: removes manifest entries for uninstalled packages, sweeps orphan blob/diff/package-archive files. Off by default to preserve manifest state across temporary uninstalls.
  • --sync is sugar for --apply --prune — the canonical single-flag bot invocation.
  • --dry-run / -d previews --apply / --prune / --sync actions without mutating disk. Emits apply.patches[*] populated via decide_patch_action and gc.prunable* / gc.orphan* via the cleanup helpers' built-in dry-run mode.
  • detect_prunable, GcSummary, run_apply_gc, preview_apply_gc helpers + 5 unit tests.

repair / gc remain first-class:

  • The gc visible alias on repair stays. Both appear in socket-patch --help.
  • Use repair / gc for cleanup-without-apply; use scan --sync for the combined workflow.
  • 3 contract tests guard the visible_alias from regressing.

e2e tests + CI:

  • New tests/e2e_scan.rs with 11 #[ignore] scenarios against the real Socket API (mirrors tests/e2e_npm.rs). Covers added/updated/skipped, default-no-prune, --prune flag, orphan blob cleanup, --sync full lifecycle, --dry-run non-mutation, GC field omission contract.
  • CI matrix gains e2e_scan slots on ubuntu + macos.

Workspace v3.0.0:

  • Cargo.toml bumped 2.1.4 → 3.0.0; scripts/version-sync.sh 3.0.0 propagated to all 16 npm wrappers + pypi.
  • Drive-by Windows path-traversal fix in socket-patch-core (PR feat(patch): add package- and diff-level patch sources #67 had a latent gap exposed when CI re-enabled).
  • CI tightened: macOS added to test matrix, test-release job added, explicit cargo build step before tests.

Breaking changes (MAJOR)

  • scan --apply per-patch JSON: "action": "added" may now be "action": "updated" with an oldUuid field when the PURL already had a different UUID. Scripts that hard-coded action == "added" break.
  • repair/gc is no longer needed for the combined workflow — bots should switch to scan --sync. Both repair and gc still work for cleanup-only invocations.

How a bot uses this

socket-patch scan --json --sync --yes > scan-result.json

Exit code: 0 on full success, 1 if any apply.patches[*].action == "failed" (top-level status becomes "partial_failure"). A bot can pipe the JSON through jq and into peter-evans/create-pull-request (or its equivalent) with a summary of what changed.

Tests (all green)

  • cargo build --workspace --all-features
  • cargo clippy --workspace --all-features -- -D warnings
  • cargo test --workspace --all-features
    • 416 lib tests in core
    • 100 lib tests in CLI (severity_order, detect_updates, detect_prunable, decide_patch_action)
    • 30 in cli_parse_scan (parser snapshot + subprocess JSON-shape)
    • 18 in cli_parse_repair (parser + visible-alias contract)
    • 11 in tests/e2e_scan.rs (#[ignore], run with --ignored)

Test plan

  • cargo build/clippy/test --workspace --all-features
  • Smoke: socket-patch --help shows repair [aliases: gc]
  • Smoke: socket-patch scan --help documents --apply, --prune, --sync, -d/--dry-run
  • CI: e2e_scan matrix passes on ubuntu + macos (real Socket API)
  • Reviewer to confirm v3.0 semver bump is appropriate

Assisted-by: Claude Code:claude-opus-4-7

…flows

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
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
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
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
@mikolalysenko Mikola Lysenko (mikolalysenko) changed the title feat(scan): add --apply + structured updates for auto-update bot workflows feat(scan)!: unified GC + auto-update engine (v3.0) May 20, 2026
@socket-security-staging
Copy link
Copy Markdown

socket-security-staging Bot commented May 20, 2026

@socket-security
Copy link
Copy Markdown

socket-security Bot commented May 20, 2026

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
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
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
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
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
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
@mikolalysenko Mikola Lysenko (mikolalysenko) changed the title feat(scan)!: unified GC + auto-update engine (v3.0) feat(scan): unified auto-update engine — --sync, --prune, --dry-run (v3.0) May 20, 2026
- 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
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).
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.
* 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.
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.
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.
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.
`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.
`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.
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.
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.
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.
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.
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
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.
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.
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.
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).
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
… 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
…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
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
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
`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
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
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
…rity, 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
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
`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
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
`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
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 Mikola Lysenko (mikolalysenko) merged commit 9b7b5c2 into main May 22, 2026
42 checks passed
@mikolalysenko Mikola Lysenko (mikolalysenko) deleted the feat/scan-apply-json branch May 22, 2026 13:15
Mikola Lysenko (mikolalysenko) added a commit that referenced this pull request May 22, 2026
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
Mikola Lysenko (mikolalysenko) added a commit that referenced this pull request May 22, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants