diff --git a/Cargo.lock b/Cargo.lock index b2e66a1ef..aada9fe91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -155,27 +155,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" -[[package]] -name = "bzip2" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" -dependencies = [ - "libbz2-rs-sys", -] - [[package]] name = "cc" version = "1.2.56" @@ -186,16 +171,6 @@ dependencies = [ "shlex", ] -[[package]] -name = "cdivsufsort" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edefce019197609da416762da75bb000bbd2224b2d89a7e722c2296cbff79b8c" -dependencies = [ - "cc", - "sacabase", -] - [[package]] name = "cfg-if" version = "1.0.4" @@ -1053,12 +1028,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" -[[package]] -name = "libbz2-rs-sys" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fc329e1457d97a9d58a4e2ca49e3be572431a7e096008efc2e3a3c19d428f4" - [[package]] name = "libc" version = "0.2.182" @@ -1339,18 +1308,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "qbsdiff" -version = "1.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdc7f24528be166f08f2c7becaca5618865499b6ded2565d5afcd795cc0d7596" -dependencies = [ - "byteorder", - "bzip2", - "rayon", - "suffix_array", -] - [[package]] name = "quinn" version = "0.11.9" @@ -1624,15 +1581,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "sacabase" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9883fc3d6ce3d78bb54d908602f8bc1f7b5f983afe601dabe083009d86267a84" -dependencies = [ - "num-traits", -] - [[package]] name = "same-file" version = "1.0.6" @@ -1907,7 +1855,6 @@ dependencies = [ "hex", "libc", "portable-pty", - "qbsdiff", "regex", "reqwest", "semver", @@ -1940,7 +1887,6 @@ dependencies = [ "ignore", "libc", "once_cell", - "qbsdiff", "rayon", "regex", "reqwest", @@ -2008,15 +1954,6 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "suffix_array" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "907d9ca9637a22e3a7d7c7818f6105a7898857359e187ad3325d986684b9ec3f" -dependencies = [ - "cdivsufsort", -] - [[package]] name = "syn" version = "2.0.117" diff --git a/Cargo.toml b/Cargo.toml index 68f7fa37f..446610cf4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,7 +42,6 @@ aho-corasick = "=1.1.4" glob = "=0.3.4" toml_edit = "=0.25.12" once_cell = "=1.21.3" -qbsdiff = "=1.4.4" rayon = "=1.12.0" tar = "=0.4.46" flate2 = "=1.1.9" @@ -96,7 +95,7 @@ inherits = "release" lto = "thin" # Test-execution speed: `cargo test` builds dependencies with the dev -# profile; at opt-level 0 the hash/compression/bsdiff hot loops are 10-100x +# profile; at opt-level 0 the hash/compression hot loops are 10-100x # slower (the self_update fixtures gzip and sha256-hash the multi-MB debug # CLI binary per test: ~403s debug vs ~2s release). Workspace members are NOT matched by "*" — they stay # opt-level 0, so incremental compile speed, debugging, and llvm-cov line @@ -107,7 +106,7 @@ opt-level = 1 # The measured hot path gets full optimization: archive building # (flate2/zip over multi-MB payloads), SHA hashing in fixtures and in the -# CLI's own verify paths, and qbsdiff bspatch in every apply test. +# CLI's own verify paths. [profile.dev.package.sha2] opt-level = 3 [profile.dev.package.sha1] @@ -132,14 +131,6 @@ opt-level = 3 opt-level = 3 [profile.dev.package.zip] opt-level = 3 -[profile.dev.package.qbsdiff] -opt-level = 3 -[profile.dev.package.bzip2] -opt-level = 3 -[profile.dev.package.libbz2-rs-sys] -opt-level = 3 -[profile.dev.package.suffix_array] -opt-level = 3 # macOS debug builds use unpacked split-debuginfo: every linked binary — # including each of the ~90 e2e test executables — pins its per-codegen-unit diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 863dbcbca..b777ba61a 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -55,7 +55,6 @@ Every subcommand accepts the same set of "global" flags via a single shared `Glo | `--org` | `-o` | `SOCKET_ORG_SLUG` | (auto-resolve) | string | Org slug. Resolved once per run from the token (`GET /v0/organizations`) when omitted; if that fails, the whole run uses the public proxy anonymously (free patches only) and warns. | | `--proxy-url` | — | `SOCKET_PROXY_URL` | `https://patches-api.socket.dev` | string | Public proxy when no token | | `--ecosystems` | `-e` | `SOCKET_ECOSYSTEMS` | (all) | CSV → `Vec` | Restrict to these ecosystems | -| `--download-mode` | — | `SOCKET_DOWNLOAD_MODE` | **`diff`** | enum: `diff` \| `file` (`package` was removed and is rejected) | Patch artifact format | | `--vendor-source` | — | `SOCKET_VENDOR_SOURCE` | **`service`** | enum: `service` \| `auto` (alias) | How `vendor` acquires the installable artifact (see "Prebuilt vendor artifacts") | | `--maven-config` | — | — | (recorded choice, else `auto`) | enum: `auto` \| `none` | Maven reactor vendoring: write the repository tail (`auto`) or use only the fallback file repository (`none`). The choice persists in the vendor ledger. | | `--vendor-url` | — | `SOCKET_VENDOR_URL` | (active API/proxy base) | string | Base host for the vendoring-service package-reference request | @@ -76,9 +75,11 @@ Every subcommand accepts the same set of "global" flags via a single shared `Glo | `--no-npm-allow-remote-config` | — | `SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG` | `false` | bool | Opt out of hosted mode's automatic `allow-remote=all` write to the project `.npmrc` (see the npm allow-remote note under the scan arguments). Read by `scan --mode hosted` and `get --mode hosted`; other subcommands accept it silently | | `--no-vlt-install-cleanup` | — | `SOCKET_NO_VLT_INSTALL_CLEANUP` | `false` | bool | Opt out of hosted mode's warm-tree heal for vlt: stale installed copies (`node_modules/.vlt-lock.json` and the stale `node_modules/.vlt/` entries) are left in place after `vlt-lock.json` is repointed (`scan`/`get --mode hosted`) or restored (`rollback`/`remove`), and the `redirect_vlt_reinstall_required` advisory tells you to run `vlt ci` instead. Stale copies of optional dependencies are always left in place (see `redirect_vlt_reinstall_required`). Other subcommands accept it silently | +**`--download-mode` removed (v5.0, MAJOR).** The global `--download-mode` flag and its `SOCKET_DOWNLOAD_MODE` binding are gone, with no alias: passing the flag is a usage error (exit 2) and the env var is ignored. Patch content is always fetched as per-file blobs (`.socket/blobs/`); `.socket/diffs/` archives are obsolete, never read, and removed by the cleanup sweeps. + `--offline` means the same thing on every command (v3.0): never contact the network, fail loudly when a required local source is missing. On `repair`, `--offline` and `--download-only` are mutually exclusive (exit 2). `scan` and `get` need remote data for their core function (patch discovery / patch fetch), so `--offline` refuses them up front — exit 1 with an error naming the offline gate (JSON: `status: "error"`), before any crawl, client build, or network contact. This covers `scan --mode vendored` too: offline vendored staging is `vendor --offline`'s job. -The `--strict` mismatch policy applies to the in-place apply paths (apply/get/scan --mode agent/hook/go redirect). DEFAULT (v3.4): a file whose on-disk content matches neither the patch's beforeHash nor its afterHash is overwritten with the FULL verified patched content (the diff strategy self-disables on a wrong base; archive/blob writes are hash-gated to exactly afterHash; the missing blob is downloaded on demand) and surfaced as a `content_mismatch_overwritten` stderr warning + Skipped event (agent-mode `get` / `scan --json`: a `warnings[]` entry, see "Agent-mode mismatch overwrites"). A file the patch adds (empty beforeHash) that already exists with other content is the same case. `--strict` turns that case into a hard error. Rollback of an added file deletes it; the content it replaced is not kept. `--force` overrides `--strict` and additionally skips missing files. Vendor staging is unaffected (it always auto-overwrites into its private stage). +The `--strict` mismatch policy applies to the in-place apply paths (apply/get/scan --mode agent/hook/go redirect). DEFAULT (v3.4): a file whose on-disk content matches neither the patch's beforeHash nor its afterHash is overwritten with the FULL verified patched content (blob writes are hash-gated to exactly afterHash; the missing blob is downloaded on demand) and surfaced as a `content_mismatch_overwritten` stderr warning + Skipped event (agent-mode `get` / `scan --json`: a `warnings[]` entry, see "Agent-mode mismatch overwrites"). A file the patch adds (empty beforeHash) that already exists with other content is the same case. `--strict` turns that case into a hard error. Rollback of an added file deletes it; the content it replaced is not kept. `--force` overrides `--strict` and additionally skips missing files. Vendor staging is unaffected (it always auto-overwrites into its private stage). ## Per-subcommand arguments @@ -144,7 +145,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Agent-flow run-level warnings (additive).** An agent-mode apply (`--mode agent` / `--sync`, `--json`) may add a top-level `warnings[]` array of `{code, detail}` entries to the scan envelope (absent when none fired; each is also mirrored to stderr unless `--silent`). They surface cross-mode state the apply cannot change — never a status or exit-code change (hosted refusals set the precedent: exit 0 + warning). Codes (stable; new codes are additive/MINOR): `vendored_ownership_retained` — vendor-owned package(s) were skipped before download (the per-patch `skipped`/`vendored` records in `apply.patches[]` are unchanged); the detail names the purls and the migration path (`remove `, or `vendor --revert` which unwinds every vendored package, then re-run). `hosted_wiring_retained` — the lockfiles still pin scanned package(s) to a hosted patch (the agent run does not unwind hosted wiring — as of v5.0 that is `socket-patch rollback`'s job, which restores the upstream registry entries, or `remove ` per package); the detail names the purls and the options (stay `--mode hosted`, migrate via `scan --mode vendored`, or `socket-patch rollback`). The warning keys on the hosted pins lockfile discovery finds at scan time, so a flow that restored the upstream entries retires it. The human path prints the same `hosted_wiring_retained` text to stderr after an apply; the vendored counterpart is already covered by its per-package `[skip] … (vendored …)` lines. `ownership_not_restored` (v5.0; `apply` and `rollback` `warnings[]` alike) — a file WAS patched (or restored) but its ownership could not be put back to the original uid/gid (the mode is still restored last); the detail is `: : patched, but ownership could not be restored to uid N gid M: ` and the human line `Warning: ` (stderr, muted by `--silent`); never a status or exit change. -`scan --prune` opts into garbage collection. When set, `scan` removes manifest entries for packages no longer present in the crawl, then deletes orphan blob and diff-archive files, and every legacy package archive, from `.socket/`. Off by default (v3.0) so a temporary uninstall doesn't silently destroy manifest state. Only entries whose ecosystem this run actually crawled are eligible: a `pkg:/` with no crawler in this build (a newer CLI's ecosystem in the committed manifest) is exempt — the crawl never looked for them, so their absence is not evidence of removal (same fail-safe as the `--ecosystems` filter, which narrows the query but never the prune's installed set). The pass also reconciles vendored state (runs FIRST, under ONE apply-lock acquisition shared with the manifest prune — lock contention skips the whole pass without failing the scan; `--lock-timeout` is honored and a lock I/O error is reported rather than swallowed; the existence gate — a manifest file OR a vendor ledger file, both cheap stats; an emptied ledger is deleted on save, so its presence is its content proxy — runs BEFORE the lock, so a bare project never gets a `.socket/`; in the vendored scan arms the pass runs AFTER the vendor step): (a) ledger entries still tracked by a manifest record (manifest-mode entries written by standalone `vendor`) whose patch is gone from the manifest are reverted — `detached` entries (every `scan`/`get --mode vendored` entry, v5.0) have no manifest record to lose and are exempt from this leg; (b) EVERY ledger entry whose dependency is no longer in the lockfile graph is reverted and any manifest entry it still had dropped (v5.0: the check is about the lockfile, not the manifest, so embedded-record entries are no longer exempt; a missing or undeterminable lockfile keeps the entry, fail-safe); and (c) orphan `.socket/vendor//` dirs with no ledger entry are swept. The prune never deletes a zero-patch `.socket/manifest.json` (its `{"patches": {}}` + `setup` block stay). The JSON `gc` sub-object gains `revertedVendoredEntries` + `keptVendoredEntries` + `failedVendoredEntries` + `removedVendorOrphanDirs` (wet) / `revertableVendoredEntries` + `vendorOrphanDirs` (preview), plus two ADDITIVE wet-only keys: `skipped: {code, message}` — present exactly when the pass was skipped at the lock (`lock_held` | `lock_io`; every count is then zero) — and `warnings: [{code, detail}]` — `vendor_state_write_failed` / `manifest_write_failed` (entries were reverted but the ledger or manifest rewrite failed), `cleanup_failed` (an orphan sweep failed mid-way), and the reinstall advisories of the vendored reverts (`vendor_bun_reinstall_required`, `vendor_vlt_reinstall_required`; a revert's other warnings, such as `vendor_lock_entry_removed` or a drift keep's, are not repeated here). Human mode prints `GC: skipped (): .`, one `GC: .` line per warning, and `GC: failed to revert N vendored entries: …` (singular for one) for `failedVendoredEntries`. `keptVendoredEntries` lists drift-kept entries the revert deliberately preserved (`vendor_artifact_kept` — undo the drift and re-run `vendor --revert` to finish); the preview cannot see drift (backends return before the wiring replay on dry runs), so `revertableVendoredEntries` may over-promise what a wet run will actually reclaim. +`scan --prune` opts into garbage collection. When set, `scan` removes manifest entries for packages no longer present in the crawl, then deletes orphan blob files, and every obsolete diff and package archive, from `.socket/`. Off by default (v3.0) so a temporary uninstall doesn't silently destroy manifest state. Only entries whose ecosystem this run actually crawled are eligible: a `pkg:/` with no crawler in this build (a newer CLI's ecosystem in the committed manifest) is exempt — the crawl never looked for them, so their absence is not evidence of removal (same fail-safe as the `--ecosystems` filter, which narrows the query but never the prune's installed set). The pass also reconciles vendored state (runs FIRST, under ONE apply-lock acquisition shared with the manifest prune — lock contention skips the whole pass without failing the scan; `--lock-timeout` is honored and a lock I/O error is reported rather than swallowed; the existence gate — a manifest file OR a vendor ledger file, both cheap stats; an emptied ledger is deleted on save, so its presence is its content proxy — runs BEFORE the lock, so a bare project never gets a `.socket/`; in the vendored scan arms the pass runs AFTER the vendor step): (a) ledger entries still tracked by a manifest record (manifest-mode entries written by standalone `vendor`) whose patch is gone from the manifest are reverted — `detached` entries (every `scan`/`get --mode vendored` entry, v5.0) have no manifest record to lose and are exempt from this leg; (b) EVERY ledger entry whose dependency is no longer in the lockfile graph is reverted and any manifest entry it still had dropped (v5.0: the check is about the lockfile, not the manifest, so embedded-record entries are no longer exempt; a missing or undeterminable lockfile keeps the entry, fail-safe); and (c) orphan `.socket/vendor//` dirs with no ledger entry are swept. The prune never deletes a zero-patch `.socket/manifest.json` (its `{"patches": {}}` + `setup` block stay). The JSON `gc` sub-object gains `revertedVendoredEntries` + `keptVendoredEntries` + `failedVendoredEntries` + `removedVendorOrphanDirs` (wet) / `revertableVendoredEntries` + `vendorOrphanDirs` (preview), plus two ADDITIVE wet-only keys: `skipped: {code, message}` — present exactly when the pass was skipped at the lock (`lock_held` | `lock_io`; every count is then zero) — and `warnings: [{code, detail}]` — `vendor_state_write_failed` / `manifest_write_failed` (entries were reverted but the ledger or manifest rewrite failed), `cleanup_failed` (an orphan sweep failed mid-way), and the reinstall advisories of the vendored reverts (`vendor_bun_reinstall_required`, `vendor_vlt_reinstall_required`; a revert's other warnings, such as `vendor_lock_entry_removed` or a drift keep's, are not repeated here). Human mode prints `GC: skipped (): .`, one `GC: .` line per warning, and `GC: failed to revert N vendored entries: …` (singular for one) for `failedVendoredEntries`. `keptVendoredEntries` lists drift-kept entries the revert deliberately preserved (`vendor_artifact_kept` — undo the drift and re-run `vendor --revert` to finish); the preview cannot see drift (backends return before the wiring replay on dry runs), so `revertableVendoredEntries` may over-promise what a wet run will actually reclaim. `scan` queries the patch API in `--batch-size` chunks. Authenticated runs POST `/v0/orgs/{slug}/patches/batch`; token-less runs POST `{proxy}/patch/batch` on the public proxy and degrade to per-package `GET /patch/by-package/:purl` requests in two cases: the deployed proxy predates the batch endpoint (legacy proxies answer the POST with their `400 "Unsupported endpoint"` catch-all), or the all-or-nothing batch validation rejects the chunk (e.g. a crawled PURL type the server doesn't recognize, such as `pkg:jsr/…` — the per-package path tolerates those individually, preserving the pre-batch scan semantics). Rate limits and over-capacity 503s surface instead of silently degrading. @@ -684,7 +685,7 @@ path. Without hosted pins the no-manifest no-op below is unchanged. Coverage includes npm (all lock flavors), Python wheels and source distributions, Cargo crates, Go module zips, Composer dist zips, RubyGems, NuGet packages and JVM jars. Directory artifacts are extracted and receive only the existing package-manager layout transformations. RubyGems requires the server's separately verified `gem-stub-gemspec`; a missing or invalid stub is a failure. Yarn Berry checksums come from server metadata. Hosted Berry rollback retrieves upstream checksum metadata from `/upstream/npm/.json` and checks its package identity and upstream integrity against the registry; the CLI does not recreate the Berry zip. -`--download-mode` controls agent patch content only. Vendored runs retain patch records in memory and embed them in the vendor ledger, without staging blobs or diffs. N-API and the hosted in-memory engine remain supported for callers such as the future GitHub App. +Agent runs fetch patch content as per-file blobs (v5.0 removed `--download-mode` and the diff archive path). Vendored runs retain patch records in memory and embed them in the vendor ledger, without staging blobs. N-API and the hosted in-memory engine remain supported for callers such as the future GitHub App. **Vendored artifact repair (v5.0)**: `repair` checks the committed artifact and downloads a replacement for the same UUID into a temporary location. Before replacement it checks transfer integrity, patched-member hashes, and the original ledger's SHA-256 and size (file artifacts) or complete file inventory (directory artifacts). JVM repair also reproduces and checks the recorded repository metadata. Different downloaded bytes or inventories are refused; the old files, ledger and lockfiles remain intact. No installed package or patch blobs are required, and none are used to construct a replacement. Missing directory inventories cannot establish an exact replacement and require explicit re-vendoring. @@ -856,8 +857,8 @@ worse, lets a warm cache silently serve unpatched bytes): 0) — NOT `not_found`, which stays reserved for identifier-matches-nothing. `remove`'s default GC also extends (v5.0, additive) from blobs-only to blobs + diff archives + package archives (parity with rollback/repair/`scan --prune`; GC errors warn and continue, repair's posture). - Package archives (`.socket/packages/`) are legacy in v5.0: nothing writes or reads them, so - every GC sweep removes the whole directory. + Diff archives (`.socket/diffs/`) and package archives (`.socket/packages/`) are obsolete in + v5.0: nothing writes or reads them, so every GC sweep removes both directories whole. Like `rollback`, `remove` retains the original blobs for every patch left in the manifest and for removed-but-not-installed patches, so removing one patch preserves offline rollback of other active patches. Only blobs no longer referenced by that keep set are collected. @@ -1065,7 +1066,6 @@ Empty string means unset at every layer: exported-but-empty flag-bound vars are | `SOCKET_ORG_SLUG` | `--org` / `-o` | (auto-resolve) | Resolved once per run from the token (`GET /v0/organizations`) when omitted; if that fails, the whole run uses the public proxy anonymously (free patches only) and warns. | | `SOCKET_PROXY_URL` | `--proxy-url` | `https://patches-api.socket.dev` | — | | `SOCKET_ECOSYSTEMS` | `--ecosystems` / `-e` | (all) | Comma-separated list. | -| `SOCKET_DOWNLOAD_MODE` | `--download-mode` | `diff` | One of `diff` / `file`. | | `SOCKET_VENDOR_SOURCE` | `--vendor-source` | `service` | `auto` is a compatibility alias for `service`; `build` is rejected. | | `SOCKET_VENDOR_URL` | `--vendor-url` | (active API/proxy base) | Vendoring-service package-reference host. | | `SOCKET_PATCH_SERVER_URL` | `--patch-server-url` | (server-returned) | Rewrites the prebuilt-archive download host. | @@ -1218,7 +1218,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified { "path": "package/index.js", "verified": true, - "appliedVia": "diff" | "blob" // only on action=applied; v5.0 drops "package" + "appliedVia": "blob" // only on action=applied; v5.0 drops "package" and "diff" } ], "bytes": 1234, // optional (downloaded/removed) @@ -1459,7 +1459,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `apply` | `Applied` · `Updated` · `Skipped` (already_patched / package_not_installed / vendored) · `Failed` · `Verified` (dry-run) | | `vendor` | `Applied` (= vendored; `command` routes) · `Skipped` (refusals, warnings, unsupported ecosystems) · `Failed` · `Removed` (reconcile + `--revert`) · `Verified` (dry-run) | | `list` | `Discovered` (with `details.vulnerabilities`, `details.tier`, `details.license`, `details.description`, `details.exportedAt`; hosted pins (v5.0: one per `(purl, uuid)` the lockfiles wire) additionally carry `details.mode: "hosted"` and `details.lockfiles: []` (no `details.ledger` — hosted mode keeps no ledger; the human listing labels them `Mode: hosted (wired in )`), both additive and absent on manifest entries; v5.0: vendor-ledger records carry `details.mode: "vendored"` + `details.ledger: ".socket/vendor/state.json"` the same way, and the human listing labels them `Mode: vendored (recorded in .socket/vendor/state.json)`; a `state.json` that cannot be read or parsed degrades to nothing-to-consult with the stderr line `Warning: unreadable vendor ledger (); its vendored patches are not listed` — muted by `--silent`, exit unchanged) | -| `repair` | `Downloaded` (or `Verified` on dry-run; a diff-mode repair adds a second one, `mode: "file"`, for the blobs of files the patches create) · `Rebuilt` (vendored artifacts; `Verified` previews on dry-run) · `Skipped` (vendor_uuid_mismatch) · `Removed` (or `Verified`) · `Failed` events | +| `repair` | `Downloaded` (or `Verified` on dry-run; `details: {count, mode: "file"}` — `mode` is always `"file"` since v5.0 removed the diff download path) · `Rebuilt` (vendored artifacts; `Verified` previews on dry-run) · `Skipped` (vendor_uuid_mismatch) · `Removed` (or `Verified`) · `Failed` events | | `remove` | `Removed` (per purl; `Verified` on dry-run) · artifact-level `Removed`/`Verified` event (with `details.blobsRemoved`, `details.rolledBack`) | | `--update` | `Downloaded` → `Updated` (success) · `Skipped` (already_latest) · `Verified` (dry-run check, reason update_check) — see the Self-update contract section for details fields and top-level error codes | @@ -1793,7 +1793,7 @@ Versioning lives in **`Cargo.toml`** at the workspace root (`version = "..."`) a | Rename or remove a subcommand | **MAJOR** | | Rename or remove a visible or hidden alias | **MAJOR** | | Rename, remove, or change short form of a flag (`-d`, `-m`, etc.) | **MAJOR** | -| Change a default value (`--download-mode`, `--batch-size`, `--manifest-path`, …) | **MAJOR** | +| Change a default value (`--vendor-source`, `--batch-size`, `--manifest-path`, …) | **MAJOR** | | Change an exit code's meaning or add a new non-zero code with different semantics | **MAJOR** | | Rename a JSON output key or change a `status` string | **MAJOR** | | Remove a JSON output key | **MAJOR** | diff --git a/crates/socket-patch-cli/Cargo.toml b/crates/socket-patch-cli/Cargo.toml index 2c3e35cc8..690505589 100644 --- a/crates/socket-patch-cli/Cargo.toml +++ b/crates/socket-patch-cli/Cargo.toml @@ -64,8 +64,6 @@ sha1 = { workspace = true } # scan_vendor_e2e builds pristine registry tarballs for the auto-fetch tests. tar = { workspace = true } flate2 = { workspace = true } -# diff_created_file_e2e builds a real diff archive. -qbsdiff = { workspace = true } # update_fixture builds the Windows-shaped release archive for self-update e2e. zip = { workspace = true } hex = { workspace = true } diff --git a/crates/socket-patch-cli/src/args.rs b/crates/socket-patch-cli/src/args.rs index 5e0620969..62dc7b5f2 100644 --- a/crates/socket-patch-cli/src/args.rs +++ b/crates/socket-patch-cli/src/args.rs @@ -155,17 +155,6 @@ pub struct GlobalArgs { )] pub ecosystems: Option>, - /// Which kind of patch artifact to download when local files are missing. - /// `diff` (default) fetches the smallest delta archive; `file` falls back - /// to legacy per-file blobs. - #[arg( - help_heading = GLOBAL_OPTIONS, - long = "download-mode", - env = "SOCKET_DOWNLOAD_MODE", - default_value = "diff" - )] - pub download_mode: String, - /// Download installable patched artifacts from the patch service. /// `service` is the default; `auto` is a compatibility alias. Local /// artifact building is no longer supported. Healthy committed artifacts @@ -531,8 +520,9 @@ impl GlobalArgs { } } - /// The directory the manifest lives in — where `apply.lock`, `blobs/`, - /// `diffs/` and `packages/` sit (`/.socket` by default). The one + /// The directory the manifest lives in — where `apply.lock` and `blobs/` + /// sit, and the obsolete `diffs/` and `packages/` the cleanup sweeps + /// remove (`/.socket` by default). The one /// derivation every lock acquire and artifact probe uses; see /// [`socket_dir_of`] for callers holding a raw manifest path. pub(crate) fn socket_dir(&self) -> PathBuf { @@ -669,7 +659,6 @@ pub const GLOBAL_ARG_ENV_VARS: &[&str] = &[ "SOCKET_ORG_SLUG", "SOCKET_PROXY_URL", "SOCKET_ECOSYSTEMS", - "SOCKET_DOWNLOAD_MODE", "SOCKET_VENDOR_SOURCE", "SOCKET_VENDOR_URL", "SOCKET_PATCH_SERVER_URL", @@ -723,7 +712,7 @@ pub const LOCAL_ARG_ENV_VARS: &[&str] = &[ /// ("a value is required"), `SOCKET_LOCK_TIMEOUT` / `SOCKET_BATCH_SIZE` /// ("cannot parse integer from empty string") and `SOCKET_ECOSYSTEMS` (the /// per-token validator) outright — a single stray blank var crashed every -/// subcommand — and an empty `SOCKET_DOWNLOAD_MODE` / `SOCKET_MANIFEST_PATH` +/// subcommand — and an empty `SOCKET_MANIFEST_PATH` /// (or `SOCKET_VEX_OUTPUT`, which would silently target `""`) leaked `""` /// past the documented defaults. Called from `main` after peer-alias /// promotion and before clap runs. Only exactly-empty values are scrubbed; @@ -760,7 +749,6 @@ impl Default for GlobalArgs { org: None, proxy_url: None, ecosystems: None, - download_mode: "diff".to_string(), vendor_source: "service".to_string(), maven_config: None, vendor_url: None, @@ -987,7 +975,6 @@ mod tests { std::env::set_var("SOCKET_LOCK_TIMEOUT", ""); std::env::set_var("SOCKET_GLOBAL_PREFIX", ""); std::env::set_var("SOCKET_ECOSYSTEMS", ""); - std::env::set_var("SOCKET_DOWNLOAD_MODE", ""); std::env::set_var("SOCKET_VENDOR_SOURCE", ""); std::env::set_var("SOCKET_BATCH_SIZE", ""); std::env::set_var("SOCKET_VEX_OUTPUT", ""); @@ -1023,7 +1010,6 @@ mod tests { assert_eq!(cli.common.lock_timeout, None); assert!(cli.common.global_prefix.is_none()); assert!(cli.common.ecosystems.is_none()); - assert_eq!(cli.common.download_mode, "diff"); assert_eq!( cli.common.vendor_source, "service", "empty SOCKET_VENDOR_SOURCE must fall back to the `auto` default" @@ -1560,7 +1546,6 @@ mod tests { let cli = TestCli::try_parse_from(["socket-patch"]).unwrap(); assert_eq!(cli.common.manifest_path, DEFAULT_PATCH_MANIFEST_PATH); - assert_eq!(cli.common.download_mode, "diff"); assert_eq!(cli.common.cwd, PathBuf::from(".")); }); } diff --git a/crates/socket-patch-cli/src/commands/agent_download.rs b/crates/socket-patch-cli/src/commands/agent_download.rs index 30471d61e..e7f691386 100644 --- a/crates/socket-patch-cli/src/commands/agent_download.rs +++ b/crates/socket-patch-cli/src/commands/agent_download.rs @@ -374,8 +374,6 @@ pub struct DownloadParams { pub global_prefix: Option, pub json: bool, pub silent: bool, - /// `--download-mode` value forwarded to the apply step. - pub download_mode: String, /// When `false` (the default — narrow), a release-variant package (PyPI /// `?artifact_id=`, RubyGems `?platform=`, Maven `?classifier=`) is /// filtered down to the variant(s) matching the locally-installed @@ -1330,7 +1328,7 @@ pub(crate) async fn warn_on_vendored_uuid_drift( } /// The `GlobalArgs` a nested apply runs with: the caller's flags verbatim -/// (`--verbose`, `--strict`, `--ecosystems`, `--download-mode` … all flow +/// (`--verbose`, `--strict`, `--ecosystems` … all flow /// through; the API flags ride along but are inert — the nested apply runs /// on the caller's client), with the fields `get` owns overridden: the /// already-resolved manifest path (apply re-resolves a @@ -1368,7 +1366,6 @@ pub(crate) fn nested_apply_args_from_params( cwd: params.cwd.clone(), global: params.global, global_prefix: params.global_prefix.clone(), - download_mode: params.download_mode.clone(), strict: params.strict, // Scope the nested apply like the caller was scoped: `None` would // apply the WHOLE manifest, mutating other ecosystems' packages the diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index 22ed44888..e05c3e8e6 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -1,5 +1,4 @@ use clap::Args; -use socket_patch_core::api::blob_fetcher::get_missing_blobs; use socket_patch_core::api::client::{get_api_client_with_overrides, ApiClient}; use socket_patch_core::crawlers::ruby_crawler::config_path_ignored_warning; use socket_patch_core::crawlers::{ @@ -25,7 +24,7 @@ use std::path::{Path, PathBuf}; use std::time::Duration; use crate::args::{apply_env_toggles, is_local_go, GlobalArgs}; -use crate::commands::fetch_stage::{stage_patch_sources, StageOutcome, StagedSources}; +use crate::commands::fetch_stage::{stage_patch_sources, StageOutcome}; use crate::commands::lock_cli::acquire_or_emit; use crate::commands::vex::{ generate_vex_from_manifest_path, generate_vex_without_manifest, ManifestlessVex, VexEmbedArgs, @@ -38,7 +37,7 @@ use crate::json_envelope::{ AppliedVia, Command, Envelope, EnvelopeError, PatchAction, PatchEvent, PatchEventFile, RunWarning, Status, VexSummary, }; -use crate::ui::{plural, StatusLine}; +use crate::ui::plural; /// Files whose pre-apply content matched NEITHER hash and were (or would /// be) overwritten with the verified patched content — the promoted @@ -110,260 +109,6 @@ fn mismatch_overwrite_warnings(results: &[ApplyResult], dry_run: bool) -> Vec String { - if n == 1 { - "1 mismatched file will fail to apply".to_string() - } else { - format!("{n} mismatched files will fail to apply") - } -} - -/// The default mismatch policy applies the FULL patched content for -/// mismatched files — and the full content lives in the afterHash blob, -/// which the default `--download-mode diff` may not have staged. Probe the -/// in-scope packages for mismatches and fetch the missing afterHash blobs -/// by hash (online only) so the apply below can fall through diff → blob. -async fn ensure_blobs_for_mismatches( - args: &ApplyArgs, - manifest: &PatchManifest, - all_packages: &HashMap>, - vendored_purls: &HashSet, - staged: &mut StagedSources, - client: &ApiClient, -) { - if args.common.strict && !args.force { - return; // strict fails on mismatch — nothing to fetch - } - let needed = mismatch_blob_gaps( - manifest, - all_packages, - vendored_purls, - &staged.blobs, - args.force, - ) - .await; - if needed.is_empty() { - return; - } - let quiet = args.common.silent || args.common.json; - if args.common.offline { - if !quiet { - eprintln!( - "Warning: {} {} the full patched blob, but --offline prevents fetching; {}", - plural( - needed.len(), - "mismatched file needs", - "mismatched files need" - ), - if needed.len() == 1 { "its" } else { "their" }, - if needed.len() == 1 { - "that file will fail to apply" - } else { - "those files will fail to apply" - } - ); - } - return; - } - // Apply is read-only against `.socket/`: when the stage step returned - // direct `.socket/` paths (everything had a local source), the on-demand - // blobs must go to a transient overlay, never `.socket/blobs/`. - let Some(blobs_path) = staged.writable_blobs().await else { - if !quiet { - eprintln!( - "Warning: could not stage a transient blob directory; {}", - mismatched_files_fail(needed.len()) - ); - } - return; - }; - let mut status = StatusLine::stderr(args.common.json, args.common.silent); - status.set(format!( - "Downloading {} for mismatched files...", - plural(needed.len(), "full patched blob", "full patched blobs") - )); - let fetched = socket_patch_core::api::blob_fetcher::fetch_blobs_by_hash( - &needed, blobs_path, client, None, - ) - .await; - status.finish_with(format_mismatch_fetch_result( - fetched.downloaded, - needed.len(), - )); -} - -/// The result line after fetching full blobs for mismatched files. -fn format_mismatch_fetch_result(downloaded: usize, needed: usize) -> String { - if downloaded == needed { - format!( - "Downloaded {} for mismatched files", - plural(needed, "full patched blob", "full patched blobs") - ) - } else { - format!( - "Downloaded {downloaded} of {} for mismatched files", - plural(needed, "full patched blob", "full patched blobs") - ) - } -} - -/// Probe the crawled packages for `beforeHash` mismatches whose -/// `afterHash` blob is not already staged, returning the missing blob -/// hashes [`ensure_blobs_for_mismatches`] should fetch. -/// -/// The crawler keys `all_packages` by BASE purl, but release-variant -/// ecosystems (PyPI `?artifact_id=`, RubyGems `?platform=`, Maven -/// `?classifier=&ext=`) key the manifest by QUALIFIED purls — an -/// exact-key lookup misses every one of them. Match records by -/// qualifier-stripped key, and probe only the variants the apply loop -/// will actually attempt (its representative-file installed-distribution -/// gate, bypassed by `--force`) so a skipped sibling variant's files -/// don't trigger spurious fetches or `--offline` warnings. An UNQUALIFIED -/// singleton base is always attempted (the mismatch-policy fall-through), -/// so its mismatched files are probed unconditionally; a QUALIFIED -/// singleton keeps the gate, mirroring the apply loop. Vendor-owned bases -/// are skipped outright: the apply loop never attempts them (their -/// results are synthesized up front), so their drifted files must not -/// queue fetches either. -/// -/// EVERY physical copy of a purl is probed: npm materializes genuine -/// duplicates of one `name@version`, the apply loop patches each of them, -/// and copies drift independently — a pristine (or already-patched) root -/// copy says nothing about a locally-modified nested duplicate, whose -/// mismatched files still need their afterHash blobs. The variant gate -/// mirrors the apply loop's representative check PER COPY for gem and -/// PyPI (which patch every copy, and two envs can hold different wheels -/// of one release), and against the FIRST copy otherwise: a variant's -/// files are probed only on the copies it is attempted on. Maven's Gradle -/// copies are version dirs whose files sit in hash dirs, so each one is -/// first expanded into the hash dirs holding the record's files (as -/// `apply_maven_base` does) and gated and probed per hash dir; probing the -/// version dir itself would only ever find nothing, and a drifted Gradle -/// copy would never queue the afterHash blob its write needs. -/// -/// Only a mismatched file whose afterHash blob is NOT staged can queue a -/// fetch, so the probe first decides that with metadata probes alone and -/// hashes only the files that can still matter: the common fully-cached -/// run hashes nothing here (the apply loop re-verifies everything anyway). -async fn mismatch_blob_gaps( - manifest: &PatchManifest, - all_packages: &HashMap>, - vendored_purls: &HashSet, - blobs_path: &Path, - force: bool, -) -> HashSet { - let mut needed: HashSet = HashSet::new(); - let missing = get_missing_blobs(manifest, blobs_path).await; - if missing.is_empty() { - return needed; - } - // A record can queue a fetch only through a content-modifying file - // (non-empty beforeHash) whose afterHash blob is missing. - let can_queue = |record: &PatchRecord| { - record - .files - .values() - .any(|f| !f.before_hash.is_empty() && missing.contains(&f.after_hash)) - }; - for (purl, pkg_paths) in all_packages { - let Some(first_path) = pkg_paths.first() else { - continue; - }; - let variant_eco = Ecosystem::from_purl(purl).is_some_and(|e| e.supports_release_variants()); - let stripped = strip_purl_qualifiers(purl); - let identity = PurlKey::new(purl); - let records: Vec<(&String, &PatchRecord)> = manifest - .patches - .iter() - .filter(|(key, _)| *key == purl || PurlKey::new(key) == identity) - .collect(); - if purl_keys_cover(vendored_purls, purl) - || records - .iter() - .any(|(key, _)| purl_keys_cover(vendored_purls, key)) - { - continue; - } - if !records.iter().any(|(_, record)| can_queue(record)) { - continue; - } - let gated = variant_eco - && !force - && (records.len() > 1 - || records - .first() - .is_some_and(|(key, _)| key.as_str() != stripped)); - let maven = Ecosystem::from_purl(purl) == Some(Ecosystem::Maven); - for (_, record) in records { - if !can_queue(record) { - continue; - } - // Maven: every Gradle version dir expanded into the hash dirs - // holding the record's files. - let expanded: Vec = if maven { - pkg_paths - .iter() - .flat_map(|p| { - socket_patch_core::crawlers::gradle_cache::installed_copies( - p, - &record.files, - ) - .into_iter() - .map(|(dir, _)| dir) - }) - .collect() - } else { - Vec::new() - }; - let pkg_paths: &[PathBuf] = if maven { &expanded } else { pkg_paths }; - // The copies the apply loop gates per copy: gem and PyPI patch - // every copy, each against its own representative check, and - // Maven each hash dir; the rest gate on the first. - let gate_copies: &[PathBuf] = if matches!( - Ecosystem::from_purl(purl), - Some(Ecosystem::Gem | Ecosystem::Pypi | Ecosystem::Maven) - ) { - pkg_paths - } else { - std::slice::from_ref(first_path) - }; - // Copies this variant is attempted on: a copy whose installed - // distribution is another variant (two envs can hold different - // wheels of one release) is skipped there by the apply loop. - let probe_copies: Vec<&PathBuf> = match representative_file(&record.files) { - Some((file_name, file_info)) if gated => { - let mut matched = Vec::new(); - for copy in gate_copies { - let status = verify_file_patch(copy, file_name, file_info).await.status; - if variant_matches_installed(Some(&status)) { - matched.push(copy); - } - } - matched - } - _ => pkg_paths.iter().collect(), - }; - if probe_copies.is_empty() { - continue; - } - for (file_name, info) in &record.files { - if info.before_hash.is_empty() || !missing.contains(&info.after_hash) { - continue; - } - for pkg_path in &probe_copies { - let verify = verify_file_patch(pkg_path, file_name, info).await; - if verify.status == VerifyStatus::HashMismatch { - needed.insert(info.after_hash.clone()); - break; // the fetch is per-hash; one drifted copy queues it - } - } - } - } - } - needed -} - /// The mismatch policy this run applies with: `--force` ⊃ default /// (adds the missing-file skip), `--strict` restores fail-closed. fn mismatch_policy(force: bool, strict: bool) -> MismatchPolicy { @@ -483,7 +228,6 @@ async fn try_local_go_apply( socket_patch_core::vendor::go_mod_edit::GO_PATCHES_DIR, &patch.files, sources, - Some(&patch.uuid), common.dry_run, policy, ) @@ -2121,8 +1865,7 @@ async fn apply_patches_inner( .patches .retain(|purl, _| target_manifest_purls.contains(purl)); - let mut staged = match stage_patch_sources(&args.common, &manifest, &socket_dir, client).await? - { + let staged = match stage_patch_sources(&args.common, &manifest, &socket_dir, client).await? { StageOutcome::Ready(s) => s, StageOutcome::Unavailable => { return Ok(ApplyOutcome { @@ -2152,8 +1895,7 @@ async fn apply_patches_inner( // `postinstall` hook runs `apply` on every install, including fresh // projects whose manifest has no matching patches yet. Decided // BEFORE the ledger read, gem discovery and the crawl — none of which - // can add work to an empty scope — but AFTER the staging above, which - // is where `--download-mode` is validated at runtime. + // can add work to an empty scope — but AFTER the staging above. if !args.common.silent && !args.common.json { println!("No patches to apply."); } @@ -2282,15 +2024,6 @@ async fn apply_patches_inner( } // Apply patches - ensure_blobs_for_mismatches( - args, - &manifest, - &all_packages, - &vendored_purls, - &mut staged, - client, - ) - .await; let sources = staged.as_patch_sources(); let policy = mismatch_policy(args.force, args.common.strict); let mut has_errors = false; @@ -2509,7 +2242,6 @@ async fn apply_patches_inner( pkg_path, &patch.files, &sources, - Some(&patch.uuid), args.common.dry_run, policy, ) @@ -2657,7 +2389,6 @@ async fn apply_patches_inner( pkg_path, &patch.files, &sources, - Some(&patch.uuid), args.common.dry_run, policy, ) @@ -3086,7 +2817,6 @@ async fn apply_maven_base(m: &MavenBase<'_>) -> MavenApplied { &dir, &files, m.sources, - Some(&patch.uuid), args.common.dry_run, m.policy, ) @@ -3107,7 +2837,6 @@ async fn apply_maven_base(m: &MavenBase<'_>) -> MavenApplied { copy, &absent, m.sources, - Some(&patch.uuid), args.common.dry_run, m.policy, ) @@ -3372,7 +3101,7 @@ mod tests { /// verified file. Used as the base for action-routing tests. fn sample_applied(status: VerifyStatus) -> ApplyResult { let mut applied_via = HashMap::new(); - applied_via.insert("package/index.js".to_string(), CoreAppliedVia::Diff); + applied_via.insert("package/index.js".to_string(), CoreAppliedVia::Blob); ApplyResult { package_key: "pkg:npm/minimist@1.2.2".to_string(), package_path: "/tmp/node_modules/minimist".to_string(), @@ -3446,14 +3175,14 @@ mod tests { assert_eq!(files[0]["path"], "package/index.js"); assert_eq!(files[0]["verified"], true); // `appliedVia` is camelCase + lowercase tag — contract value. - assert_eq!(files[0]["appliedVia"], "diff"); + assert_eq!(files[0]["appliedVia"], "blob"); } #[test] fn applied_event_emits_one_file_entry_per_patched_file() { let mut applied_via = HashMap::new(); - applied_via.insert("package/a.js".to_string(), CoreAppliedVia::Diff); - applied_via.insert("package/b.js".to_string(), CoreAppliedVia::Diff); + applied_via.insert("package/a.js".to_string(), CoreAppliedVia::Blob); + applied_via.insert("package/b.js".to_string(), CoreAppliedVia::Blob); applied_via.insert("package/c.js".to_string(), CoreAppliedVia::Blob); let result = ApplyResult { package_key: "pkg:npm/foo@1.0.0".to_string(), @@ -3479,8 +3208,8 @@ mod tests { .iter() .map(|f| (f["path"].as_str().unwrap().to_string(), f)) .collect(); - assert_eq!(by_path["package/a.js"]["appliedVia"], "diff"); - assert_eq!(by_path["package/b.js"]["appliedVia"], "diff"); + assert_eq!(by_path["package/a.js"]["appliedVia"], "blob"); + assert_eq!(by_path["package/b.js"]["appliedVia"], "blob"); assert_eq!(by_path["package/c.js"]["appliedVia"], "blob"); } @@ -3619,573 +3348,6 @@ mod tests { } } - /// One-record manifest fixture for the `mismatch_blob_gaps` tests. - fn manifest_with_record(key: &str, files: HashMap) -> PatchManifest { - let mut manifest = PatchManifest::new(); - manifest.patches.insert( - key.to_string(), - PatchRecord { - uuid: "11111111-1111-4111-8111-111111111111".to_string(), - exported_at: "2024-01-01T00:00:00Z".to_string(), - files, - vulnerabilities: HashMap::new(), - description: "fixture".to_string(), - license: "MIT".to_string(), - tier: "free".to_string(), - }, - ); - manifest - } - - /// Regression: release-variant ecosystems key the manifest by - /// QUALIFIED purl (`?artifact_id=`…) while the crawler keys - /// `all_packages` by BASE purl, so the exact-key lookup in the - /// mismatch-blob probe missed every PyPI/Gem/Maven record — the - /// afterHash blobs that the default (Warn) mismatch policy needs were - /// never prefetched, and a locally-modified file in a variant package - /// failed to apply under the default diff download mode instead of - /// being warn-overwritten. - #[tokio::test] - async fn mismatch_blob_gaps_matches_qualified_variant_keys() { - use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; - - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("pkg"); - tokio::fs::create_dir_all(&pkg).await.unwrap(); - // Representative file (lex-smallest, non-empty beforeHash) matches - // the installed distribution, so the apply loop WILL attempt this - // variant... - tokio::fs::write(pkg.join("aaa.py"), b"pristine\n") - .await - .unwrap(); - // ...but a second file was locally modified: under the default - // Warn policy it is overwritten with the full afterHash blob, so - // that blob must be prefetched. - tokio::fs::write(pkg.join("zzz.py"), b"locally modified\n") - .await - .unwrap(); - let blobs = dir.path().join("blobs"); - tokio::fs::create_dir_all(&blobs).await.unwrap(); - - let mut files = HashMap::new(); - files.insert( - "aaa.py".to_string(), - PatchFileInfo { - before_hash: compute_git_sha256_from_bytes(b"pristine\n"), - after_hash: "1".repeat(64), - }, - ); - files.insert( - "zzz.py".to_string(), - PatchFileInfo { - before_hash: "2".repeat(64), - after_hash: "3".repeat(64), - }, - ); - let manifest = manifest_with_record( - "pkg:pypi/foo@1.0.0?artifact_id=foo-1.0.0-py3-none-any.whl", - files, - ); - let mut all_packages = HashMap::new(); - all_packages.insert("pkg:pypi/foo@1.0.0".to_string(), vec![pkg.clone()]); - - let needed = - mismatch_blob_gaps(&manifest, &all_packages, &HashSet::new(), &blobs, false).await; - assert_eq!( - needed, - HashSet::from(["3".repeat(64)]), - "the qualified variant's mismatched file must have its afterHash blob queued" - ); - } - - /// A Maven GAV in two caches: only the second (Coursier) copy holds the - /// classifier jar, with a locally modified sibling file. The variant - /// gate runs per copy, as the apply loop attempts it per copy, so the - /// sibling's afterHash blob is queued although the first copy lacks the - /// classifier. - #[tokio::test] - async fn mismatch_blob_gaps_gates_each_maven_copy() { - use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; - - let dir = tempfile::tempdir().unwrap(); - let m2 = dir.path().join("m2/g/a/1"); - let csr = dir.path().join("csr/https/h/g/a/1"); - for copy in [&m2, &csr] { - tokio::fs::create_dir_all(copy).await.unwrap(); - tokio::fs::write(copy.join("a-1.jar"), b"jar\n") - .await - .unwrap(); - } - tokio::fs::write(csr.join("a-1-tests.jar"), b"tests\n") - .await - .unwrap(); - tokio::fs::write(csr.join("z-tests.txt"), b"locally modified\n") - .await - .unwrap(); - let blobs = dir.path().join("blobs"); - tokio::fs::create_dir_all(&blobs).await.unwrap(); - let mut base = HashMap::new(); - base.insert( - "a-1.jar".to_string(), - PatchFileInfo { - before_hash: compute_git_sha256_from_bytes(b"jar\n"), - after_hash: "1".repeat(64), - }, - ); - let mut manifest = manifest_with_record("pkg:maven/g/a@1", base); - let mut tests = HashMap::new(); - tests.insert( - "a-1-tests.jar".to_string(), - PatchFileInfo { - before_hash: compute_git_sha256_from_bytes(b"tests\n"), - after_hash: "2".repeat(64), - }, - ); - tests.insert( - "z-tests.txt".to_string(), - PatchFileInfo { - before_hash: compute_git_sha256_from_bytes(b"pristine\n"), - after_hash: "3".repeat(64), - }, - ); - manifest.patches.insert( - "pkg:maven/g/a@1?classifier=tests".to_string(), - PatchRecord { - uuid: "22222222-2222-4222-8222-222222222222".to_string(), - exported_at: "2024-01-01T00:00:00Z".to_string(), - files: tests, - vulnerabilities: HashMap::new(), - description: "fixture".to_string(), - license: "MIT".to_string(), - tier: "free".to_string(), - }, - ); - let mut all_packages = HashMap::new(); - all_packages.insert("pkg:maven/g/a@1".to_string(), vec![m2.clone(), csr.clone()]); - let needed = - mismatch_blob_gaps(&manifest, &all_packages, &HashSet::new(), &blobs, false).await; - assert_eq!(needed, HashSet::from(["3".repeat(64)])); - } - - /// The counterpart guard: a sibling variant that does NOT describe the - /// installed distribution (its representative file mismatches) is - /// skipped by the apply loop, so its blobs must not be queued — that - /// would mean spurious downloads and spurious `--offline` "will fail - /// to apply" warnings on every run. An unqualified singleton is - /// always attempted (see the singleton test below), so the group - /// carries an installed wheel sibling alongside the non-installed - /// sdist. Under `--force` every variant IS attempted, so then its - /// blob must be queued. - #[tokio::test] - async fn mismatch_blob_gaps_skips_non_installed_variant_unless_forced() { - use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; - - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("pkg"); - tokio::fs::create_dir_all(&pkg).await.unwrap(); - tokio::fs::write(pkg.join("aaa.py"), b"pristine\n") - .await - .unwrap(); - let blobs = dir.path().join("blobs"); - tokio::fs::create_dir_all(&blobs).await.unwrap(); - - // Installed wheel variant: representative matches the on-disk - // bytes (Ready — no mismatch, so nothing to queue for it). - let mut wheel_files = HashMap::new(); - wheel_files.insert( - "aaa.py".to_string(), - PatchFileInfo { - before_hash: compute_git_sha256_from_bytes(b"pristine\n"), - after_hash: "1".repeat(64), - }, - ); - let mut manifest = manifest_with_record( - "pkg:pypi/foo@1.0.0?artifact_id=foo-1.0.0-py3-none-any.whl", - wheel_files, - ); - // The sdist sibling's only file has a different base than the - // on-disk bytes: representative mismatch → not installed. - let mut sdist_files = HashMap::new(); - sdist_files.insert( - "aaa.py".to_string(), - PatchFileInfo { - before_hash: "4".repeat(64), - after_hash: "5".repeat(64), - }, - ); - manifest.patches.insert( - "pkg:pypi/foo@1.0.0?artifact_id=foo-1.0.0.tar.gz".to_string(), - PatchRecord { - uuid: "22222222-2222-4222-8222-222222222222".to_string(), - exported_at: "2024-01-01T00:00:00Z".to_string(), - files: sdist_files, - vulnerabilities: HashMap::new(), - description: "fixture".to_string(), - license: "MIT".to_string(), - tier: "free".to_string(), - }, - ); - let mut all_packages = HashMap::new(); - all_packages.insert("pkg:pypi/foo@1.0.0".to_string(), vec![pkg.clone()]); - - let needed = - mismatch_blob_gaps(&manifest, &all_packages, &HashSet::new(), &blobs, false).await; - assert!( - needed.is_empty(), - "a non-installed sibling variant is never attempted, so its blobs must not be queued: {needed:?}" - ); - - let needed = - mismatch_blob_gaps(&manifest, &all_packages, &HashSet::new(), &blobs, true).await; - assert_eq!( - needed, - HashSet::from(["5".repeat(64)]), - "--force attempts every variant, so the mismatch blob is needed" - ); - } - - /// An UNQUALIFIED singleton release-variant base is always attempted - /// by the apply loop (the mismatch-policy fall-through: it names no - /// distribution, so a mismatch means locally-modified bytes), so its - /// mismatched file's afterHash blob must be queued even though the - /// representative file mismatches — otherwise the default Warn policy - /// has no bytes to overwrite with under `--download-mode diff` and - /// the apply fails instead of warn-overwriting. - #[tokio::test] - async fn mismatch_blob_gaps_singleton_mismatch_queued() { - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("pkg"); - tokio::fs::create_dir_all(&pkg).await.unwrap(); - tokio::fs::write(pkg.join("aaa.rb"), b"locally modified\n") - .await - .unwrap(); - let blobs = dir.path().join("blobs"); - tokio::fs::create_dir_all(&blobs).await.unwrap(); - - let mut files = HashMap::new(); - files.insert( - "aaa.rb".to_string(), - PatchFileInfo { - before_hash: "4".repeat(64), - after_hash: "5".repeat(64), - }, - ); - let manifest = manifest_with_record("pkg:gem/foo@1.0.0", files); - let mut all_packages = HashMap::new(); - all_packages.insert("pkg:gem/foo@1.0.0".to_string(), vec![pkg.clone()]); - - let needed = - mismatch_blob_gaps(&manifest, &all_packages, &HashSet::new(), &blobs, false).await; - assert_eq!( - needed, - HashSet::from(["5".repeat(64)]), - "a singleton base falls through to the mismatch policy, so its blob is needed" - ); - } - - /// #646 review: a Gradle copy is a `files-2.1` version dir whose files - /// sit one level down in `/` hash dirs. A drifted jar there (not - /// pristine, not the record's beforeHash) must queue its afterHash - /// blob as a drifted `~/.m2` copy does: the default Warn policy - /// overwrites it with the full blob. - #[tokio::test] - async fn mismatch_blob_gaps_probes_gradle_hash_dirs() { - let dir = tempfile::tempdir().unwrap(); - let version = dir - .path() - .join(".gradle/caches/modules-2/files-2.1/com.example/victim/1.0"); - let hash = version.join("0123456789abcdef0123456789abcdef01234567"); - tokio::fs::create_dir_all(&hash).await.unwrap(); - tokio::fs::write(hash.join("victim-1.0.jar"), b"older patch bytes") - .await - .unwrap(); - let blobs = dir.path().join("blobs"); - tokio::fs::create_dir_all(&blobs).await.unwrap(); - let mut files = HashMap::new(); - files.insert( - "package/victim-1.0.jar".to_string(), - PatchFileInfo { - before_hash: "4".repeat(64), - after_hash: "5".repeat(64), - }, - ); - let manifest = manifest_with_record("pkg:maven/com.example/victim@1.0", files); - let mut all_packages = HashMap::new(); - all_packages.insert( - "pkg:maven/com.example/victim@1.0".to_string(), - vec![version.clone()], - ); - let needed = - mismatch_blob_gaps(&manifest, &all_packages, &HashSet::new(), &blobs, false).await; - assert_eq!(needed, HashSet::from(["5".repeat(64)])); - } - - /// A QUALIFIED singleton (`?platform=`…) keeps the - /// installed-distribution gate — it names one specific distribution, - /// and the apply loop skips it when the representative file - /// mismatches (the crawler drops the gem dir's platform suffix, so - /// this hash check is the only platform resolution). Its blobs must - /// not be queued: that would mean spurious downloads and spurious - /// `--offline` warnings for a variant the loop never attempts. Under - /// `--force` it IS attempted, so then the blob is needed. - #[tokio::test] - async fn mismatch_blob_gaps_qualified_singleton_gated_unless_forced() { - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("pkg"); - tokio::fs::create_dir_all(&pkg).await.unwrap(); - tokio::fs::write(pkg.join("aaa.rb"), b"darwin bytes\n") - .await - .unwrap(); - let blobs = dir.path().join("blobs"); - tokio::fs::create_dir_all(&blobs).await.unwrap(); - - let mut files = HashMap::new(); - files.insert( - "aaa.rb".to_string(), - PatchFileInfo { - before_hash: "4".repeat(64), - after_hash: "5".repeat(64), - }, - ); - let manifest = manifest_with_record("pkg:gem/foo@1.0.0?platform=x86_64-linux", files); - let mut all_packages = HashMap::new(); - all_packages.insert("pkg:gem/foo@1.0.0".to_string(), vec![pkg.clone()]); - - let needed = - mismatch_blob_gaps(&manifest, &all_packages, &HashSet::new(), &blobs, false).await; - assert!( - needed.is_empty(), - "a qualified singleton whose distribution is not on disk is never attempted, \ - so its blobs must not be queued: {needed:?}" - ); - - let needed = - mismatch_blob_gaps(&manifest, &all_packages, &HashSet::new(), &blobs, true).await; - assert_eq!( - needed, - HashSet::from(["5".repeat(64)]), - "--force attempts the qualified singleton, so the mismatch blob is needed" - ); - } - - /// A vendor-owned base is unconditionally skipped by the apply loop - /// (its result is synthesized up front), so its drifted installed - /// files must not queue blobs — that meant a spurious "Downloading N - /// full patched blob(s)" fetch online and a spurious "will fail to - /// apply" warning under `--offline` for a package apply never - /// touches. The same fixture queues without the vendor claim - /// (anti-vacuity: the mismatch is real). - #[tokio::test] - async fn mismatch_blob_gaps_vendored_base_never_queued() { - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("pkg"); - tokio::fs::create_dir_all(&pkg).await.unwrap(); - tokio::fs::write(pkg.join("aaa.rb"), b"drifted\n") - .await - .unwrap(); - let blobs = dir.path().join("blobs"); - tokio::fs::create_dir_all(&blobs).await.unwrap(); - - let mut files = HashMap::new(); - files.insert( - "aaa.rb".to_string(), - PatchFileInfo { - before_hash: "4".repeat(64), - after_hash: "5".repeat(64), - }, - ); - let manifest = manifest_with_record("pkg:gem/foo@1.0.0", files); - let mut all_packages = HashMap::new(); - all_packages.insert("pkg:gem/foo@1.0.0".to_string(), vec![pkg.clone()]); - - let needed = - mismatch_blob_gaps(&manifest, &all_packages, &HashSet::new(), &blobs, false).await; - assert_eq!( - needed, - HashSet::from(["5".repeat(64)]), - "without a vendor claim the drifted singleton must queue (fixture sanity)" - ); - - let vendored = HashSet::from([PurlKey::new("pkg:gem/foo@1.0.0")]); - let needed = mismatch_blob_gaps(&manifest, &all_packages, &vendored, &blobs, false).await; - assert!( - needed.is_empty(), - "a vendor-owned base is never attempted, so its blobs must not be queued: {needed:?}" - ); - - let needed = mismatch_blob_gaps(&manifest, &all_packages, &vendored, &blobs, true).await; - assert!( - needed.is_empty(), - "--force does not override vendor ownership in the apply loop, so nothing is queued: {needed:?}" - ); - } - - /// Exact-key (npm-shaped) probing keeps working: unqualified manifest - /// keys match the crawled purl directly, with no installed-variant - /// gate (the npm branch always attempts). - #[tokio::test] - async fn mismatch_blob_gaps_exact_key_still_probed() { - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("pkg"); - tokio::fs::create_dir_all(&pkg).await.unwrap(); - tokio::fs::write(pkg.join("index.js"), b"locally modified\n") - .await - .unwrap(); - let blobs = dir.path().join("blobs"); - tokio::fs::create_dir_all(&blobs).await.unwrap(); - - let mut files = HashMap::new(); - files.insert( - "package/index.js".to_string(), - PatchFileInfo { - before_hash: "6".repeat(64), - after_hash: "7".repeat(64), - }, - ); - let manifest = manifest_with_record("pkg:npm/foo@1.0.0", files); - let mut all_packages = HashMap::new(); - all_packages.insert("pkg:npm/foo@1.0.0".to_string(), vec![pkg.clone()]); - - let needed = - mismatch_blob_gaps(&manifest, &all_packages, &HashSet::new(), &blobs, false).await; - assert_eq!(needed, HashSet::from(["7".repeat(64)])); - } - - /// Regression (multi-copy): npm materializes genuine duplicates of one - /// `name@version` and the apply loop patches every copy, so the probe - /// must scan every copy too — copies drift independently. Before the - /// fix only the FIRST copy was probed: a clean root copy masked a - /// locally-modified nested duplicate, its afterHash blob was never - /// queued, and the nested copy failed to apply under the default - /// warn-and-overwrite policy in diff download mode. - #[tokio::test] - async fn mismatch_blob_gaps_probes_every_copy() { - use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; - - let dir = tempfile::tempdir().unwrap(); - // First copy: pristine (matches beforeHash — no blob needed). - let root_copy = dir.path().join("root"); - tokio::fs::create_dir_all(&root_copy).await.unwrap(); - tokio::fs::write(root_copy.join("index.js"), b"pristine\n") - .await - .unwrap(); - // Second copy: locally modified (matches neither hash). - let nested_copy = dir.path().join("nested"); - tokio::fs::create_dir_all(&nested_copy).await.unwrap(); - tokio::fs::write(nested_copy.join("index.js"), b"locally modified\n") - .await - .unwrap(); - let blobs = dir.path().join("blobs"); - tokio::fs::create_dir_all(&blobs).await.unwrap(); - - let mut files = HashMap::new(); - files.insert( - "package/index.js".to_string(), - PatchFileInfo { - before_hash: compute_git_sha256_from_bytes(b"pristine\n"), - after_hash: "8".repeat(64), - }, - ); - let manifest = manifest_with_record("pkg:npm/foo@1.0.0", files); - let mut all_packages = HashMap::new(); - all_packages.insert( - "pkg:npm/foo@1.0.0".to_string(), - vec![root_copy.clone(), nested_copy.clone()], - ); - - let needed = - mismatch_blob_gaps(&manifest, &all_packages, &HashSet::new(), &blobs, false).await; - assert_eq!( - needed, - HashSet::from(["8".repeat(64)]), - "the drifted second copy must queue the blob even though the first copy is clean" - ); - } - - /// Regression (#538 review): two PyPI copies of one release holding - /// DIFFERENT wheels. The apply loop gates each variant per copy, so the - /// variant installed only in the SECOND copy is attempted there; its - /// locally-modified non-representative file needs the full afterHash - /// blob. Gating against the first copy alone skipped that variant and - /// left the blob unfetched, so warn-and-apply failed on the second copy. - #[tokio::test] - async fn mismatch_blob_gaps_gates_pypi_variants_per_copy() { - use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; - - let dir = tempfile::tempdir().unwrap(); - // Copy A: the wheel's distribution, pristine. - let copy_a = dir.path().join("a"); - tokio::fs::create_dir_all(©_a).await.unwrap(); - tokio::fs::write(copy_a.join("aaa.py"), b"wheel\n") - .await - .unwrap(); - // Copy B: the sdist's distribution, with a locally modified - // non-representative file. - let copy_b = dir.path().join("b"); - tokio::fs::create_dir_all(©_b).await.unwrap(); - tokio::fs::write(copy_b.join("aaa.py"), b"sdist\n") - .await - .unwrap(); - tokio::fs::write(copy_b.join("zzz.py"), b"locally modified\n") - .await - .unwrap(); - let blobs = dir.path().join("blobs"); - tokio::fs::create_dir_all(&blobs).await.unwrap(); - - let mut wheel_files = HashMap::new(); - wheel_files.insert( - "aaa.py".to_string(), - PatchFileInfo { - before_hash: compute_git_sha256_from_bytes(b"wheel\n"), - after_hash: "1".repeat(64), - }, - ); - let mut manifest = manifest_with_record( - "pkg:pypi/foo@1.0.0?artifact_id=foo-1.0.0-py3-none-any.whl", - wheel_files, - ); - let mut sdist_files = HashMap::new(); - sdist_files.insert( - "aaa.py".to_string(), - PatchFileInfo { - before_hash: compute_git_sha256_from_bytes(b"sdist\n"), - after_hash: "2".repeat(64), - }, - ); - sdist_files.insert( - "zzz.py".to_string(), - PatchFileInfo { - before_hash: "3".repeat(64), - after_hash: "4".repeat(64), - }, - ); - manifest.patches.insert( - "pkg:pypi/foo@1.0.0?artifact_id=foo-1.0.0.tar.gz".to_string(), - PatchRecord { - uuid: "22222222-2222-4222-8222-222222222222".to_string(), - exported_at: "2024-01-01T00:00:00Z".to_string(), - files: sdist_files, - vulnerabilities: HashMap::new(), - description: "fixture".to_string(), - license: "MIT".to_string(), - tier: "free".to_string(), - }, - ); - let mut all_packages = HashMap::new(); - all_packages.insert( - "pkg:pypi/foo@1.0.0".to_string(), - vec![copy_a.clone(), copy_b.clone()], - ); - - let needed = - mismatch_blob_gaps(&manifest, &all_packages, &HashSet::new(), &blobs, false).await; - assert_eq!( - needed, - HashSet::from(["4".repeat(64)]), - "the second copy's variant must queue its mismatched file's blob" - ); - } - /// A variant with no content-modifying files (only new files) has /// nothing to disqualify it: no representative, treated as a match — /// the same no-files contract as core's `select_installed_variants`. @@ -4377,7 +3539,7 @@ mod tests { vec![ "", "Patched packages:", - " pkg:npm/minimist@1.2.2 (via diff)", + " pkg:npm/minimist@1.2.2 (via blob)", " pkg:npm/other@2.0.0 (already patched)", ] ); @@ -4398,8 +3560,8 @@ mod tests { vec![ "", "Patched packages:", - " pkg:npm/nuxt@4.5.0 (node_modules/nuxt, via diff)", - " pkg:npm/nuxt@4.5.0 (node_modules/vite/node_modules/nuxt, via diff)", + " pkg:npm/nuxt@4.5.0 (node_modules/nuxt, via blob)", + " pkg:npm/nuxt@4.5.0 (node_modules/vite/node_modules/nuxt, via blob)", ] ); } @@ -4407,8 +3569,8 @@ mod tests { #[test] fn patched_line_shapes() { assert_eq!( - format_patched_line("pkg:npm/a@1", None, "via blob+diff"), - " pkg:npm/a@1 (via blob+diff)" + format_patched_line("pkg:npm/a@1", None, "via blob"), + " pkg:npm/a@1 (via blob)" ); assert_eq!( format_patched_line("pkg:npm/a@1", Some("node_modules/a"), "already patched"), @@ -4434,30 +3596,6 @@ mod tests { assert!(mismatch_event_detail("f.js", true).ends_with("content would be applied")); } - #[test] - fn mismatch_fetch_and_fail_counts() { - assert_eq!( - format_mismatch_fetch_result(1, 1), - "Downloaded 1 full patched blob for mismatched files" - ); - assert_eq!( - format_mismatch_fetch_result(3, 3), - "Downloaded 3 full patched blobs for mismatched files" - ); - assert_eq!( - format_mismatch_fetch_result(1, 2), - "Downloaded 1 of 2 full patched blobs for mismatched files" - ); - assert_eq!( - mismatched_files_fail(1), - "1 mismatched file will fail to apply" - ); - assert_eq!( - mismatched_files_fail(2), - "2 mismatched files will fail to apply" - ); - } - #[test] fn check_in_sync_line() { assert_eq!(format_check_in_sync(0, 0), "No patches to check."); diff --git a/crates/socket-patch-cli/src/commands/fetch_stage.rs b/crates/socket-patch-cli/src/commands/fetch_stage.rs index 4f64f79d4..2c91ab644 100644 --- a/crates/socket-patch-cli/src/commands/fetch_stage.rs +++ b/crates/socket-patch-cli/src/commands/fetch_stage.rs @@ -1,18 +1,17 @@ //! Shared patch-source staging for the mutating commands (`apply`, `vendor`). //! -//! Resolves where the patch pipeline should read blob/diff artifacts from, +//! Resolves where the patch pipeline should read per-file blobs from, //! downloading what's missing into a transient overlay tempdir. The -//! persistent `.socket/{blobs,diffs}` cache is only ever *read* — +//! persistent `.socket/blobs` cache is only ever *read* — //! downloads land in the tempdir and are discarded when it drops (filling the //! cache is `repair`'s job, keeping these commands read-only against //! `.socket/`). -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use std::path::{Path, PathBuf}; use socket_patch_core::api::blob_fetcher::{ - fetch_missing_blobs, fetch_missing_sources, get_missing_archives, get_missing_blobs, - DownloadMode, FetchMissingBlobsResult, + fetch_missing_blobs, get_missing_blobs, FetchMissingBlobsResult, }; use socket_patch_core::api::client::ApiClient; use socket_patch_core::manifest::schema::PatchManifest; @@ -25,8 +24,7 @@ use crate::ui::{plural, StatusLine}; /// Resolved artifact locations for the patch pipeline. Holds the overlay /// `TempDir` alive — sources become invalid when this is dropped. pub(crate) struct StagedSources { - pub(crate) blobs: PathBuf, - diffs: PathBuf, + blobs: PathBuf, _stage: Option, } @@ -35,29 +33,9 @@ impl StagedSources { pub(crate) fn as_patch_sources(&self) -> PatchSources<'_> { PatchSources { blobs_path: &self.blobs, - diffs_path: Some(&self.diffs), mem_blobs: None, } } - - /// Blob destination for post-stage, on-demand fetches (apply's mismatch - /// blob top-up). When sources are read directly from `.socket/` (no - /// overlay was staged), promote `blobs` to a transient overlay tempdir - /// first — a late download must never land in the persistent - /// `.socket/blobs/` cache (this module's read-only contract). `None` - /// when the overlay cannot be created; the caller skips the fetch and - /// the affected files fail as they would offline. - pub(crate) async fn writable_blobs(&mut self) -> Option<&Path> { - if self._stage.is_none() { - let stage = tempfile::tempdir().ok()?; - let blobs = stage.path().join("blobs"); - tokio::fs::create_dir_all(&blobs).await.ok()?; - overlay_dir(&self.blobs, &blobs).await; - self.blobs = blobs; - self._stage = Some(stage); - } - Some(&self.blobs) - } } /// The staging outcome. @@ -104,47 +82,37 @@ fn format_purl_list(purls: &[&str], max: usize) -> Vec { lines } -/// Singular and plural names of one kind of downloaded artifact. -type Noun = (&'static str, &'static str); -const BLOB: Noun = ("blob", "blobs"); -const DIFF_ARCHIVE: Noun = ("diff archive", "diff archives"); - -/// What a fetch did, one line per non-zero outcome (`Downloaded 2 diff -/// archives`, `1 blob already present locally`), plus the failures (up to -/// five, then `... and N more`) when `with_failures`. The core formatter -/// always says "blob(s)", whatever was fetched. -fn format_fetch_summary( - result: &FetchMissingBlobsResult, - (one, many): Noun, - with_failures: bool, -) -> Vec { +/// What a blob fetch did, one line per non-zero outcome (`Downloaded 2 +/// blobs`, `1 blob already present locally`), plus the failures (up to +/// five, then `... and N more`). +fn format_fetch_summary(result: &FetchMissingBlobsResult) -> Vec { if result.total == 0 { - return vec![format!("All {many} are present locally.")]; + return vec!["All blobs are present locally.".to_string()]; } let mut lines = Vec::new(); if result.downloaded > 0 { lines.push(format!( "Downloaded {}", - plural(result.downloaded, one, many) + plural(result.downloaded, "blob", "blobs") )); } if result.skipped > 0 { lines.push(format!( "{} already present locally", - plural(result.skipped, one, many) + plural(result.skipped, "blob", "blobs") )); } - if with_failures && result.failed > 0 { - lines.extend(format_fetch_failures(result, (one, many))); + if result.failed > 0 { + lines.extend(format_fetch_failures(result)); } lines } -/// `Failed to download N :` and the per-item reasons. -fn format_fetch_failures(result: &FetchMissingBlobsResult, (one, many): Noun) -> Vec { +/// `Failed to download N blobs:` and the per-item reasons. +fn format_fetch_failures(result: &FetchMissingBlobsResult) -> Vec { let mut lines = vec![format!( "Failed to download {}:", - plural(result.failed, one, many) + plural(result.failed, "blob", "blobs") )]; let failed: Vec<_> = result.results.iter().filter(|r| !r.success).collect(); for r in failed.iter().take(5) { @@ -162,80 +130,25 @@ fn format_fetch_failures(result: &FetchMissingBlobsResult, (one, many): Noun) -> /// The disk stager's status line while it downloads what `.socket/` lacks. const DOWNLOADING_ARTIFACTS: &str = "Downloading missing patch artifacts..."; -/// Announce the per-file blob top-up that follows a diff-mode fetch. It -/// runs even when every diff archive arrived — a diff cannot patch a file -/// whose bytes differ from `beforeHash`, and the pipeline then falls back -/// to the blob — so it is worded as a complement, not a failure, unless -/// some archives really were unavailable. -fn format_blob_fallback(diff_failed: usize, blobs: usize) -> String { - let blobs = plural(blobs, "per-file blob", "per-file blobs"); - if diff_failed == 0 { - format!("Also fetching {blobs} (used where a diff does not apply)...") - } else { - format!( - "{} unavailable; fetching {blobs} instead...", - plural(diff_failed, "diff archive", "diff archives") - ) - } -} - -/// The manifest PURLs with no usable local source. A patch is "locally -/// applicable" iff every file it touches has its `after_hash` blob on -/// disk or is covered by the patch's diff archive. A diff covers only files -/// that exist before the patch: a created file (empty `before_hash`) has -/// nothing to diff against, so it always needs its blob. -/// -/// The patch pipeline picks whichever is present per file. Shared by the -/// offline gate (probed against `.socket/`) and the post-download gate +/// The manifest PURLs with no usable local source: some file the patch +/// touches has no `after_hash` blob in `missing_blobs`' directory. Shared by +/// the offline gate (probed against `.socket/`) and the post-download gate /// (probed against the staged overlay). fn patches_without_source<'m>( manifest: &'m PatchManifest, missing_blobs: &HashSet, - missing_diff_archives: &HashSet, ) -> Vec<&'m str> { manifest .patches .iter() - .filter_map(|(purl, record)| { - let diff_present = !missing_diff_archives.contains(&record.uuid); - let files_covered = record.files.values().all(|f| { - !missing_blobs.contains(&f.after_hash) - || (diff_present && !f.before_hash.is_empty()) - }); - if files_covered { - None - } else { - Some(purl.as_str()) - } - }) - .collect() -} - -/// `manifest` cut down to the files a diff archive cannot patch (created -/// files, whose `before_hash` is empty): the blobs a diff-mode fetch still -/// needs even when every diff archive is present. -pub(crate) fn files_diffs_cannot_cover(manifest: &PatchManifest) -> PatchManifest { - let patches = manifest - .patches - .iter() - .filter_map(|(purl, record)| { - let files: HashMap<_, _> = record + .filter(|(_, record)| { + record .files - .iter() - .filter(|(_, f)| f.before_hash.is_empty()) - .map(|(k, v)| (k.clone(), v.clone())) - .collect(); - (!files.is_empty()).then(|| { - let mut record = record.clone(); - record.files = files; - (purl.clone(), record) - }) + .values() + .any(|f| missing_blobs.contains(&f.after_hash)) }) - .collect(); - PatchManifest { - patches, - setup: manifest.setup.clone(), - } + .map(|(purl, _)| purl.as_str()) + .collect() } /// Mirror `src`'s files into `dst` by hardlink (copy fallback). Pre-seeds the @@ -265,10 +178,10 @@ async fn overlay_dir(src: &Path, dst: &Path) { } /// Resolve patch sources for `manifest`: read straight from `.socket/` when -/// everything needed is cached (or `--offline`), else stage an overlay -/// tempdir and fetch the gap through `client` (the run's one API client — -/// building another here repeated its advisory and org-slug resolution). -/// `Err` is a hard setup failure (bad `--download-mode`, tempdir creation); +/// every blob needed is cached (or `--offline`), else stage an overlay +/// tempdir and fetch the missing blobs through `client` (the run's one API +/// client — building another here repeated its advisory and org-slug +/// resolution). `Err` is a hard setup failure (tempdir creation); /// `Ok(Unavailable)` is the soft "cannot proceed" path with diagnostics /// already printed. pub(crate) async fn stage_patch_sources( @@ -279,146 +192,73 @@ pub(crate) async fn stage_patch_sources( ) -> Result { let quiet = common.silent || common.json; let socket_blobs_path = socket_dir.join("blobs"); - let socket_diffs_path = socket_dir.join("diffs"); - let download_mode = DownloadMode::parse(&common.download_mode).map_err(|e| e.to_string())?; - - // Compute per-patch source availability so both the offline guard and - // the `download_needed` decision share the same notion of what's already - // on disk. These probes are read-only. + // Read-only probe of what is already on disk. let missing_blobs = get_missing_blobs(manifest, &socket_blobs_path).await; - let missing_diff_archives = get_missing_archives(manifest, &socket_diffs_path).await; - - let no_source_purls = patches_without_source(manifest, &missing_blobs, &missing_diff_archives); if common.offline { // Offline: bail only if some patch has no usable local source. // Note: with `--force`, the patch pipeline can short-circuit // verification on its own; we still surface the no-source // diagnosis so the user runs `repair` before retrying. + let no_source_purls = patches_without_source(manifest, &missing_blobs); if !no_source_purls.is_empty() { report_offline_missing(common, &no_source_purls, APPLY_OFFLINE_REMEDY); return Ok(StageOutcome::Unavailable); } } - // Decide what (if anything) needs downloading. - // - // The patch pipeline tries sources in the order diff → blob - // locally. We honor `--download-mode` for the primary fetch when there's - // actually a gap to close. Skip the archive fetch entirely when all file - // blobs are already present locally — the pipeline will succeed via the - // blob path, so an archive fetch would be wasted round-trips. Cached - // diff archives can still leave a patch uncovered (a created file), and - // the blob top-up below closes that gap. - let download_needed = !common.offline - && match download_mode { - DownloadMode::File => !missing_blobs.is_empty(), - DownloadMode::Diff if missing_blobs.is_empty() => false, - DownloadMode::Diff => !missing_diff_archives.is_empty() || !no_source_purls.is_empty(), - }; - - if !download_needed { + if common.offline || missing_blobs.is_empty() { return Ok(StageOutcome::Ready(StagedSources { blobs: socket_blobs_path, - diffs: socket_diffs_path, _stage: None, })); } // Stage a transient overlay tempdir that hardlinks every existing - // `.socket/` artifact and receives fresh downloads. The pipeline reads + // `.socket/blobs` entry and receives fresh downloads. The pipeline reads // exclusively from the tempdir; `.socket/` is never mutated. Dropping // `StagedSources` removes the directory and any downloaded bytes. let stage = tempfile::tempdir().map_err(|e| e.to_string())?; let staged = StagedSources { blobs: stage.path().join("blobs"), - diffs: stage.path().join("diffs"), _stage: Some(stage), }; - for dir in [&staged.blobs, &staged.diffs] { - tokio::fs::create_dir_all(dir) - .await - .map_err(|e| e.to_string())?; - } + tokio::fs::create_dir_all(&staged.blobs) + .await + .map_err(|e| e.to_string())?; overlay_dir(&socket_blobs_path, &staged.blobs).await; - overlay_dir(&socket_diffs_path, &staged.diffs).await; // Progress: a transient status line on stderr (stdout is data); the // result lines below are what stays on screen. let mut status = StatusLine::stderr(common.json, common.silent); status.set(DOWNLOADING_ARTIFACTS); - - let sources = staged.as_patch_sources(); - let fetch_result = fetch_missing_sources(manifest, &sources, download_mode, client, None).await; + let fetch_result = fetch_missing_blobs(manifest, &staged.blobs, client, None).await; status.finish(); - - // In diff mode an unavailable archive is routine (the blob top-up - // below covers it), so its failure detail is held back and printed - // only if the patch really ends up with no source. - let primary_noun = match download_mode { - DownloadMode::File => BLOB, - DownloadMode::Diff => DIFF_ARCHIVE, - }; - let defer_failures = download_mode != DownloadMode::File; if !quiet { - for line in format_fetch_summary(&fetch_result, primary_noun, !defer_failures) { + for line in format_fetch_summary(&fetch_result) { eprintln!("{line}"); } } - // For non-file modes, automatically fetch any still-missing file blobs as - // a fallback. Patches that lack the requested mode on the server will - // still apply via the legacy blob path. - // - // With every diff archive already cached, only the files no diff can - // patch are fetched: that is the gap that triggered this download. - let mut blob_fetch_failed = false; - if download_mode != DownloadMode::File { - let created_only; - let blob_scope = if missing_diff_archives.is_empty() { - created_only = files_diffs_cannot_cover(manifest); - &created_only - } else { - manifest - }; - let still_missing_blobs = get_missing_blobs(blob_scope, &staged.blobs).await; - if !still_missing_blobs.is_empty() { - status.set(format_blob_fallback( - fetch_result.failed, - still_missing_blobs.len(), - )); - let blob_result = fetch_missing_blobs(blob_scope, &staged.blobs, client, None).await; - status.finish(); - if !quiet { - for line in format_fetch_summary(&blob_result, BLOB, true) { - eprintln!("{line}"); - } - } - blob_fetch_failed = blob_result.failed > 0; - } - } - // Download failures only matter per patch: bail iff some patch is left - // with no usable source at the staged paths — the same coverage rule as - // the offline gate. Aggregate counters can't decide this (a patch whose - // diff failed may be covered by its blobs and vice versa). - if fetch_result.failed > 0 || blob_fetch_failed { + // with no usable source at the staged path — the same coverage rule as + // the offline gate. + if fetch_result.failed > 0 { let missing_blobs = get_missing_blobs(manifest, &staged.blobs).await; - let missing_diff_archives = get_missing_archives(manifest, &staged.diffs).await; - let uncovered = patches_without_source(manifest, &missing_blobs, &missing_diff_archives); - if !uncovered.is_empty() { + if !patches_without_source(manifest, &missing_blobs).is_empty() { // An error, not progress chatter: prints even under --silent - // (same rule as report_offline_missing above). + // (same rule as report_offline_missing above), with the + // per-blob reasons the quiet summary above held back. if !common.json { - eprintln!( - "Error: Some patch artifacts could not be downloaded; cannot apply patches." - ); - if defer_failures && fetch_result.failed > 0 { - for line in format_fetch_failures(&fetch_result, primary_noun) { + if quiet { + for line in format_fetch_failures(&fetch_result) { eprintln!("{line}"); } } + eprintln!( + "Error: Some patch artifacts could not be downloaded; cannot apply patches." + ); } return Ok(StageOutcome::Unavailable); } @@ -441,6 +281,7 @@ mod tests { use socket_patch_core::api::client::get_api_client_with_overrides; use socket_patch_core::manifest::schema::{PatchFileInfo, PatchRecord}; + use std::collections::HashMap; const UUID: &str = "11111111-1111-4111-8111-111111111111"; // 64 ascii-hex, the shape `is_valid_blob_hash` accepts. @@ -546,10 +387,11 @@ mod tests { ); } - /// A diff archive alone satisfies the disk stager (the pipeline can apply - /// via the diff path), even with every blob missing. + /// A leftover `.socket/diffs/.tar.gz` is not a source: v5 reads + /// only per-file blobs, so offline staging with the blob missing is + /// Unavailable even when the old diff archive is on disk. #[tokio::test] - async fn stage_offline_accepts_diff_archive_as_sole_source() { + async fn stage_offline_ignores_obsolete_diff_archive() { let tmp = tempfile::tempdir().unwrap(); let socket_dir = tmp.path().join(".socket"); std::fs::create_dir_all(socket_dir.join("diffs")).unwrap(); @@ -568,73 +410,9 @@ mod tests { .await .expect("no hard failure"); assert!( - matches!(outcome, StageOutcome::Ready(_)), - "a present diff archive is a usable source for the disk stager" - ); - } - - /// A diff archive cannot patch a file the patch creates (nothing to diff - /// against), so it covers such a patch only together with the created - /// file's blob: without it, offline staging is Unavailable up front - /// instead of passing the gate and failing mid-apply. - #[tokio::test] - async fn stage_offline_diff_archive_does_not_cover_a_created_file() { - let tmp = tempfile::tempdir().unwrap(); - let socket_dir = tmp.path().join(".socket"); - std::fs::create_dir_all(socket_dir.join("diffs")).unwrap(); - std::fs::write( - socket_dir.join("diffs").join(format!("{UUID}.tar.gz")), - b"x", - ) - .unwrap(); - let created = "c".repeat(64); - let mut manifest = manifest_with_one_patch(); - manifest - .patches - .get_mut("pkg:npm/left-pad@1.3.0") - .unwrap() - .files - .insert( - "new.js".to_string(), - PatchFileInfo { - before_hash: String::new(), - after_hash: created.clone(), - }, - ); - - let outcome = - stage_patch_sources(&offline_args(), &manifest, &socket_dir, &offline_client()) - .await - .expect("no hard failure"); - assert!(matches!(outcome, StageOutcome::Unavailable)); - - std::fs::create_dir_all(socket_dir.join("blobs")).unwrap(); - std::fs::write(socket_dir.join("blobs").join(&created), b"new").unwrap(); - let outcome = - stage_patch_sources(&offline_args(), &manifest, &socket_dir, &offline_client()) - .await - .expect("no hard failure"); - assert!( - matches!(outcome, StageOutcome::Ready(_)), - "diff for the modified file + blob for the created one covers the patch" - ); - } - - #[test] - fn files_diffs_cannot_cover_keeps_only_created_files() { - let mut manifest = manifest_with_one_patch(); - assert!(files_diffs_cannot_cover(&manifest).patches.is_empty()); - let record = manifest.patches.get_mut("pkg:npm/left-pad@1.3.0").unwrap(); - record.files.insert( - "new.js".to_string(), - PatchFileInfo { - before_hash: String::new(), - after_hash: "c".repeat(64), - }, + matches!(outcome, StageOutcome::Unavailable), + "an obsolete diff archive must not cover the patch" ); - let cut = files_diffs_cannot_cover(&manifest); - let files: Vec<&String> = cut.patches["pkg:npm/left-pad@1.3.0"].files.keys().collect(); - assert_eq!(files, ["new.js"]); } /// GlobalArgs wired to a guaranteed-unreachable API endpoint: explicit @@ -684,38 +462,6 @@ mod tests { ); } - /// Same coverage rule in file mode: a local diff archive is a usable - /// source (pinned offline by `stage_offline_accepts_diff_archive_as_sole_source`), - /// so a failed blob download must not flip the outcome to Unavailable. - #[tokio::test] - async fn stage_online_file_mode_blob_failure_accepts_local_diff_archive() { - let tmp = tempfile::tempdir().unwrap(); - let socket_dir = tmp.path().join(".socket"); - std::fs::create_dir_all(socket_dir.join("diffs")).unwrap(); - std::fs::write( - socket_dir.join("diffs").join(format!("{UUID}.tar.gz")), - b"x", - ) - .unwrap(); - - let args = GlobalArgs { - download_mode: "file".to_string(), - ..dead_endpoint_args() - }; - let outcome = stage_patch_sources( - &args, - &manifest_with_one_patch(), - &socket_dir, - &dead_endpoint_client(&args).await, - ) - .await - .expect("no hard failure"); - assert!( - matches!(outcome, StageOutcome::Ready(_)), - "a local diff archive covers the patch even when the blob download fails" - ); - } - /// Overshoot guard for the per-patch coverage gate: with no local source /// at all, failed downloads must still yield Unavailable. #[tokio::test] @@ -738,76 +484,6 @@ mod tests { ); } - /// An unknown `--download-mode` is a hard setup failure (Err), not a - /// soft Unavailable. - #[tokio::test] - async fn stage_rejects_unknown_download_mode() { - let tmp = tempfile::tempdir().unwrap(); - let args = GlobalArgs { - download_mode: "bogus".to_string(), - silent: true, - ..GlobalArgs::default() - }; - let Err(err) = stage_patch_sources( - &args, - &manifest_with_one_patch(), - tmp.path(), - &offline_client(), - ) - .await - else { - panic!("an unparseable download mode is a hard failure"); - }; - assert!( - err.contains("bogus"), - "diagnostic names the bad mode: {err}" - ); - } - - /// `writable_blobs` promotes an in-place (no-overlay) source set to a - /// transient overlay: the returned dir is NOT `.socket/blobs`, existing - /// blobs are pre-seeded into it, and a late download that lands there - /// leaves the persistent cache untouched. - #[tokio::test] - async fn writable_blobs_promotes_to_overlay_and_preserves_cache() { - let tmp = tempfile::tempdir().unwrap(); - let socket_dir = tmp.path().join(".socket"); - std::fs::create_dir_all(socket_dir.join("blobs")).unwrap(); - std::fs::write(socket_dir.join("blobs").join(HASH), b"cached").unwrap(); - - let outcome = stage_patch_sources( - &offline_args(), - &manifest_with_one_patch(), - &socket_dir, - &offline_client(), - ) - .await - .expect("no hard failure"); - let StageOutcome::Ready(mut staged) = outcome else { - panic!("fully-cached staging must be Ready"); - }; - - let writable = staged.writable_blobs().await.expect("overlay created"); - assert_ne!( - writable, - socket_dir.join("blobs"), - "late downloads must never target the persistent cache" - ); - assert!( - writable.join(HASH).exists(), - "the overlay is pre-seeded with the cached blobs" - ); - - std::fs::write(writable.join("late-download"), b"new").unwrap(); - assert!( - !socket_dir.join("blobs").join("late-download").exists(), - "a write into the overlay must not appear in .socket/blobs" - ); - // Stable across calls: a second call reuses the same overlay. - let again = staged.writable_blobs().await.unwrap().to_path_buf(); - assert!(again.join("late-download").exists()); - } - /// `overlay_dir` mirrors regular files only, and never clobbers a file /// already present at the destination. #[tokio::test] @@ -914,23 +590,23 @@ mod ui_format_tests { } #[test] - fn fetch_summary_uses_the_right_noun_and_plurals() { + fn fetch_summary_uses_plurals() { assert_eq!( - format_fetch_summary(&result(0, 0, &[]), BLOB, true), + format_fetch_summary(&result(0, 0, &[])), vec!["All blobs are present locally."] ); assert_eq!( - format_fetch_summary(&result(1, 0, &[]), DIFF_ARCHIVE, true), - vec!["Downloaded 1 diff archive"] + format_fetch_summary(&result(1, 0, &[])), + vec!["Downloaded 1 blob"] ); assert_eq!( - format_fetch_summary(&result(7, 1, &[]), BLOB, true), + format_fetch_summary(&result(7, 1, &[])), vec!["Downloaded 7 blobs", "1 blob already present locally"] ); } #[test] - fn fetch_summary_failures_are_optional_and_capped() { + fn fetch_summary_failures_are_capped() { let fails: Vec<(String, String)> = (0..7) .map(|i| (format!("{i}{}", "a".repeat(20)), "404".to_string())) .collect(); @@ -939,19 +615,16 @@ mod ui_format_tests { .map(|(h, e)| (h.as_str(), e.as_str())) .collect(); let r = result(1, 0, &refs); - assert_eq!( - format_fetch_summary(&r, DIFF_ARCHIVE, false), - vec!["Downloaded 1 diff archive"] - ); - let lines = format_fetch_summary(&r, DIFF_ARCHIVE, true); - assert_eq!(lines[1], "Failed to download 7 diff archives:"); + let lines = format_fetch_summary(&r); + assert_eq!(lines[0], "Downloaded 1 blob"); + assert_eq!(lines[1], "Failed to download 7 blobs:"); assert_eq!(lines[2], " - 0aaaaaaaaaaa...: 404"); assert_eq!(lines.last().unwrap(), " ... and 2 more"); assert_eq!(lines.len(), 1 + 1 + 5 + 1); // A multibyte hash is cut by chars, never mid-byte. let r = result(0, 0, &[("é".repeat(20).as_str(), "boom")]); assert_eq!( - format_fetch_failures(&r, BLOB), + format_fetch_failures(&r), vec![ "Failed to download 1 blob:".to_string(), format!(" - {}...: boom", "é".repeat(12)) @@ -959,26 +632,6 @@ mod ui_format_tests { ); } - #[test] - fn blob_fallback_wording() { - assert_eq!( - format_blob_fallback(0, 1), - "Also fetching 1 per-file blob (used where a diff does not apply)..." - ); - assert_eq!( - format_blob_fallback(0, 7), - "Also fetching 7 per-file blobs (used where a diff does not apply)..." - ); - assert_eq!( - format_blob_fallback(1, 3), - "1 diff archive unavailable; fetching 3 per-file blobs instead..." - ); - assert_eq!( - format_blob_fallback(2, 1), - "2 diff archives unavailable; fetching 1 per-file blob instead..." - ); - } - #[test] fn purl_list_caps_at_max_with_remainder() { assert!(format_purl_list(&[], 5).is_empty()); diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index e83707d17..ad79a5ba7 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -1117,7 +1117,6 @@ pub async fn run(args: GetArgs) -> i32 { )], _ => Vec::new(), }; - let download_mode = args.common.download_mode.clone(); // Set to `true` after the first 401/403 from the authenticated // endpoint triggered a rebuild against the public proxy. Plumbed // through to every subsequent telemetry event so we can track the @@ -1211,7 +1210,6 @@ pub async fn run(args: GetArgs) -> i32 { &patch.uuid, &patch.tier, &ecosystem_from_purl(&patch.purl), - &download_mode, fallback_to_proxy, &telemetry, ) @@ -2176,7 +2174,6 @@ fn get_download_params(args: &GetArgs, save_only: bool, persist_blobs: bool) -> global_prefix: args.common.global_prefix.clone(), json: args.common.json, silent: args.common.silent, - download_mode: args.common.download_mode.clone(), all_releases: args.all_releases, strict: args.common.strict, ecosystems: args.common.ecosystems.clone(), @@ -3875,7 +3872,6 @@ mod tests { global_prefix: None, json: true, silent: true, - download_mode: "diff".to_string(), all_releases: false, strict: false, ecosystems: None, @@ -4571,7 +4567,6 @@ mod tests { global_prefix: None, json: true, silent: true, - download_mode: "diff".to_string(), all_releases: false, strict: false, ecosystems: None, @@ -5676,7 +5671,6 @@ mod tests { nested.org.is_none() && nested.api_token.is_none(), "API fields are never threaded through params: the nested apply runs on the run's client" ); - assert_eq!(nested.download_mode, "diff"); assert!(nested.silent, "json || silent params run a quiet apply"); assert!(!nested.json && !nested.dry_run); } diff --git a/crates/socket-patch-cli/src/commands/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs index 6e3591fad..295f556c8 100644 --- a/crates/socket-patch-cli/src/commands/remove.rs +++ b/crates/socket-patch-cli/src/commands/remove.rs @@ -987,9 +987,8 @@ pub async fn run(args: RemoveArgs) -> i32 { ); } } - // Diff archives use the same manifest-uuid keep rule; legacy - // package archives are swept whole (parity with repair and scan - // --prune). + // Obsolete diff and package archives are swept whole (parity with + // repair and scan --prune). for (dir, result) in [("diffs", sweep.diffs), ("packages", sweep.packages)] { if let Some(detail) = sweep_failure(dir, &result) { if loud { diff --git a/crates/socket-patch-cli/src/commands/repair.rs b/crates/socket-patch-cli/src/commands/repair.rs index 345dc9355..9abe6e66c 100644 --- a/crates/socket-patch-cli/src/commands/repair.rs +++ b/crates/socket-patch-cli/src/commands/repair.rs @@ -1,14 +1,13 @@ use clap::Args; use socket_patch_core::api::blob_fetcher::{ - fetch_missing_sources, format_fetch_failures, format_fetch_successes, get_missing_archives, - get_missing_blobs, ArtifactNoun, DownloadMode, BLOB, DIFF_ARCHIVE, PACKAGE_ARCHIVE, + fetch_missing_blobs, format_fetch_failures, format_fetch_successes, get_missing_blobs, + ArtifactNoun, BLOB, DIFF_ARCHIVE, PACKAGE_ARCHIVE, }; use socket_patch_core::api::client::{get_api_client_with_overrides, ApiClient}; use socket_patch_core::manifest::cleanup_blobs::{ format_all_in_use, format_cleanup_result_for, ArtifactReferences, CleanupResult, }; use socket_patch_core::manifest::operations::read_manifest; -use socket_patch_core::patch::apply::PatchSources; use socket_patch_core::telemetry::{ track_patch_repair_failed, track_patch_repaired, TelemetryAuth, }; @@ -16,7 +15,6 @@ use std::path::Path; use std::time::Duration; use crate::args::{apply_env_toggles, parse_bool_flag, GlobalArgs}; -use crate::commands::fetch_stage::files_diffs_cannot_cover; use crate::commands::lock_cli::{acquire_or_emit, error_envelope}; use crate::json_envelope::{Command, Envelope, PatchAction, PatchEvent, Status}; use crate::ui::sweep_failure; @@ -267,7 +265,7 @@ fn format_id_list(ids: &[String], noun: ArtifactNoun, cap: usize) -> Vec lines } -/// `Found 2 missing diff archives` / `Found 1 missing blob`. +/// `Found 2 missing blobs` / `Found 1 missing blob`. fn format_found_missing(n: usize, noun: ArtifactNoun) -> String { format!("Found {}", noun.count(n).replacen(' ', " missing ", 1)) } @@ -352,12 +350,6 @@ fn format_final_line( } } -/// The `.socket/` source directories a download pass writes into. -struct SourcePaths<'a> { - blobs: &'a Path, - diffs: &'a Path, -} - /// What one download pass did: how many artifacts were missing, and how /// many of them it downloaded or failed to. #[derive(Default)] @@ -367,19 +359,18 @@ struct DownloadPass { failed: usize, } -/// Step 1's pass over `missing` (non-empty), the `mode` artifacts `m` +/// Step 1's pass over `missing` (non-empty), the `afterHash` blobs `m` /// references: the `--offline` warning, the `--dry-run` preview, or the -/// download and its result lines. +/// download into `blobs_path` and its result lines. async fn download_pass( args: &RepairArgs, client: &mut Option, m: &socket_patch_core::manifest::schema::PatchManifest, missing: &[String], - mode: DownloadMode, - paths: &SourcePaths<'_>, + blobs_path: &Path, ) -> DownloadPass { let quiet = args.common.json || args.common.silent; - let noun = mode.noun(); + let noun = BLOB; let mut pass = DownloadPass { missing: missing.len(), ..DownloadPass::default() @@ -413,12 +404,7 @@ async fn download_pass( ); } let client = client.as_ref().expect("client built just above"); - let sources = PatchSources { - blobs_path: paths.blobs, - diffs_path: Some(paths.diffs), - mem_blobs: None, - }; - let fetch_result = fetch_missing_sources(m, &sources, mode, client, None).await; + let fetch_result = fetch_missing_blobs(m, blobs_path, client, None).await; status.finish(); pass.downloaded = fetch_result.downloaded; pass.failed = fetch_result.failed; @@ -480,10 +466,6 @@ async fn repair_inner( let socket_dir = crate::args::socket_dir_of(manifest_path, &args.common.cwd); let blobs_path = socket_dir.join("blobs"); - let diffs_path = socket_dir.join("diffs"); - - let download_mode = - DownloadMode::parse(&args.common.download_mode).map_err(|e| e.to_string())?; // `--silent` ("suppress non-error output") must mute the human-readable // progress just like `--json` does — otherwise a silent repair still @@ -505,14 +487,12 @@ async fn repair_inner( let mut env = Envelope::new(Command::Repair); env.dry_run = args.common.dry_run; - // Step 1: Check for and download missing artifacts in the requested - // mode. Counts below refer to whatever kind of artifact was requested - // (file blobs or diff archives). + // Step 1: Check for and download missing per-file blobs. // // VENDORED-in-sync manifest entries are excluded: vendor flows keep // patch content in memory and the committed artifact IS the patch, so - // a fully-vendored project legitimately has no `.socket/blobs|diffs| - // packages` — repair must not re-litter them (or fail trying). The + // a fully-vendored project legitimately has no `.socket/blobs` — + // repair must not re-litter them (or fail trying). The // cleanup phase below still uses the FULL manifest, so it never sweeps // sources an in-place apply may need for rollback. // Loaded ONCE under the lock; the vendored phase below takes the raw @@ -546,29 +526,21 @@ async fn repair_inner( setup: m.setup.clone(), } }); - let missing_artifacts: Vec = match (&scoped_manifest, download_mode) { - (None, _) => Vec::new(), - (Some(m), DownloadMode::File) => get_missing_blobs(m, &blobs_path) + let missing_artifacts: Vec = match &scoped_manifest { + None => Vec::new(), + Some(m) => get_missing_blobs(m, &blobs_path) .await .into_iter() .collect(), - (Some(m), DownloadMode::Diff) => get_missing_archives(m, &diffs_path) - .await - .into_iter() - .collect(), - }; - let noun = download_mode.noun(); - let paths = SourcePaths { - blobs: &blobs_path, - diffs: &diffs_path, }; + let noun = BLOB; // Whether stdout already carries a line, so the blank separators // between sections never open the output (the offline warning goes // to stderr). let mut stdout_started = !args.common.offline || missing_artifacts.is_empty(); let primary = match scoped_manifest.as_ref() { Some(m) if !missing_artifacts.is_empty() => { - download_pass(args, client, m, &missing_artifacts, download_mode, &paths).await + download_pass(args, client, m, &missing_artifacts, &blobs_path).await } _ => { if !quiet { @@ -577,24 +549,6 @@ async fn repair_inner( DownloadPass::default() } }; - // A diff archive has no delta for a file the patch creates, so in diff - // mode that file's blob is downloaded too: without it, a later - // `apply --offline` cannot apply the patch. - let created = match (&scoped_manifest, download_mode) { - (Some(m), DownloadMode::Diff) => { - let created = files_diffs_cannot_cover(m); - let missing: Vec = get_missing_blobs(&created, &blobs_path) - .await - .into_iter() - .collect(); - if missing.is_empty() { - DownloadPass::default() - } else { - download_pass(args, client, &created, &missing, DownloadMode::File, &paths).await - } - } - _ => DownloadPass::default(), - }; let missing_count = primary.missing; downloaded_count += primary.downloaded; download_failed_count += primary.failed; @@ -629,9 +583,10 @@ async fn repair_inner( ); } - // Step 2: Clean up unused artifacts across all three directories. The - // summary prints once all three passes are in, so "nothing to clean - // up" is only said when all three really are empty. + // Step 2: Clean up unused artifacts across all three directories + // (`.socket/diffs` and `.socket/packages` are obsolete: every file in + // them goes). The summary prints once all three passes are in, so + // "nothing to clean up" is only said when all three really are empty. if let (false, Some(manifest)) = (args.download_only, manifest.as_ref()) { let sweep = ArtifactReferences::for_apply(manifest) .sweep(&socket_dir, args.common.dry_run) @@ -686,24 +641,13 @@ async fn repair_inner( // so a piped stdout never ends in a stray blank line when the // line itself goes to stderr. let other_failure = matches!(env.status, Status::PartialFailure | Status::Error); - let failed = download_failed_count + created.failed; - let line = if download_failed_count > 0 && created.failed > 0 { - format!( - "Repair finished with errors: {} and {} were not downloaded.", - noun.count(download_failed_count), - BLOB.count(created.failed) - ) - } else if download_failed_count > 0 { - format_final_line( - download_failed_count, - other_failure, - noun, - args.common.dry_run, - ) - } else { - format_final_line(created.failed, other_failure, BLOB, args.common.dry_run) - }; - if failed > 0 || other_failure { + let line = format_final_line( + download_failed_count, + other_failure, + noun, + args.common.dry_run, + ); + if download_failed_count > 0 || other_failure { if stdout_started { eprintln!(); } @@ -733,7 +677,9 @@ async fn repair_inner( env.record( PatchEvent::artifact(action).with_details(serde_json::json!({ "count": count, - "mode": download_mode.as_tag(), + // Constant since v5 (blobs are the only download); kept so + // the event's shape is unchanged. + "mode": "file", })), ); } @@ -744,28 +690,6 @@ async fn repair_inner( )); env.mark_partial_failure(); } - if created.downloaded > 0 - || (!args.common.offline && args.common.dry_run && created.missing > 0) - { - let (action, count) = if args.common.dry_run { - (PatchAction::Verified, created.missing) - } else { - (PatchAction::Downloaded, created.downloaded) - }; - env.record( - PatchEvent::artifact(action).with_details(serde_json::json!({ - "count": count, - "mode": DownloadMode::File.as_tag(), - })), - ); - } - if created.failed > 0 { - env.record(PatchEvent::artifact(PatchAction::Failed).with_error( - "download_failed", - format!("{} failed to download", BLOB.count(created.failed)), - )); - env.mark_partial_failure(); - } if blobs_cleaned > 0 { let cleanup_action = if args.common.dry_run { PatchAction::Verified @@ -782,7 +706,7 @@ async fn repair_inner( Ok(( env, RepairCounts { - downloaded: downloaded_count + created.downloaded, + downloaded: downloaded_count, cleaned: blobs_cleaned, bytes_freed, }, @@ -878,7 +802,6 @@ mod tests { manifest_path: ".socket/manifest.json".to_string(), offline: true, json: true, - download_mode: "file".to_string(), ..GlobalArgs::default() }, download_only: false, @@ -1003,9 +926,10 @@ mod tests { ); } - /// Cleanup must sweep orphaned diff archives and every legacy - /// `.socket/packages/` archive (nothing reads them, so even one named - /// after a manifest UUID goes) in addition to blobs, and the reclaimed + /// Cleanup must sweep every obsolete `.socket/diffs/` and + /// `.socket/packages/` archive (nothing reads them since v5, so even + /// one named after a manifest UUID goes) in addition to blobs, and the + /// reclaimed /// counts/bytes from all three directories must aggregate into a single /// `RepairCounts`. Guards against a regression where a cleanup pass uses /// the wrong directory or drops its tallies. @@ -1014,9 +938,10 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let socket = make_socket(tmp.path()); - // A referenced diff archive (named after the manifest UUID) must - // survive; a legacy package archive under the same name must not. - write_archive(&socket, "diffs", REFERENCED_UUID, b"kept-diff"); + // Archives named after the manifest UUID are swept too: the diff + // download path is gone, so a stale referenced diff archive is junk. + let stale_diff = b"stale diff"; // 10 bytes + write_archive(&socket, "diffs", REFERENCED_UUID, stale_diff); let legacy_pkg = b"legacy package"; // 14 bytes write_archive(&socket, "packages", REFERENCED_UUID, legacy_pkg); @@ -1042,20 +967,16 @@ mod tests { .await .expect("repair_inner"); - // Both orphans and the legacy package archive go; the referenced - // diff archive stays. - assert_eq!( - counts.cleaned, 3, - "orphans and legacy archives should be swept" - ); + // Every archive goes, referenced or not. + assert_eq!(counts.cleaned, 4, "all obsolete archives should be swept"); assert_eq!( counts.bytes_freed, - (orphan_diff.len() + orphan_pkg.len() + legacy_pkg.len()) as u64, + (orphan_diff.len() + orphan_pkg.len() + legacy_pkg.len() + stale_diff.len()) as u64, "bytes_freed must aggregate diff + package reclaim" ); // Cleanup is reported as a SINGLE batched `removed` artifact event whose // `details.count` carries the tally — so the event-count summary is 1 - // (`Summary::bump` increments once per event), and the 3-artifact count + // (`Summary::bump` increments once per event), and the 4-artifact count // is asserted via `counts.cleaned` above and the event details here. assert_eq!(env.summary.removed, 1, "one batched removal event"); let removed = env @@ -1069,14 +990,14 @@ mod tests { .as_ref() .and_then(|d| d.get("count")) .and_then(serde_json::Value::as_u64), - Some(3), - "the batched removal event must report 3 swept artifacts" + Some(4), + "the batched removal event must report 4 swept artifacts" ); - assert!(socket - .join("diffs") - .join(format!("{REFERENCED_UUID}.tar.gz")) - .exists()); + assert!( + !socket.join("diffs").exists(), + "the emptied obsolete diffs dir is removed" + ); assert!(!socket .join("packages") .join(format!("{REFERENCED_UUID}.tar.gz")) @@ -1193,7 +1114,7 @@ mod tests { "22222222-2222-4222-8222-222222222222", "11111111-1111-4111-8111-111111111111", ]); - // Diff-mode UUIDs print in full, in sorted order. + // UUID-keyed archive ids print in full, in sorted order. assert_eq!( format_id_list(&uuids, DIFF_ARCHIVE, 5), vec![ @@ -1222,21 +1143,14 @@ mod tests { #[test] fn found_missing_line() { assert_eq!(format_found_missing(1, BLOB), "Found 1 missing blob"); - assert_eq!( - format_found_missing(12, DIFF_ARCHIVE), - "Found 12 missing diff archives" - ); + assert_eq!(format_found_missing(12, BLOB), "Found 12 missing blobs"); } #[test] fn offline_warning_singular_and_plural() { assert_eq!( - format_offline_warning( - &ids(&["11111111-1111-4111-8111-111111111111"]), - DIFF_ARCHIVE - ), - "Warning: 1 diff archive is missing (offline mode - not downloading):\n\ - \x20 - 11111111-1111-4111-8111-111111111111" + format_offline_warning(&ids(&["a"]), BLOB), + "Warning: 1 blob is missing (offline mode - not downloading):\n - a" ); assert_eq!( format_offline_warning(&ids(&["b", "a"]), BLOB), @@ -1315,8 +1229,8 @@ mod tests { "Dry run: no changes made." ); assert_eq!( - format_final_line(1, false, DIFF_ARCHIVE, false), - "Repair finished with errors: 1 diff archive was not downloaded." + format_final_line(1, false, BLOB, false), + "Repair finished with errors: 1 blob was not downloaded." ); assert_eq!( format_final_line(2, true, BLOB, false), diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index 02bcdf968..38d973563 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -2343,8 +2343,7 @@ pub(crate) async fn rollback_patches_inner( // locally-drifted) root copy says nothing about a still-patched // nested duplicate, whose restore still needs the blob. Probing // only a representative copy skipped the download and wedged the - // online rollback with a mid-run `MissingBlob` failure. Mirrors - // apply's `mismatch_blob_gaps`. + // online rollback with a mid-run `MissingBlob` failure. let mut pkg_paths = all_packages_multi .get(purl) .expect("gate manifest holds only attempted targets, which the crawler discovered") diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index b02e9cfdd..43d42cb85 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -820,7 +820,6 @@ fn download_params(args: &ScanArgs, save_only: bool, json: bool, silent: bool) - global_prefix: args.common.global_prefix.clone(), json, silent, - download_mode: args.common.download_mode.clone(), all_releases: args.all_releases, strict: args.common.strict, ecosystems: args.common.ecosystems.clone(), diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 29bcad9cd..f522c439f 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -4663,7 +4663,6 @@ mod dispatch_tests { }; let sources = PatchSources { blobs_path: tmp.path(), - diffs_path: None, mem_blobs: None, }; let service = GlobalArgs { @@ -5047,7 +5046,6 @@ mod variant_probe_tests { }; let sources = PatchSources { blobs_path: tmp.path(), - diffs_path: None, mem_blobs: None, }; @@ -5130,7 +5128,6 @@ mod variant_probe_tests { }; let sources = PatchSources { blobs_path: tmp.path(), - diffs_path: None, mem_blobs: None, }; @@ -5243,7 +5240,6 @@ mod variant_probe_tests { let common = dry_run_over(tmp.path(), &site); let sources = PatchSources { blobs_path: tmp.path(), - diffs_path: None, mem_blobs: None, }; let mut state = VendorState::default(); @@ -5295,7 +5291,6 @@ mod variant_probe_tests { let common = dry_run_over(tmp.path(), &site); let sources = PatchSources { blobs_path: tmp.path(), - diffs_path: None, mem_blobs: None, }; @@ -5354,7 +5349,6 @@ mod variant_probe_tests { let common = dry_run_over(tmp.path(), &site); let sources = PatchSources { blobs_path: tmp.path(), - diffs_path: None, mem_blobs: None, }; diff --git a/crates/socket-patch-cli/src/commands/vendored_backend/mod.rs b/crates/socket-patch-cli/src/commands/vendored_backend/mod.rs index 2b0461f30..043ad08ce 100644 --- a/crates/socket-patch-cli/src/commands/vendored_backend/mod.rs +++ b/crates/socket-patch-cli/src/commands/vendored_backend/mod.rs @@ -73,7 +73,6 @@ impl<'a> VendoredBackend<'a> { let blobs = req.socket_dir.join("blobs"); let sources = socket_patch_core::patch::apply::PatchSources { blobs_path: &blobs, - diffs_path: None, mem_blobs: None, }; let records = &req.manifest.patches; diff --git a/crates/socket-patch-cli/src/json_envelope.rs b/crates/socket-patch-cli/src/json_envelope.rs index 68bfcdfe5..15ff84fe5 100644 --- a/crates/socket-patch-cli/src/json_envelope.rs +++ b/crates/socket-patch-cli/src/json_envelope.rs @@ -405,11 +405,11 @@ pub enum PatchAction { /// Patch-source strategy used to apply a file. Mirrors the existing /// `socket_patch_core::patch::apply::AppliedVia` enum, but lives here so -/// the JSON layer doesn't depend on core internals. +/// the JSON layer doesn't depend on core internals. `blob` is the only +/// value since v5 removed the diff download path. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub enum AppliedVia { - Diff, Blob, } @@ -417,7 +417,6 @@ impl AppliedVia { pub fn from_core(via: socket_patch_core::patch::apply::AppliedVia) -> Self { use socket_patch_core::patch::apply::AppliedVia as Core; match via { - Core::Diff => AppliedVia::Diff, Core::Blob => AppliedVia::Blob, } } @@ -984,7 +983,7 @@ mod tests { PatchEventFile { path: "package/index.js".into(), verified: true, - applied_via: Some(AppliedVia::Diff), + applied_via: Some(AppliedVia::Blob), }, PatchEventFile { path: "package/lib/util.js".into(), @@ -998,7 +997,7 @@ mod tests { assert_eq!(files.len(), 2); assert_eq!(files[0]["path"], "package/index.js"); assert_eq!(files[0]["verified"], true); - assert_eq!(files[0]["appliedVia"], "diff"); + assert_eq!(files[0]["appliedVia"], "blob"); assert_eq!(files[1]["appliedVia"], "blob"); } diff --git a/crates/socket-patch-cli/tests/apply/apply_network.rs b/crates/socket-patch-cli/tests/apply/apply_network.rs index 7284a5e2f..38ec86f17 100644 --- a/crates/socket-patch-cli/tests/apply/apply_network.rs +++ b/crates/socket-patch-cli/tests/apply/apply_network.rs @@ -5,7 +5,8 @@ //! Verifies: //! - `apply` (default, online) fetches missing blobs from the API //! and writes them to an OS tempdir (NOT `.socket/`). -//! - `--download-mode file` falls back to the per-file blob endpoint. +//! - a cold-cache apply fetches only per-file blobs, never a diff +//! archive (v5 removed the diff download path). //! - `apply` against installed packages writes patched content to //! node_modules and leaves `.socket/` byte-identical. @@ -130,18 +131,6 @@ async fn apply_online_fetches_missing_blob_and_patches_file() { .respond_with(ResponseTemplate::new(200).set_body_bytes(after.to_vec())) .mount(&mock) .await; - // The diff/package endpoints might be queried first (default mode is - // `diff`). 404 them so the fetcher falls back to the blob endpoint. - Mock::given(method("GET")) - .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/diff/{uuid}"))) - .respond_with(ResponseTemplate::new(404)) - .mount(&mock) - .await; - Mock::given(method("GET")) - .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/package/{uuid}"))) - .respond_with(ResponseTemplate::new(404)) - .mount(&mock) - .await; let tmp = tempfile::tempdir().expect("tempdir"); write_root_package_json(tmp.path()); @@ -155,7 +144,7 @@ async fn apply_online_fetches_missing_blob_and_patches_file() { let socket = tmp.path().join(".socket"); write_manifest_with_patch(&socket, purl, uuid, &before_hash, &after_hash); - let (code, stdout, stderr) = run_apply(tmp.path(), &mock.uri(), &["--download-mode", "file"]); + let (code, stdout, stderr) = run_apply(tmp.path(), &mock.uri(), &[]); assert_eq!( code, 0, "apply must succeed; stdout={stdout}; stderr={stderr}" @@ -181,6 +170,22 @@ async fn apply_online_fetches_missing_blob_and_patches_file() { .map(|r| r.url.path().to_string()) .collect::>() ); + // Per-file blobs are the only download: a cold cache must never + // request a diff (or legacy package) archive. + assert!( + requests + .iter() + .all(|r| !r.url.path().contains("/diff") && !r.url.path().contains("/package/")), + "apply must request only blobs; got requests={:?}", + requests + .iter() + .map(|r| r.url.path().to_string()) + .collect::>() + ); + assert!( + !socket.join("diffs").exists(), + "apply must not create .socket/diffs/" + ); // The fetch path must have actually applied the patch (not silently // no-op'd to a green exit). Assert the JSON summary, not just exit code. let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); @@ -473,8 +478,7 @@ async fn apply_hash_mismatch_default_warns_and_applies_strict_fails() { }; // DEFAULT: the mismatch is overwritten with the full verified patched - // content (the diff strategy would self-skip; the blob is hash-gated to - // afterHash) and surfaced as a warning event — exit 0. + // content (the blob is hash-gated to afterHash) and surfaced as a warning event — exit 0. let tmp = fixture(); let out = Command::new(binary()) .args(["apply", "--json", "--offline"]) @@ -701,13 +705,12 @@ async fn apply_uses_locally_cached_blob_without_fetching() { } // --------------------------------------------------------------------------- -// Mismatch + diff-mode sources: the full blob is redownloaded on demand. +// Mismatch: the full blob is downloaded and applied. // --------------------------------------------------------------------------- -/// A mismatched file cannot be patched from a partial source (the diff -/// strategy needs the exact before-bytes), so the default mismatch policy -/// redownloads the FULL afterHash blob and applies that — even when a -/// local source archive made the stage step skip downloading. +/// The default mismatch policy applies the FULL afterHash blob to a +/// mismatched file. A leftover legacy package archive is not a source, so +/// the stage step still downloads the blob. #[tokio::test] async fn apply_mismatch_redownloads_full_blob_and_applies() { let after = b"after\n"; @@ -741,9 +744,8 @@ async fn apply_mismatch_redownloads_full_blob_and_applies() { &expected_before_hash, &after_hash, ); - // A LOCAL package archive exists (so the stage step downloads nothing) - // but carries no entry for index.js — only the blob can produce the - // patched bytes, and no blob is staged. + // A leftover LOCAL package archive exists, but nothing reads it: only + // the blob can produce the patched bytes, and no blob is cached. let packages = socket.join("packages"); std::fs::create_dir_all(&packages).unwrap(); { @@ -964,14 +966,11 @@ async fn apply_online_ignores_legacy_package_archive_when_downloads_fail() { /// Two physical copies of one PURL (a root copy plus a nested duplicate): /// the root copy already carries the afterHash bytes, the nested one was -/// locally modified (matches NEITHER hash). A cached diff archive makes the -/// stage step download nothing, so the on-demand mismatch top-up is the -/// ONLY chance to fetch the full afterHash blob the nested copy needs under -/// the default warn-and-overwrite policy — and copies drift independently, -/// so the top-up must probe EVERY copy, not just the first (clean, root) -/// one. +/// locally modified (matches NEITHER hash). The stage step fetches the full +/// afterHash blob, and the default warn-and-overwrite policy must apply it +/// to EVERY copy, not just the first (clean, root) one. #[tokio::test] -async fn mismatch_blob_topup_probes_every_copy_of_a_duplicated_package() { +async fn mismatch_overwrites_every_copy_of_a_duplicated_package() { let before = b"before\n"; let after = b"after\n"; let before_hash = git_sha256(before); @@ -1006,36 +1005,6 @@ async fn mismatch_blob_topup_probes_every_copy_of_a_duplicated_package() { let socket = tmp.path().join(".socket"); write_manifest_with_patch(&socket, purl, uuid, &before_hash, &after_hash); - // A cached diff archive: the stage step concludes nothing needs - // downloading (default `--download-mode diff`), so sources are read - // in place and no whole-manifest blob fallback runs. The archive's - // content is never consulted for these copies (already-patched needs - // nothing; a mismatched file can only take the full blob). - let diffs = socket.join("diffs"); - std::fs::create_dir_all(&diffs).unwrap(); - { - use std::io::Write as _; - let mut builder = tar::Builder::new(flate2::write::GzEncoder::new( - std::fs::File::create(diffs.join(format!("{uuid}.tar.gz"))).unwrap(), - flate2::Compression::default(), - )); - let mut header = tar::Header::new_gnu(); - let bytes = b"unrelated"; - header.set_size(bytes.len() as u64); - header.set_mode(0o644); - header.set_cksum(); - builder - .append_data(&mut header, "other.js", &bytes[..]) - .unwrap(); - builder - .into_inner() - .unwrap() - .finish() - .unwrap() - .flush() - .unwrap(); - } - let (code, stdout, stderr) = run_apply(tmp.path(), &mock.uri(), &[]); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); assert_eq!( @@ -1048,12 +1017,12 @@ async fn mismatch_blob_topup_probes_every_copy_of_a_duplicated_package() { ); assert_eq!(v["summary"]["failed"], 0, "no copy may fail.\nstdout={v:#}"); - // The nested copy's blob was fetched on demand… + // The blob was fetched… let requests = mock.received_requests().await.unwrap(); let blob_path = format!("/v0/orgs/{ORG_SLUG}/patches/blob/{after_hash}"); assert!( requests.iter().any(|r| r.url.path() == blob_path), - "the top-up must fetch the blob the nested copy needs; got {:?}", + "the stage step must fetch the blob the nested copy needs; got {:?}", requests .iter() .map(|r| r.url.path().to_string()) @@ -1075,7 +1044,7 @@ async fn mismatch_blob_topup_probes_every_copy_of_a_duplicated_package() { "the nested copy must be overwritten with the verified patched bytes" ); - // Apply stays read-only against the persistent cache: the on-demand + // Apply stays read-only against the persistent cache: the fetched // blob lands in a transient overlay, never `.socket/blobs/`. let blobs_dir = socket.join("blobs"); if blobs_dir.exists() { diff --git a/crates/socket-patch-cli/tests/apply/covgap_commands_apply.rs b/crates/socket-patch-cli/tests/apply/covgap_commands_apply.rs index c838970b9..f4c60e0bd 100644 --- a/crates/socket-patch-cli/tests/apply/covgap_commands_apply.rs +++ b/crates/socket-patch-cli/tests/apply/covgap_commands_apply.rs @@ -3,7 +3,7 @@ //! The uncovered surface of apply.rs is dominated by HUMAN-mode output — //! nearly every existing apply test passes `--json` and/or `--silent` — plus //! two never-fired go-drift variants, the whole Bun layout arm, the -//! `--check --json` envelopes, and the mismatch-blob prefetch messages. +//! `--check --json` envelopes. //! Themes: //! //! 1. `apply --check --json`: the in-sync success envelope and the @@ -12,22 +12,18 @@ //! (hand-built `go.mod` fixtures — the other variants are covered); //! 3. `reconcile_local_go`'s human report (`Removed` / `Would remove` //! N stale go patch redirect(s)); -//! 4. mismatch-blob prefetch messages: the `--offline` warning, the -//! transient "Downloading ..." status resolving to the "Downloaded N -//! full patched blob(s)" result line, and the broken-TMPDIR -//! transient-stage failure warning; -//! 5. human-mode output block: "No patches to apply.", the no-matching- +//! 4. human-mode output block: "No patches to apply.", the no-matching- //! packages warning, the npm per-package failure line, the dry-run //! "already patched" count, `--verbose` per-file labels, the //! pnpm/bun/vlt layout notes, and the corrupt-manifest-under-PnP //! fall-through; -//! 6. gem fallback-home skip surfacing on human stderr; -//! 7. apply-loop wiring: a vendored release-variant base with its +//! 5. gem fallback-home skip surfacing on human stderr; +//! 6. apply-loop wiring: a vendored release-variant base with its //! installed tree PRESENT is skipped (not re-patched), and a qualified //! singleton whose record holds only NEW files (no representative) //! is treated as installed and applied; a ledger-only vendored //! project with no manifest is the `noManifest` no-op; -//! 8. the `apply --dry-run --vex` skip message. +//! 7. the `apply --dry-run --vex` skip message. //! //! Binary-driven throughout (`common::run_with_env`, `SOCKET_*`-scrubbed //! children), hand-written camelCase manifests, git-sha256 oracle, @@ -36,8 +32,6 @@ use std::path::{Path, PathBuf}; use serde_json::{json, Value}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; use crate::common; @@ -127,35 +121,6 @@ fn install_gem(root: &Path, leaf: &str, file_rel: &str, contents: &[u8]) -> Path file } -/// Write a cached `.socket/diffs/.tar.gz` whose mere existence makes -/// the stage step conclude nothing needs downloading (default -/// `--download-mode diff`) — its content is never consulted for a -/// mismatched file, which can only take the full blob. -fn write_diff_archive(root: &Path, uuid: &str) { - use std::io::Write as _; - let diffs = root.join(".socket").join("diffs"); - std::fs::create_dir_all(&diffs).unwrap(); - let mut builder = tar::Builder::new(flate2::write::GzEncoder::new( - std::fs::File::create(diffs.join(format!("{uuid}.tar.gz"))).unwrap(), - flate2::Compression::default(), - )); - let mut header = tar::Header::new_gnu(); - let bytes = b"unrelated"; - header.set_size(bytes.len() as u64); - header.set_mode(0o644); - header.set_cksum(); - builder - .append_data(&mut header, "other.js", &bytes[..]) - .unwrap(); - builder - .into_inner() - .unwrap() - .finish() - .unwrap() - .flush() - .unwrap(); -} - // ═══════════════════ 1. `--check --json` envelopes ═══════════════════ const GO_PURL: &str = "pkg:golang/example.com/mod@v1.0.0"; @@ -383,159 +348,12 @@ fn reconcile_dry_run_says_would_remove_and_touches_nothing() { ); } -// ═══════════ 4. mismatch-blob prefetch messages ═══════════ - +// Shared fixture bytes for the sections below. const MM_BEFORE: &[u8] = b"pristine content\n"; const MM_AFTER: &[u8] = b"patched content\n"; const MM_LOCAL: &[u8] = b"locally modified content\n"; -const MM_UUID: &str = "62626262-6262-4262-8262-626262626262"; - -/// npm package whose only patched file matches NEITHER hash, a cached diff -/// archive (so staging succeeds with direct `.socket/` paths), and an -/// EMPTY blobs dir — the shape that forces the on-demand afterHash-blob -/// prefetch. Returns the drifted file's path. -fn mismatch_prefetch_fixture(root: &Path, pkg: &str) -> PathBuf { - write_root_package_json(root); - let file = install_npm_pkg(root, pkg, "1.0.0", MM_LOCAL); - write_manifest( - root, - json!({ - format!("pkg:npm/{pkg}@1.0.0"): patch_record( - MM_UUID, - json!({ "package/index.js": { - "beforeHash": git_sha256(MM_BEFORE), - "afterHash": git_sha256(MM_AFTER), - }}), - ) - }), - ); - write_diff_archive(root, MM_UUID); - // Fixture sanity: the on-disk bytes must match neither hash, or the - // mismatch-prefetch path under test is never taken. - assert_ne!(git_sha256(MM_LOCAL), git_sha256(MM_BEFORE)); - assert_ne!(git_sha256(MM_LOCAL), git_sha256(MM_AFTER)); - file -} - -/// `--offline` + a mismatched file whose afterHash blob is not staged: -/// human mode warns that the blob cannot be fetched, the file fails to -/// apply (per-package failure line — the npm-branch human failure output), -/// and the drifted bytes stay untouched. -#[test] -fn offline_mismatch_blob_gap_warns_and_fails_in_human_mode() { - let tmp = tempfile::tempdir().unwrap(); - let file = mismatch_prefetch_fixture(tmp.path(), "mmoff"); - - let (code, _stdout, stderr) = run_apply(tmp.path(), &["--offline"], &[]); - assert_eq!(code, 1, "the blob-less mismatch must fail; stderr={stderr}"); - assert!( - stderr.contains("the full patched blob, but --offline prevents fetching"), - "the offline prefetch warning must print; stderr={stderr}" - ); - assert!( - stderr.contains("Failed to patch pkg:npm/mmoff@1.0.0"), - "the npm-branch human failure line must name the package; stderr={stderr}" - ); - assert_eq!( - std::fs::read(&file).unwrap(), - MM_LOCAL, - "a failed apply must leave the drifted bytes untouched" - ); -} - -/// Online, human mode: the transient "Downloading ..." status resolves to -/// the "Downloaded 1 full patched blob for mismatched files" result line, the blob is fetched from the -/// API into a transient overlay (never `.socket/blobs/`), and the mismatch -/// is warn-overwritten with the verified patched bytes. -#[tokio::test] -async fn online_mismatch_prefetch_prints_download_line_in_human_mode() { - let after_hash = git_sha256(MM_AFTER); - let mock = MockServer::start().await; - Mock::given(method("GET")) - .and(path(format!("/v0/orgs/test-org/patches/blob/{after_hash}"))) - .respond_with(ResponseTemplate::new(200).set_body_bytes(MM_AFTER.to_vec())) - .mount(&mock) - .await; - - let tmp = tempfile::tempdir().unwrap(); - let file = mismatch_prefetch_fixture(tmp.path(), "mmnet"); - - let (code, _stdout, stderr) = run_apply( - tmp.path(), - &[], - &[ - ("SOCKET_API_URL", &mock.uri()), - ("SOCKET_API_TOKEN", "fake-token-for-test"), - ("SOCKET_ORG_SLUG", "test-org"), - ], - ); - assert_eq!( - code, 0, - "the default policy warn-overwrites the mismatch; stderr={stderr}" - ); - assert!( - stderr.contains("Downloaded 1 full patched blob for mismatched files"), - "the human progress line must print before the prefetch; stderr={stderr}" - ); - assert!( - stderr.contains("did not match the patch's expected original content"), - "the overwrite must be surfaced as the mismatch warning; stderr={stderr}" - ); - assert_eq!( - std::fs::read(&file).unwrap(), - MM_AFTER, - "the mismatched file must carry the verified patched bytes" - ); - // Apply stays read-only against the persistent cache. - let blobs: Vec<_> = std::fs::read_dir(tmp.path().join(".socket/blobs")) - .unwrap() - .collect(); - assert!( - blobs.is_empty(), - "the on-demand blob must land in a transient overlay, never .socket/blobs/: {blobs:?}" - ); -} - -/// When the transient blob overlay cannot be staged (tempdir creation -/// fails — TMPDIR points at a nonexistent dir), apply prints the -/// diagnostic warning instead of failing silently, then the mismatched -/// file fails to apply. Unix-only: TMPDIR drives `env::temp_dir()`. -#[cfg(unix)] -#[test] -fn broken_tmpdir_surfaces_transient_blob_stage_warning() { - let tmp = tempfile::tempdir().unwrap(); - let file = mismatch_prefetch_fixture(tmp.path(), "mmtmp"); - let broken_tmpdir = tmp.path().join("no-such-tmpdir"); - assert!(!broken_tmpdir.exists()); - - // Online (not --offline) so the run reaches writable_blobs(); the API - // URL is a dead loopback port, but the else-branch returns before any - // fetch is attempted. - let (code, _stdout, stderr) = run_apply( - tmp.path(), - &[], - &[ - ("TMPDIR", broken_tmpdir.to_str().unwrap()), - ("SOCKET_API_URL", "http://127.0.0.1:1"), - ], - ); - assert_eq!(code, 1, "the blob-less mismatch must fail; stderr={stderr}"); - assert!( - stderr.contains("could not stage a transient blob directory"), - "the staging failure must be diagnosed, not silent; stderr={stderr}" - ); - assert!( - stderr.contains("Failed to patch pkg:npm/mmtmp@1.0.0"), - "stderr={stderr}" - ); - assert_eq!( - std::fs::read(&file).unwrap(), - MM_LOCAL, - "a failed apply must leave the drifted bytes untouched" - ); -} -// ═══════════ 5. human-mode apply output block ═══════════ +// ═══════════ 4. human-mode apply output block ═══════════ /// The empty-scope clean success prints "No patches to apply." — the /// postinstall-hook UX for fresh projects (json/silent suppress the line). @@ -887,7 +705,7 @@ fn corrupt_manifest_under_pnp_layout_reports_manifest_error_not_refusal() { ); } -// ═══════════ 6. gem fallback-home skip on human stderr ═══════════ +// ═══════════ 5. gem fallback-home skip on human stderr ═══════════ /// Unix-only: the fallback home comes from a fake `gem` binary on PATH /// (the `in_process_gem_fallback_home.rs` fixture shape, re-run in HUMAN @@ -1027,7 +845,7 @@ mod gem_fallback_home_human { } } -// ═══════════ 7. apply-loop wiring: vendored base skip + new-file-only variant ═══════════ +// ═══════════ 6. apply-loop wiring: vendored base skip + new-file-only variant ═══════════ const GEM_BASE_PURL: &str = "pkg:gem/rack@3.1.0"; const GEM_PRISTINE: &[u8] = b"module Rack\n VERSION = '3.1.0'\nend\n"; @@ -1229,7 +1047,7 @@ fn qualified_singleton_with_only_new_files_is_attempted_and_applied() { ); } -// ═══════════ 8. dry-run --vex skip message ═══════════ +// ═══════════ 7. dry-run --vex skip message ═══════════ /// `apply --dry-run --vex ` in human mode: nothing was applied, so /// VEX generation is skipped WITH the explanatory message, and no diff --git a/crates/socket-patch-cli/tests/cli/api_client_errors_e2e.rs b/crates/socket-patch-cli/tests/cli/api_client_errors_e2e.rs index a4175802d..8d6984eab 100644 --- a/crates/socket-patch-cli/tests/cli/api_client_errors_e2e.rs +++ b/crates/socket-patch-cli/tests/cli/api_client_errors_e2e.rs @@ -510,13 +510,7 @@ async fn repair_with_blob_404_marks_failure_in_summary() { .unwrap(); let out = crate::common::hermetic_command(&binary()) - .args([ - "repair", - "--json", - "--download-mode", - "file", - "--download-only", - ]) + .args(["repair", "--json", "--download-only"]) .current_dir(tmp.path()) .env("SOCKET_API_URL", mock.uri()) .env("SOCKET_API_TOKEN", "fake-token") diff --git a/crates/socket-patch-cli/tests/cli_global_args.rs b/crates/socket-patch-cli/tests/cli_global_args.rs index b7e1f50a3..b4a37f678 100644 --- a/crates/socket-patch-cli/tests/cli_global_args.rs +++ b/crates/socket-patch-cli/tests/cli_global_args.rs @@ -56,7 +56,7 @@ const DUMMY_IDENTIFIER: &str = "80630680-4da6-45f9-bba8-b888e0ffd58c"; /// `GlobalArgs` field. Parsing-succeeds-only (`is_ok`) is not enough: it /// would stay green if a flag were silently dropped, bound to the wrong /// field, or mapped to a no-op. Each value is deliberately chosen to differ -/// from the field's default (e.g. `--download-mode file`, not `diff`) so +/// from the field's default (e.g. `--ecosystems npm,pypi`, not unset) so /// the assertion can distinguish "bound" from "left at default". fn global_flag_cases() -> Vec<(&'static str, Option<&'static str>, fn(&GlobalArgs))> { vec![ @@ -84,9 +84,6 @@ fn global_flag_cases() -> Vec<(&'static str, Option<&'static str>, fn(&GlobalArg Some(&["npm".to_string(), "pypi".to_string()][..]) ) }), - ("--download-mode", Some("file"), |c| { - assert_eq!(c.download_mode, "file") - }), ("--maven-config", Some("none"), |c| { assert_eq!(c.maven_config.as_deref(), Some("none")) }), @@ -221,7 +218,6 @@ fn global_flag_cases_cover_every_global_field() { org: _, proxy_url: _, ecosystems: _, - download_mode: _, offline: _, global: _, global_prefix: _, @@ -243,11 +239,11 @@ fn global_flag_cases_cover_every_global_field() { maven_config: _, } = common; - // 27 fields ↔ 27 long-flag cases. Bump both this count and add a case when + // 26 fields ↔ 26 long-flag cases. Bump both this count and add a case when // the destructure above forces you to add a field. assert_eq!( global_flag_cases().len(), - 27, + 26, "every GlobalArgs field needs a long-flag case in global_flag_cases()", ); @@ -448,7 +444,6 @@ fn env_vars_populate_global_args() { ("SOCKET_ORG_SLUG", "env-org"), ("SOCKET_PROXY_URL", "https://env-proxy.example.com"), ("SOCKET_ECOSYSTEMS", "npm,gem"), - ("SOCKET_DOWNLOAD_MODE", "file"), ("SOCKET_VENDOR_SOURCE", "service"), ("SOCKET_VENDOR_URL", "https://env-vendor.example.com"), ("SOCKET_PATCH_SERVER_URL", "http://localhost:4026"), @@ -495,7 +490,6 @@ fn env_vars_populate_global_args() { args.common.ecosystems.as_deref(), Some(&["npm".to_string(), "gem".to_string()][..]) ); - assert_eq!(args.common.download_mode, "file"); assert_eq!(args.common.vendor_source, "service"); assert_eq!( args.common.vendor_url.as_deref(), @@ -714,8 +708,8 @@ use socket_patch_cli::args::GLOBAL_ARG_ENV_VARS as GLOBAL_ENV_VARS; /// but `SOCKET_CWD=`, `SOCKET_GLOBAL_PREFIX=`, `SOCKET_LOCK_TIMEOUT=` and /// `SOCKET_ECOSYSTEMS=` (the same blank-without-unsetting shell/CI idiom) /// still aborted every subcommand at clap-parse time ("a value is required" / -/// "cannot parse integer from empty string"), and empty -/// `SOCKET_DOWNLOAD_MODE=` / `SOCKET_MANIFEST_PATH=` leaked `""` past the +/// "cannot parse integer from empty string"), and an empty +/// `SOCKET_MANIFEST_PATH=` leaked `""` past the /// documented defaults. The binary now scrubs empty `GlobalArgs` env vars /// before clap parses (`args::scrub_empty_env_vars` in `main`), /// restoring the documented CLI > env > default precedence for blank vars. @@ -737,7 +731,6 @@ fn empty_nonbool_env_vars_do_not_crash_the_binary() { "SOCKET_GLOBAL_PREFIX", "SOCKET_LOCK_TIMEOUT", "SOCKET_ECOSYSTEMS", - "SOCKET_DOWNLOAD_MODE", // Crash-class without the scrub: the vendor-source validator rejects // `""` outright; the two URL knobs would leak `Some("")` downstream. "SOCKET_VENDOR_SOURCE", @@ -913,7 +906,6 @@ fn production_defaults_populate_when_unset() { assert_eq!(c.manifest_path, ".socket/manifest.json"); assert_eq!(c.api_url, None, "no clap default — resolved in core"); assert_eq!(c.proxy_url, None, "no clap default — resolved in core"); - assert_eq!(c.download_mode, "diff"); assert_eq!(c.vendor_source, "service"); assert!(c.vendor_url.is_none()); assert!(c.patch_server_url.is_none()); diff --git a/crates/socket-patch-cli/tests/cli_parse_apply.rs b/crates/socket-patch-cli/tests/cli_parse_apply.rs index 642c70db3..9cfafbff2 100644 --- a/crates/socket-patch-cli/tests/cli_parse_apply.rs +++ b/crates/socket-patch-cli/tests/cli_parse_apply.rs @@ -86,7 +86,6 @@ fn defaults_match_contract() { assert!(!a.force); assert!(!a.common.json); assert!(!a.common.verbose); - assert_eq!(a.common.download_mode, "diff"); // The remaining global defaults from the contract table, pinned so a // dangerous default-value drift cannot slip through silently — e.g. @@ -167,14 +166,6 @@ fn vex_passthrough_flags() { assert_only_true(&a, &["vex_no_verify", "vex_compact"]); } -/// The `download_mode` default is pinned separately — it's the one -/// field whose default value diverges across subcommands historically, -/// so we assert it explicitly to catch drift. -#[test] -fn default_download_mode_is_diff() { - assert_eq!(parse_apply(&[]).common.download_mode, "diff"); -} - /// The `manifest_path` default is contract — many scripts hard-code /// `.socket/manifest.json` as the canonical location. #[test] @@ -497,89 +488,20 @@ fn ecosystems_single_value() { } // --------------------------------------------------------------------------- -// --download-mode — the parse layer passes tokens through verbatim. +// --download-mode was removed in v5.0 (patch content is always fetched as +// per-file blobs): no alias, no warning, a plain unknown-argument error. // --------------------------------------------------------------------------- #[test] -fn download_mode_diff() { - assert_eq!( - parse_apply(&["--download-mode", "diff"]) - .common - .download_mode, - "diff" - ); -} - -#[test] -fn download_mode_package() { - assert_eq!( - parse_apply(&["--download-mode", "package"]) - .common - .download_mode, - "package" - ); -} - -#[test] -fn download_mode_file() { - assert_eq!( - parse_apply(&["--download-mode", "file"]) - .common - .download_mode, - "file" - ); -} - -/// Values pass through verbatim — no lowercasing, trimming, or aliasing at the -/// parse layer. `package` must not silently normalize to `diff`, etc. This -/// guards against a parser that quietly coerces input to a default. -#[test] -fn download_mode_values_are_not_normalized() { - // Case is preserved verbatim (parse does not canonicalize). - assert_eq!( - parse_apply(&["--download-mode", "DIFF"]) - .common - .download_mode, - "DIFF" - ); - // diff/file are the valid runtime tokens; `package` (removed) is still - // passed through verbatim by the parse layer. - for token in ["diff", "package", "file"] { - let got = parse_apply(&["--download-mode", token]) - .common - .download_mode; - assert_eq!( - got, token, - "download-mode `{token}` must round-trip exactly" - ); - } -} - -/// CONTRACT GAP (documented, not a hardening of a passing behavior): the -/// accepted runtime values are `diff | file` (`blob` is an alias; `package` -/// is rejected as removed), but the arg is a plain `String` with no -/// `value_parser`, so clap accepts ANY value at parse time. Invalid values are -/// only rejected later by `DownloadMode::parse` (socket-patch-core -/// `api/blob_fetcher.rs`, called from `commands/fetch_stage.rs`). This test pins -/// the *current* parse-layer behavior so a future move to a real -/// `value_parser`/enum (which WOULD reject here) is a deliberate, visible -/// change rather than a silent one. If the enum is enforced at parse, flip the -/// expectation to assert an `InvalidValue` error. -#[test] -fn download_mode_invalid_value_is_only_caught_at_runtime() { - match try_parse(&["socket-patch", "apply", "--download-mode", "totally-bogus"]) { - Ok(cli) => match cli.command { - Commands::Apply(a) => assert_eq!( - a.common.download_mode, "totally-bogus", - "parse layer currently passes unknown download modes through verbatim" - ), - _ => panic!("expected Apply"), - }, - Err(err) => panic!( - "parse layer unexpectedly rejected an unknown download-mode (kind={:?}); \ - if the enum is now enforced at parse, update this test to assert InvalidValue", - err.kind() - ), +fn download_mode_flag_is_removed() { + for value in ["file", "diff"] { + match try_parse(&["socket-patch", "apply", "--download-mode", value]) { + Ok(_) => panic!("--download-mode {value} must be rejected"), + Err(err) => { + assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); + assert_eq!(err.exit_code(), 2, "a usage error exits 2"); + } + } } } diff --git a/crates/socket-patch-cli/tests/cli_parse_get.rs b/crates/socket-patch-cli/tests/cli_parse_get.rs index b33dac655..f0c770ae2 100644 --- a/crates/socket-patch-cli/tests/cli_parse_get.rs +++ b/crates/socket-patch-cli/tests/cli_parse_get.rs @@ -38,7 +38,6 @@ const SOCKET_ENV_VARS: &[&str] = &[ "SOCKET_ORG_SLUG", "SOCKET_PROXY_URL", "SOCKET_ECOSYSTEMS", - "SOCKET_DOWNLOAD_MODE", "SOCKET_VENDOR_SOURCE", "SOCKET_VENDOR_URL", "SOCKET_PATCH_SERVER_URL", @@ -132,7 +131,6 @@ struct Snap { org: Option, proxy_url: Option, ecosystems: Option>, - download_mode: String, vendor_source: String, vendor_url: Option, patch_server_url: Option, @@ -167,7 +165,6 @@ fn snapshot(a: &GetArgs) -> Snap { org: a.common.org.clone(), proxy_url: a.common.proxy_url.clone(), ecosystems: a.common.ecosystems.clone(), - download_mode: a.common.download_mode.clone(), vendor_source: a.common.vendor_source.clone(), vendor_url: a.common.vendor_url.clone(), patch_server_url: a.common.patch_server_url.clone(), @@ -210,7 +207,6 @@ fn expected_defaults(identifier: &str) -> Snap { org: None, proxy_url: None, // no clap default — resolved in core ecosystems: None, - download_mode: "diff".to_string(), vendor_source: "service".to_string(), vendor_url: None, patch_server_url: None, @@ -261,13 +257,6 @@ fn all_releases_flag_sets_all_releases() { assert_eq!(snapshot(&a), want); } -#[test] -#[serial_test::serial] -fn default_download_mode_is_diff() { - let a = parse_get(&["some-id"]); - assert_eq!(snapshot(&a), expected_defaults("some-id")); -} - // --- Positional -------------------------------------------------------------- #[test] @@ -443,34 +432,6 @@ fn removed_no_apply_alias_is_a_usage_error() { assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); } -// --- download-mode ----------------------------------------------------------- - -#[test] -#[serial_test::serial] -fn download_mode_package() { - let a = parse_get(&["some-id", "--download-mode", "package"]); - let mut want = expected_defaults("some-id"); - want.download_mode = "package".to_string(); - assert_eq!(snapshot(&a), want); -} - -#[test] -#[serial_test::serial] -fn download_mode_diff() { - let a = parse_get(&["some-id", "--download-mode", "diff"]); - // Explicitly passing the default value must still parse to exactly defaults. - assert_eq!(snapshot(&a), expected_defaults("some-id")); -} - -#[test] -#[serial_test::serial] -fn download_mode_file() { - let a = parse_get(&["some-id", "--download-mode", "file"]); - let mut want = expected_defaults("some-id"); - want.download_mode = "file".to_string(); - assert_eq!(snapshot(&a), want); -} - // --- `--mode` selector (v4.0) -------------------------------------------- // // `get --mode ` reuses scan's `ScanMode` value-enum diff --git a/crates/socket-patch-cli/tests/cli_parse_repair.rs b/crates/socket-patch-cli/tests/cli_parse_repair.rs index 2cfcc60de..c08c72d87 100644 --- a/crates/socket-patch-cli/tests/cli_parse_repair.rs +++ b/crates/socket-patch-cli/tests/cli_parse_repair.rs @@ -1,11 +1,8 @@ //! CLI contract tests for the `repair` subcommand. //! -//! These tests pin the public clap parser surface for `RepairArgs`. In v3.0 -//! `repair`'s `--download-mode` aligns with every other command (default -//! `"diff"`); the legacy `"file"` default was retired so the surface stays -//! uniform. Users that need legacy per-file blob downloads opt in with -//! `--download-mode file`. The `gc` alias was removed in v5 and must stay -//! a parse error. +//! These tests pin the public clap parser surface for `RepairArgs`. v5.0 +//! removed `--download-mode` (repair always fetches per-file blobs) and the +//! `gc` alias; both must stay parse errors. //! //! See `crates/socket-patch-cli/CLI_CONTRACT.md` for the full repair table. //! @@ -17,8 +14,8 @@ //! satisfy these assertions even if the corresponding CLI default //! (`default_value`/`default_value_t`) regressed or a flag's action broke — //! the env value would mask the bug and the test would pass for the wrong -//! reason (e.g. an exported `SOCKET_DOWNLOAD_MODE=diff` keeps the default -//! assertion green even if the clap `default_value` were changed to `"file"`). +//! reason (e.g. an exported `SOCKET_DOWNLOAD_ONLY=true` keeps a flag +//! assertion green even if the flag's action broke). //! To make the assertions test *argv parsing* rather than the ambient //! environment, every parse runs with the full set of `SOCKET_*` vars scrubbed //! (see [`EnvScrub`]). Because the environment is process-global, every test is @@ -30,7 +27,6 @@ use std::path::PathBuf; use clap::Parser; use socket_patch_cli::commands::repair::RepairArgs; use socket_patch_cli::{Cli, Commands}; -use socket_patch_core::api::blob_fetcher::DownloadMode; /// Every `SOCKET_*` env var that clap consults while parsing `repair` (its own /// `--download-only` flag plus the flattened `GlobalArgs`). If any leaks in @@ -45,7 +41,6 @@ const SOCKET_ENV_VARS: &[&str] = &[ "SOCKET_ORG_SLUG", "SOCKET_PROXY_URL", "SOCKET_ECOSYSTEMS", - "SOCKET_DOWNLOAD_MODE", "SOCKET_VENDOR_SOURCE", "SOCKET_VENDOR_URL", "SOCKET_PATCH_SERVER_URL", @@ -132,7 +127,6 @@ struct Snap { org: Option, proxy_url: Option, ecosystems: Option>, - download_mode: String, vendor_source: String, vendor_url: Option, patch_server_url: Option, @@ -160,7 +154,6 @@ fn snapshot(a: &RepairArgs) -> Snap { org: a.common.org.clone(), proxy_url: a.common.proxy_url.clone(), ecosystems: a.common.ecosystems.clone(), - download_mode: a.common.download_mode.clone(), vendor_source: a.common.vendor_source.clone(), vendor_url: a.common.vendor_url.clone(), patch_server_url: a.common.patch_server_url.clone(), @@ -195,7 +188,6 @@ fn expected_defaults() -> Snap { org: None, proxy_url: None, // no clap default — resolved in core ecosystems: None, - download_mode: "diff".to_string(), vendor_source: "service".to_string(), vendor_url: None, patch_server_url: None, @@ -221,26 +213,12 @@ fn repair_defaults_match_contract() { let args = parse_repair(&[]); // Pin the *entire* default surface in one shot against the independent - // oracle. The previous version only checked download_mode, cwd, + // oracle. The previous version only checked cwd, // manifest_path, dry_run, offline, download_only and json — leaving // api_url, proxy_url, verbose, silent, yes, lock_timeout, // debug, no_telemetry, global, global_prefix, ecosystems, api_token and // org free to regress unnoticed. assert_eq!(snapshot(&args), expected_defaults()); - - // v3.0: repair's --download-mode default aligns with every other - // command (was "file" in v2.x). Users that need the legacy per-file - // blob behavior opt in with `--download-mode file`. - assert_eq!(args.common.download_mode, "diff"); - // The clap layer stores a raw String with no value_parser, so the - // assertion above only proves the literal echoes. Bind it to the real - // runtime validator so a regression that changes what `"diff"` *means* - // (or stops recognizing it) fails here too. - assert_eq!( - DownloadMode::parse(&args.common.download_mode), - Ok(DownloadMode::Diff), - "default download_mode must be the real Diff variant" - ); } #[test] @@ -299,64 +277,14 @@ fn repair_json_flag() { assert_eq!(snapshot(&args), expected); } +/// v5.0 removed `--download-mode`: repair always fetches per-file blobs. #[test] #[serial_test::serial] -fn repair_download_mode_file() { - let args = parse_repair(&["--download-mode", "file"]); - let mut expected = expected_defaults(); - expected.download_mode = "file".to_string(); - assert_eq!(snapshot(&args), expected); - // The legacy per-file blob opt-in this test exists to protect: assert - // `"file"` is a mode the engine actually recognizes, not just an echoed - // string. If `File` support is dropped, this fails loudly. - assert_eq!( - DownloadMode::parse(&args.common.download_mode), - Ok(DownloadMode::File) - ); -} - -#[test] -#[serial_test::serial] -fn repair_download_mode_diff() { - let args = parse_repair(&["--download-mode", "diff"]); - let mut expected = expected_defaults(); - expected.download_mode = "diff".to_string(); - assert_eq!(snapshot(&args), expected); - assert_eq!( - DownloadMode::parse(&args.common.download_mode), - Ok(DownloadMode::Diff) - ); -} - -#[test] -#[serial_test::serial] -fn repair_download_mode_package_removed() { - // `package` still parses at the clap layer (any string does) but the - // runtime validator rejects it with a removal message, not a generic - // unknown-mode error. - let args = parse_repair(&["--download-mode", "package"]); - let mut expected = expected_defaults(); - expected.download_mode = "package".to_string(); - assert_eq!(snapshot(&args), expected); - assert!(DownloadMode::parse(&args.common.download_mode) - .unwrap_err() - .contains("removed")); -} - -#[test] -#[serial_test::serial] -fn repair_download_mode_rejects_unknown_at_runtime() { - // The clap surface accepts ANY string for --download-mode (no - // value_parser); validation is deferred to `DownloadMode::parse` in the - // run path. Pin that two-layer contract: a bogus mode parses at the clap - // layer but is rejected by the validator. Without this, a test asserting - // only the clap echo would pass even if every mode were silently valid. - let args = parse_repair(&["--download-mode", "bogus"]); - assert_eq!(args.common.download_mode, "bogus"); - assert!( - DownloadMode::parse(&args.common.download_mode).is_err(), - "unknown download mode must be rejected by the runtime validator" - ); +fn repair_download_mode_flag_is_removed() { + match Cli::try_parse_from(["socket-patch", "repair", "--download-mode", "file"]) { + Ok(_) => panic!("--download-mode must be rejected"), + Err(err) => assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument), + } } /// Regression: an exported-but-empty `SOCKET_DOWNLOAD_ONLY=` — the shell/CI diff --git a/crates/socket-patch-cli/tests/cli_parse_rollback.rs b/crates/socket-patch-cli/tests/cli_parse_rollback.rs index 8e4b7dcde..2fa006d83 100644 --- a/crates/socket-patch-cli/tests/cli_parse_rollback.rs +++ b/crates/socket-patch-cli/tests/cli_parse_rollback.rs @@ -82,7 +82,6 @@ fn defaults_no_positional() { assert!(!args.common.verbose); // Remaining global defaults the contract pins but the original test omitted. assert_eq!(args.common.proxy_url, None); // default applied in core resolver - assert_eq!(args.common.download_mode, "diff"); assert!(!args.common.yes); assert_eq!(args.common.lock_timeout, None); assert!(!args.common.debug); @@ -269,12 +268,6 @@ fn proxy_url_long() { ); } -#[test] -fn download_mode_long() { - let args = parse_rollback(&["--download-mode", "package"]); - assert_eq!(args.common.download_mode, "package"); -} - #[test] fn lock_timeout_long() { let args = parse_rollback(&["--lock-timeout", "30"]); diff --git a/crates/socket-patch-cli/tests/cli_parse_scan.rs b/crates/socket-patch-cli/tests/cli_parse_scan.rs index f3293b7d3..94c10cf8a 100644 --- a/crates/socket-patch-cli/tests/cli_parse_scan.rs +++ b/crates/socket-patch-cli/tests/cli_parse_scan.rs @@ -4,13 +4,10 @@ //! short form, and default. Changes that flip a default or rename a flag //! must break these tests so the regression is caught before release. //! -//! Two defaults are especially load-bearing and explicitly asserted: -//! -//! * `--batch-size` has no parse-time default: unset, `scan` picks it per -//! endpoint at run time (500 on the authenticated API, 100 on the public -//! proxy), so an explicit value must stay distinguishable from none. -//! * `--download-mode` defaults to `"diff"`. This diverges from `repair`'s -//! default and is a silent-regression risk if flipped. +//! One default is especially load-bearing and explicitly asserted: +//! `--batch-size` has no parse-time default: unset, `scan` picks it per +//! endpoint at run time (500 on the authenticated API, 100 on the public +//! proxy), so an explicit value must stay distinguishable from none. use clap::Parser; use socket_patch_cli::commands::scan::{resolve_mode_flags, ScanArgs, ScanMode}; @@ -20,8 +17,7 @@ use socket_patch_cli::{Cli, Commands}; /// "SOCKET_*"` binding. clap reads these at parse time whenever the matching /// flag is absent, so an ambient value silently overrides the code-level /// `default_value`. That defeats the entire purpose of these snapshot tests: -/// a regression that flips a `default_value` (e.g. `--download-mode` → -/// `"package"`, or `--batch-size` → `50`) would stay GREEN on any machine +/// a regression that flips a `default_value` (e.g. `--batch-size` → `50`) would stay GREEN on any machine /// whose shell/CI happens to export the old value, and the "default" tests /// would be asserting the environment, not the parser. We therefore clear /// the whole set before every parse and restore it after, under `#[serial]` @@ -37,7 +33,6 @@ const SCAN_ENV_VARS: &[&str] = &[ "SOCKET_CWD", "SOCKET_SCAN_PACKAGES", "SOCKET_DEBUG", - "SOCKET_DOWNLOAD_MODE", "SOCKET_DRY_RUN", "SOCKET_ECOSYSTEMS", "SOCKET_GLOBAL", @@ -121,10 +116,6 @@ fn defaults_match_contract() { args.batch_size, None, "--batch-size has no parse-time default (resolved per endpoint at run time)" ); - assert_eq!( - args.common.download_mode, "diff", - "--download-mode default is \"diff\"" - ); // All other defaults from the scan table. assert_eq!(args.common.cwd, std::path::PathBuf::from(".")); @@ -349,27 +340,6 @@ fn ecosystems_csv_single() { assert_eq!(args.common.ecosystems, Some(vec!["npm".to_string()])); } -#[test] -#[serial_test::serial] -fn download_mode_diff() { - let args = parse_scan(&["--download-mode", "diff"]); - assert_eq!(args.common.download_mode, "diff"); -} - -#[test] -#[serial_test::serial] -fn download_mode_package() { - let args = parse_scan(&["--download-mode", "package"]); - assert_eq!(args.common.download_mode, "package"); -} - -#[test] -#[serial_test::serial] -fn download_mode_file() { - let args = parse_scan(&["--download-mode", "file"]); - assert_eq!(args.common.download_mode, "file"); -} - #[test] #[serial_test::serial] fn unknown_flag_fails() { diff --git a/crates/socket-patch-cli/tests/cli_parse_vendor.rs b/crates/socket-patch-cli/tests/cli_parse_vendor.rs index 7208af9a0..515ff2bbe 100644 --- a/crates/socket-patch-cli/tests/cli_parse_vendor.rs +++ b/crates/socket-patch-cli/tests/cli_parse_vendor.rs @@ -43,7 +43,6 @@ const SOCKET_ENV_VARS: &[&str] = &[ "SOCKET_ORG_SLUG", "SOCKET_PROXY_URL", "SOCKET_ECOSYSTEMS", - "SOCKET_DOWNLOAD_MODE", "SOCKET_VENDOR_SOURCE", "SOCKET_VENDOR_URL", "SOCKET_PATCH_SERVER_URL", @@ -166,7 +165,6 @@ struct Snap { org: Option, proxy_url: Option, ecosystems: Option>, - download_mode: String, offline: bool, global: bool, global_prefix: Option, @@ -196,7 +194,6 @@ fn snapshot(a: &VendorArgs) -> Snap { org: a.common.org.clone(), proxy_url: a.common.proxy_url.clone(), ecosystems: a.common.ecosystems.clone(), - download_mode: a.common.download_mode.clone(), offline: a.common.offline, global: a.common.global, global_prefix: a.common.global_prefix.clone(), @@ -235,7 +232,6 @@ fn expected_defaults() -> Snap { org: None, proxy_url: None, // no clap default — resolved in core ecosystems: None, - download_mode: "diff".to_string(), offline: false, global: false, global_prefix: None, diff --git a/crates/socket-patch-cli/tests/cli_parse_vex.rs b/crates/socket-patch-cli/tests/cli_parse_vex.rs index 25e184bcd..23e5a15ab 100644 --- a/crates/socket-patch-cli/tests/cli_parse_vex.rs +++ b/crates/socket-patch-cli/tests/cli_parse_vex.rs @@ -41,7 +41,6 @@ const SOCKET_ENV_VARS: &[&str] = &[ "SOCKET_ORG_SLUG", "SOCKET_PROXY_URL", "SOCKET_ECOSYSTEMS", - "SOCKET_DOWNLOAD_MODE", "SOCKET_VENDOR_SOURCE", "SOCKET_VENDOR_URL", "SOCKET_PATCH_SERVER_URL", @@ -201,7 +200,6 @@ struct Snap { org: Option, proxy_url: Option, ecosystems: Option>, - download_mode: String, vendor_source: String, vendor_url: Option, patch_server_url: Option, @@ -236,7 +234,6 @@ fn snapshot(a: &VexArgs) -> Snap { org: a.common.org.clone(), proxy_url: a.common.proxy_url.clone(), ecosystems: a.common.ecosystems.clone(), - download_mode: a.common.download_mode.clone(), vendor_source: a.common.vendor_source.clone(), vendor_url: a.common.vendor_url.clone(), patch_server_url: a.common.patch_server_url.clone(), @@ -278,7 +275,6 @@ fn expected_defaults() -> Snap { org: None, proxy_url: None, // no clap default — resolved in core ecosystems: None, - download_mode: "diff".to_string(), vendor_source: "service".to_string(), vendor_url: None, patch_server_url: None, diff --git a/crates/socket-patch-cli/tests/coverage_fix_apply_silent_mute_exit.rs b/crates/socket-patch-cli/tests/coverage_fix_apply_silent_mute_exit.rs index dd60945f6..9261d673f 100644 --- a/crates/socket-patch-cli/tests/coverage_fix_apply_silent_mute_exit.rs +++ b/crates/socket-patch-cli/tests/coverage_fix_apply_silent_mute_exit.rs @@ -155,6 +155,11 @@ fn apply_silent_online_download_failure_keeps_error_output() { "--silent must keep the download-failure error (errors only, \ never nothing); stderr was: {stderr:?}" ); + assert!( + chatter.iter().any(|l| l.contains("Failed to download")), + "--silent must keep the per-blob failure reasons with the error; \ + stderr was: {stderr:?}" + ); } /// Overshoot guard: under `--json` the envelope is the machine channel — diff --git a/crates/socket-patch-cli/tests/covgap_commands_get.rs b/crates/socket-patch-cli/tests/covgap_commands_get.rs index 09688c68d..0a741f2b0 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_get.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_get.rs @@ -97,7 +97,6 @@ fn default_args(identifier: &str, cwd: &Path) -> GetArgs { global: false, global_prefix: None, json: true, - download_mode: "diff".to_string(), ..socket_patch_cli::args::GlobalArgs::default() }, identifier: identifier.to_string(), @@ -294,7 +293,6 @@ fn engine_params(root: &Path) -> DownloadParams { global_prefix: None, json: true, silent: true, - download_mode: "diff".to_string(), strict: false, ecosystems: None, persist_blobs: true, diff --git a/crates/socket-patch-cli/tests/diff_created_file_e2e.rs b/crates/socket-patch-cli/tests/diff_created_file_e2e.rs deleted file mode 100644 index 50b7b879e..000000000 --- a/crates/socket-patch-cli/tests/diff_created_file_e2e.rs +++ /dev/null @@ -1,282 +0,0 @@ -//! A diff archive only carries deltas for files that exist before the -//! patch: a file the patch CREATES (empty `beforeHash`) has nothing to -//! diff against, so the patch service leaves it out and the pipeline can -//! only apply it from its after-blob (or a package archive). These tests -//! pin that the disk stager and `repair` both treat a diff archive as -//! covering only the files it can actually patch: -//! -//! - `apply --offline` with just the diff archive on disk fails closed -//! with the "no local source" report and leaves every file untouched, -//! instead of passing the gate and failing mid-apply; -//! - online `apply` with the diff archive cached still fetches the -//! created file's blob; -//! - a default (diff-mode) `repair` also downloads the created file's -//! blob, so a later `apply --offline` succeeds. - -use std::path::{Path, PathBuf}; -use std::process::Command; - -use flate2::write::GzEncoder; -use flate2::Compression; -use qbsdiff::Bsdiff; -use sha2::{Digest, Sha256}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const ORG_SLUG: &str = "test-org"; -const UUID: &str = "67676767-6767-4767-8767-676767676767"; -const PURL: &str = "pkg:npm/created-file-test@1.0.0"; -const BEFORE: &[u8] = b"module.exports = 'before, and long enough to diff';\n"; -const AFTER: &[u8] = b"module.exports = 'after!, and long enough to diff';\n"; -const CREATED: &[u8] = b"module.exports = 'a brand new file';\n"; - -fn binary() -> PathBuf { - env!("CARGO_BIN_EXE_socket-patch").into() -} - -fn git_sha256(content: &[u8]) -> String { - let header = format!("blob {}\0", content.len()); - let mut hasher = Sha256::new(); - hasher.update(header.as_bytes()); - hasher.update(content); - hex::encode(hasher.finalize()) -} - -fn run_cli(root: &Path, argv: &[&str], mock_uri: Option<&str>) -> (i32, String, String) { - let mut cmd = Command::new(binary()); - cmd.args(argv).current_dir(root); - for (key, _) in std::env::vars_os() { - if key.to_string_lossy().starts_with("SOCKET_") - && key.to_string_lossy() != "SOCKET_NO_CONFIG" - { - cmd.env_remove(&key); - } - } - cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); - // Offline runs get a dead endpoint: any request they make fails. - let api_url = mock_uri.unwrap_or("http://127.0.0.1:1"); - cmd.env("SOCKET_API_URL", api_url) - .env("SOCKET_API_TOKEN", "fake-token-for-test") - .env("SOCKET_ORG_SLUG", ORG_SLUG); - let out = cmd.output().expect("run socket-patch"); - ( - out.status.code().unwrap_or(-1), - String::from_utf8_lossy(&out.stdout).into_owned(), - String::from_utf8_lossy(&out.stderr).into_owned(), - ) -} - -/// The diff archive the service serves for this patch: a bsdiff delta for -/// the modified file and nothing for the created one. -fn diff_archive() -> Vec { - let mut delta = Vec::new(); - Bsdiff::new(BEFORE, AFTER) - .compare(std::io::Cursor::new(&mut delta)) - .unwrap(); - let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default())); - let mut header = tar::Header::new_gnu(); - header.set_size(delta.len() as u64); - header.set_mode(0o644); - header.set_cksum(); - builder - .append_data(&mut header, "index.js", delta.as_slice()) - .unwrap(); - builder.into_inner().unwrap().finish().unwrap() -} - -/// An npm project with the unpatched package installed and a manifest -/// whose patch modifies `index.js` and creates `new.js` (sorted after -/// `index.js`, so a mid-apply failure would leave `index.js` patched). Returns the -/// installed package dir. -fn seed_project(root: &Path) -> PathBuf { - std::fs::write( - root.join("package.json"), - r#"{"name":"created-file-root","version":"0.0.0"}"#, - ) - .unwrap(); - let pkg = root.join("node_modules").join("created-file-test"); - std::fs::create_dir_all(&pkg).unwrap(); - std::fs::write( - pkg.join("package.json"), - r#"{"name":"created-file-test","version":"1.0.0"}"#, - ) - .unwrap(); - std::fs::write(pkg.join("index.js"), BEFORE).unwrap(); - - let socket = root.join(".socket"); - std::fs::create_dir_all(&socket).unwrap(); - std::fs::write( - socket.join("manifest.json"), - serde_json::to_vec_pretty(&serde_json::json!({ - "patches": { - PURL: { - "uuid": UUID, - "exportedAt": "2026-01-01T00:00:00Z", - "files": { - "package/index.js": { - "beforeHash": git_sha256(BEFORE), - "afterHash": git_sha256(AFTER), - }, - "package/new.js": { - "beforeHash": "", - "afterHash": git_sha256(CREATED), - } - }, - "vulnerabilities": {}, - "description": "creates a file", - "license": "MIT", - "tier": "free", - } - } - })) - .unwrap(), - ) - .unwrap(); - pkg -} - -fn seed_cached_diff_archive(root: &Path) { - let diffs = root.join(".socket").join("diffs"); - std::fs::create_dir_all(&diffs).unwrap(); - std::fs::write(diffs.join(format!("{UUID}.tar.gz")), diff_archive()).unwrap(); -} - -async fn mount_blob(mock: &MockServer, content: &'static [u8]) { - Mock::given(method("GET")) - .and(path(format!( - "/v0/orgs/{ORG_SLUG}/patches/blob/{}", - git_sha256(content) - ))) - .respond_with(ResponseTemplate::new(200).set_body_bytes(content.to_vec())) - .mount(mock) - .await; -} - -fn assert_fully_patched(pkg: &Path) { - assert_eq!(std::fs::read(pkg.join("index.js")).unwrap(), AFTER); - assert_eq!(std::fs::read(pkg.join("new.js")).unwrap(), CREATED); -} - -#[test] -fn offline_apply_with_only_a_diff_archive_reports_the_created_file_gap() { - let tmp = tempfile::tempdir().unwrap(); - let pkg = seed_project(tmp.path()); - seed_cached_diff_archive(tmp.path()); - - let (code, stdout, stderr) = run_cli(tmp.path(), &["apply", "--offline"], None); - - assert_eq!(code, 1, "stdout={stdout}\nstderr={stderr}"); - assert_eq!( - std::fs::read(pkg.join("index.js")).unwrap(), - BEFORE, - "a patch that cannot fully apply must not be applied partway" - ); - assert!(!pkg.join("new.js").exists()); - assert!( - stderr.contains("1 patch has no local source and --offline is set:") - && stderr.contains(PURL) - && stderr.contains("socket-patch repair"), - "the offline gate names the patch and the remedy; stderr={stderr}" - ); -} - -#[tokio::test] -async fn online_apply_with_a_cached_diff_archive_fetches_the_created_files_blob() { - let mock = MockServer::start().await; - mount_blob(&mock, CREATED).await; - - let tmp = tempfile::tempdir().unwrap(); - let pkg = seed_project(tmp.path()); - seed_cached_diff_archive(tmp.path()); - - let (code, stdout, stderr) = run_cli(tmp.path(), &["apply"], Some(&mock.uri())); - - assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); - assert_fully_patched(&pkg); - let requested: Vec = mock - .received_requests() - .await - .unwrap_or_default() - .iter() - .map(|r| r.url.path().to_string()) - .collect(); - assert!( - !requested - .iter() - .any(|p| p.contains("/patches/blob/") && p.ends_with(&git_sha256(AFTER))), - "the modified file's blob is not needed: its delta applies; requested={requested:?}" - ); -} - -#[tokio::test] -async fn default_repair_downloads_the_created_files_blob_for_offline_apply() { - let mock = MockServer::start().await; - Mock::given(method("GET")) - .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/diff/{UUID}"))) - .respond_with(ResponseTemplate::new(200).set_body_bytes(diff_archive())) - .mount(&mock) - .await; - mount_blob(&mock, CREATED).await; - - let tmp = tempfile::tempdir().unwrap(); - let pkg = seed_project(tmp.path()); - - let (code, stdout, stderr) = run_cli(tmp.path(), &["repair"], Some(&mock.uri())); - assert_eq!(code, 0, "repair: stdout={stdout}\nstderr={stderr}"); - let blobs = tmp.path().join(".socket").join("blobs"); - assert!( - blobs.join(git_sha256(CREATED)).exists(), - "repair must cache the created file's blob; stdout={stdout}\nstderr={stderr}" - ); - assert!( - !blobs.join(git_sha256(AFTER)).exists(), - "the modified file's delta covers it; its blob is not downloaded" - ); - - let (code, stdout, stderr) = run_cli(tmp.path(), &["apply", "--offline"], None); - assert_eq!(code, 0, "apply: stdout={stdout}\nstderr={stderr}"); - assert_fully_patched(&pkg); -} - -#[test] -fn offline_repair_names_the_created_files_missing_blob() { - let tmp = tempfile::tempdir().unwrap(); - seed_project(tmp.path()); - seed_cached_diff_archive(tmp.path()); - - let (code, stdout, stderr) = run_cli(tmp.path(), &["repair", "--offline"], None); - - assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); - assert!( - stdout.contains("All diff archives are present locally."), - "stdout={stdout}" - ); - let short: String = git_sha256(CREATED).chars().take(12).collect(); - assert!( - stderr.contains("Warning: 1 blob is missing (offline mode - not downloading):") - && stderr.contains(&short), - "the created file's blob is still missing; stderr={stderr}" - ); -} - -#[tokio::test] -async fn repair_json_reports_the_created_blob_download_once_as_file_mode() { - let mock = MockServer::start().await; - mount_blob(&mock, CREATED).await; - let tmp = tempfile::tempdir().unwrap(); - seed_project(tmp.path()); - seed_cached_diff_archive(tmp.path()); - - let (code, stdout, stderr) = run_cli(tmp.path(), &["repair", "--json"], Some(&mock.uri())); - assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); - let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap(); - let downloads: Vec<&serde_json::Value> = v["events"] - .as_array() - .unwrap() - .iter() - .filter(|e| e["action"] == "downloaded") - .collect(); - assert_eq!(downloads.len(), 1, "{v:#}"); - assert_eq!(downloads[0]["details"]["mode"], "file", "{v:#}"); - assert_eq!(downloads[0]["details"]["count"], 1, "{v:#}"); -} diff --git a/crates/socket-patch-cli/tests/get/get_nested_apply_api_flags_e2e.rs b/crates/socket-patch-cli/tests/get/get_nested_apply_api_flags_e2e.rs index f16b21893..009c4751a 100644 --- a/crates/socket-patch-cli/tests/get/get_nested_apply_api_flags_e2e.rs +++ b/crates/socket-patch-cli/tests/get/get_nested_apply_api_flags_e2e.rs @@ -165,10 +165,6 @@ async fn get_by_uuid_nested_apply_uses_api_flags_not_env() { "agent", "--yes", "--json", - // `file` mode goes straight for the per-file blob endpoint; the - // point here is which CLIENT does the fetch, not which artifact. - "--download-mode", - "file", "--api-url", &uri, "--api-token", @@ -221,8 +217,6 @@ async fn get_by_purl_nested_apply_uses_api_flags_not_env() { "agent", "--yes", "--json", - "--download-mode", - "file", "--api-url", &uri, "--api-token", @@ -303,8 +297,6 @@ async fn get_by_uuid_nested_apply_uses_proxy_url_flag_when_tokenless() { "agent", "--yes", "--json", - "--download-mode", - "file", "--proxy-url", &uri, ], diff --git a/crates/socket-patch-cli/tests/in_process_agent_reapply.rs b/crates/socket-patch-cli/tests/in_process_agent_reapply.rs index 3cfaaa972..f5aca4d33 100644 --- a/crates/socket-patch-cli/tests/in_process_agent_reapply.rs +++ b/crates/socket-patch-cli/tests/in_process_agent_reapply.rs @@ -40,7 +40,6 @@ fn common(cwd: &Path, server: &MockServer) -> socket_patch_cli::args::GlobalArgs yes: true, api_token: Some("fake".to_string()), api_url: Some(server.uri()), - download_mode: "diff".to_string(), ..socket_patch_cli::args::GlobalArgs::default() } } diff --git a/crates/socket-patch-cli/tests/in_process_alternate_installers.rs b/crates/socket-patch-cli/tests/in_process_alternate_installers.rs index 44b96f661..8b92cc9ad 100644 --- a/crates/socket-patch-cli/tests/in_process_alternate_installers.rs +++ b/crates/socket-patch-cli/tests/in_process_alternate_installers.rs @@ -119,7 +119,6 @@ fn default_apply(cwd: &Path) -> ApplyArgs { ecosystems: Some(vec!["npm".to_string()]), json: true, verbose: false, - download_mode: "diff".to_string(), ..socket_patch_cli::args::GlobalArgs::default() }, force: false, diff --git a/crates/socket-patch-cli/tests/in_process_cargo_apply.rs b/crates/socket-patch-cli/tests/in_process_cargo_apply.rs index 10c1d0c60..f5998198d 100644 --- a/crates/socket-patch-cli/tests/in_process_cargo_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_cargo_apply.rs @@ -234,7 +234,6 @@ async fn cargo_fetch_scan_sync_patches_real_file() { api_url: Some(server.uri()), api_token: Some("fake".to_string()), ecosystems: Some(vec!["cargo".to_string()]), - download_mode: "diff".to_string(), dry_run: false, ..socket_patch_cli::args::GlobalArgs::default() }, @@ -348,7 +347,6 @@ async fn cargo_apply_refuses_on_before_hash_mismatch() { api_url: Some(server.uri()), api_token: Some("fake".to_string()), ecosystems: Some(vec!["cargo".to_string()]), - download_mode: "diff".to_string(), dry_run: false, // strict pins the fail-closed contract: the v3.4 default (and // --force) deliberately downgrade a hash mismatch to "ready" @@ -449,7 +447,6 @@ async fn cargo_crawler_finds_real_fetched_crate() { api_url: Some(server.uri()), api_token: Some("fake".to_string()), ecosystems: Some(vec!["cargo".to_string()]), - download_mode: "diff".to_string(), dry_run: false, ..socket_patch_cli::args::GlobalArgs::default() }, diff --git a/crates/socket-patch-cli/tests/in_process_edge_cases.rs b/crates/socket-patch-cli/tests/in_process_edge_cases.rs index d5f92ac88..a940f8388 100644 --- a/crates/socket-patch-cli/tests/in_process_edge_cases.rs +++ b/crates/socket-patch-cli/tests/in_process_edge_cases.rs @@ -101,7 +101,6 @@ fn default_apply(cwd: &Path) -> ApplyArgs { ecosystems: None, json: true, verbose: false, - download_mode: "diff".to_string(), ..socket_patch_cli::args::GlobalArgs::default() }, force: false, diff --git a/crates/socket-patch-cli/tests/in_process_gem_apply.rs b/crates/socket-patch-cli/tests/in_process_gem_apply.rs index f9b3d3dcd..7f3fefc76 100644 --- a/crates/socket-patch-cli/tests/in_process_gem_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_gem_apply.rs @@ -212,7 +212,6 @@ async fn gem_install_scan_sync_patches_real_file() { api_url: Some(server.uri()), api_token: Some("fake".to_string()), ecosystems: Some(vec!["gem".to_string()]), - download_mode: "diff".to_string(), dry_run: false, ..socket_patch_cli::args::GlobalArgs::default() }, @@ -323,7 +322,6 @@ async fn gem_crawler_finds_real_installed_gem() { api_url: Some(server.uri()), api_token: Some("fake".to_string()), ecosystems: Some(vec!["gem".to_string()]), - download_mode: "diff".to_string(), dry_run: false, ..socket_patch_cli::args::GlobalArgs::default() }, diff --git a/crates/socket-patch-cli/tests/in_process_gem_multi_platform.rs b/crates/socket-patch-cli/tests/in_process_gem_multi_platform.rs index 68317b9cb..eefef3b6c 100644 --- a/crates/socket-patch-cli/tests/in_process_gem_multi_platform.rs +++ b/crates/socket-patch-cli/tests/in_process_gem_multi_platform.rs @@ -230,7 +230,6 @@ fn scan_args(cwd: &Path, api_url: String, all_releases: bool) -> ScanArgs { api_url: Some(api_url), api_token: Some("fake".to_string()), ecosystems: Some(vec!["gem".to_string()]), - download_mode: "diff".to_string(), dry_run: false, ..socket_patch_cli::args::GlobalArgs::default() }, diff --git a/crates/socket-patch-cli/tests/in_process_get.rs b/crates/socket-patch-cli/tests/in_process_get.rs index 5a9d1055a..6e7709c22 100644 --- a/crates/socket-patch-cli/tests/in_process_get.rs +++ b/crates/socket-patch-cli/tests/in_process_get.rs @@ -30,7 +30,6 @@ fn default_args(identifier: &str, cwd: &Path) -> GetArgs { global: false, global_prefix: None, json: true, - download_mode: "diff".to_string(), ..socket_patch_cli::args::GlobalArgs::default() }, identifier: identifier.to_string(), @@ -775,63 +774,3 @@ async fn get_uuid_non_json_save_only() { assert_eq!(run(args).await, 0); assert_patch_saved(tmp.path(), PURL, UUID); } - -// --------------------------------------------------------------------------- -// Custom download mode -// --------------------------------------------------------------------------- - -#[tokio::test] -#[serial] -async fn get_download_mode_package() { - let (server, url) = start_wiremock().await; - make_view_mock(&server, UUID, PURL, "free").await; - - let tmp = tempfile::tempdir().unwrap(); - let mut args = default_args(UUID, tmp.path()); - args.common.api_url = Some(url); - args.common.download_mode = "package".to_string(); - assert_eq!(run(args).await, 0); - // save_only short-circuits before apply, so download_mode is not - // consumed here; we still verify the patch was actually persisted. - assert_patch_saved(tmp.path(), PURL, UUID); -} - -#[tokio::test] -#[serial] -async fn get_download_mode_file() { - let (server, url) = start_wiremock().await; - make_view_mock(&server, UUID, PURL, "free").await; - - let tmp = tempfile::tempdir().unwrap(); - let mut args = default_args(UUID, tmp.path()); - args.common.api_url = Some(url); - args.common.download_mode = "file".to_string(); - assert_eq!(run(args).await, 0); - assert_patch_saved(tmp.path(), PURL, UUID); -} - -#[tokio::test] -#[serial] -async fn get_invalid_download_mode_handled() { - let (server, url) = start_wiremock().await; - make_view_mock(&server, UUID, PURL, "free").await; - - let tmp = tempfile::tempdir().unwrap(); - let mut args = default_args(UUID, tmp.path()); - args.common.api_url = Some(url); - args.common.download_mode = "nonsense".to_string(); - - // FINDING: an invalid download mode is NOT validated on the save_only - // UUID path. `save_and_apply_patch` only parses download_mode when it - // actually runs apply (`!save_only && added`), so with save_only=true the - // bogus "nonsense" mode is silently accepted: the run still exits 0 and - // saves the patch. We assert that exact (current) behavior rather than - // the original `let _ = run(...)` no-op, so any change to validation here - // is caught. This is a latent gap, deliberately left for the maintainers. - let code = run(args).await; - assert_eq!( - code, 0, - "invalid download_mode is not validated under --save-only (exits 0)" - ); - assert_patch_saved(tmp.path(), PURL, UUID); -} diff --git a/crates/socket-patch-cli/tests/in_process_get_hosted_ecosystems.rs b/crates/socket-patch-cli/tests/in_process_get_hosted_ecosystems.rs index bb9321caa..4bcadc5d6 100644 --- a/crates/socket-patch-cli/tests/in_process_get_hosted_ecosystems.rs +++ b/crates/socket-patch-cli/tests/in_process_get_hosted_ecosystems.rs @@ -57,7 +57,6 @@ fn get_hosted_args(identifier: &str, cwd: &Path, api_url: String) -> GetArgs { api_token: Some("fake-token-for-tests".to_string()), api_url: Some(api_url), json: true, - download_mode: "diff".to_string(), ..socket_patch_cli::args::GlobalArgs::default() }, identifier: identifier.to_string(), diff --git a/crates/socket-patch-cli/tests/in_process_get_manifest_path.rs b/crates/socket-patch-cli/tests/in_process_get_manifest_path.rs index 40e5b5c2a..71add7c56 100644 --- a/crates/socket-patch-cli/tests/in_process_get_manifest_path.rs +++ b/crates/socket-patch-cli/tests/in_process_get_manifest_path.rs @@ -19,7 +19,7 @@ //! `proj/proj/.socket/manifest.json`, hit the no-manifest clean no-op, //! and report success (`applied: 1`, exit 0) without patching anything. //! -//! 3. `run_nested_apply` threaded cwd/global/silent/download-mode/strict +//! 3. `run_nested_apply` threaded cwd/global/silent/strict //! and the four API flags into the nested `ApplyArgs` but left //! `ecosystems` at `GlobalArgs::default()` (`None`), so a download //! scoped with `--ecosystems ` (`scan --ecosystems gem --sync`, or @@ -94,7 +94,6 @@ fn get_args(identifier: &str, cwd: &Path, api_url: String) -> GetArgs { api_url: Some(api_url), json: true, no_telemetry: true, - download_mode: "diff".to_string(), ..GlobalArgs::default() }, identifier: identifier.to_string(), diff --git a/crates/socket-patch-cli/tests/in_process_get_modes.rs b/crates/socket-patch-cli/tests/in_process_get_modes.rs index 248bb1366..1a53fde39 100644 --- a/crates/socket-patch-cli/tests/in_process_get_modes.rs +++ b/crates/socket-patch-cli/tests/in_process_get_modes.rs @@ -59,7 +59,6 @@ fn get_args(identifier: &str, cwd: &Path, api_url: String) -> GetArgs { api_token: Some("fake-token-for-tests".to_string()), api_url: Some(api_url), json: true, - download_mode: "diff".to_string(), vendor_source: "service".to_string(), ..socket_patch_cli::args::GlobalArgs::default() }, diff --git a/crates/socket-patch-cli/tests/in_process_get_update_count.rs b/crates/socket-patch-cli/tests/in_process_get_update_count.rs index 5cd01c749..e7264a48a 100644 --- a/crates/socket-patch-cli/tests/in_process_get_update_count.rs +++ b/crates/socket-patch-cli/tests/in_process_get_update_count.rs @@ -89,7 +89,6 @@ fn params(root: &Path) -> DownloadParams { global_prefix: None, json: true, silent: true, - download_mode: "diff".to_string(), strict: false, ecosystems: None, persist_blobs: true, diff --git a/crates/socket-patch-cli/tests/in_process_pypi_apply.rs b/crates/socket-patch-cli/tests/in_process_pypi_apply.rs index f69383b50..68e7bda16 100644 --- a/crates/socket-patch-cli/tests/in_process_pypi_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_pypi_apply.rs @@ -261,7 +261,6 @@ async fn pypi_install_scan_sync_patches_real_file() { api_url: Some(server.uri()), api_token: Some("fake".to_string()), ecosystems: Some(vec!["pypi".to_string()]), - download_mode: "diff".to_string(), dry_run: false, ..socket_patch_cli::args::GlobalArgs::default() }, @@ -336,7 +335,6 @@ async fn pypi_scan_then_apply_force_patches_real_file() { api_url: Some(server.uri()), api_token: Some("fake".to_string()), ecosystems: Some(vec!["pypi".to_string()]), - download_mode: "diff".to_string(), dry_run: false, ..socket_patch_cli::args::GlobalArgs::default() }, @@ -379,7 +377,6 @@ async fn pypi_scan_then_apply_force_patches_real_file() { ecosystems: Some(vec!["pypi".to_string()]), json: true, verbose: false, - download_mode: "diff".to_string(), ..socket_patch_cli::args::GlobalArgs::default() }, force: true, @@ -444,7 +441,6 @@ async fn pypi_apply_dry_run_does_not_modify_file() { api_url: Some(server.uri()), api_token: Some("fake".to_string()), ecosystems: Some(vec!["pypi".to_string()]), - download_mode: "diff".to_string(), dry_run: true, ..socket_patch_cli::args::GlobalArgs::default() }, @@ -572,7 +568,6 @@ async fn pypi_crawler_finds_real_installed_six() { api_url: Some(server.uri()), api_token: Some("fake".to_string()), ecosystems: Some(vec!["pypi".to_string()]), - download_mode: "diff".to_string(), dry_run: false, ..socket_patch_cli::args::GlobalArgs::default() }, @@ -670,7 +665,6 @@ async fn pypi_scan_sync_patches_egg_info_install() { api_url: Some(server.uri()), api_token: Some("fake".to_string()), ecosystems: Some(vec!["pypi".to_string()]), - download_mode: "diff".to_string(), ..socket_patch_cli::args::GlobalArgs::default() }, batch_size: Some(100), diff --git a/crates/socket-patch-cli/tests/in_process_pypi_multi_release.rs b/crates/socket-patch-cli/tests/in_process_pypi_multi_release.rs index a6ed3ecec..a59c8d55a 100644 --- a/crates/socket-patch-cli/tests/in_process_pypi_multi_release.rs +++ b/crates/socket-patch-cli/tests/in_process_pypi_multi_release.rs @@ -304,7 +304,6 @@ fn scan_args(tmp: &Path, api_url: String, all_releases: bool) -> ScanArgs { api_url: Some(api_url), api_token: Some("fake".to_string()), ecosystems: Some(vec!["pypi".to_string()]), - download_mode: "diff".to_string(), dry_run: false, ..socket_patch_cli::args::GlobalArgs::default() }, diff --git a/crates/socket-patch-cli/tests/in_process_python_envs.rs b/crates/socket-patch-cli/tests/in_process_python_envs.rs index 38b7fdc1a..9e9f7601f 100644 --- a/crates/socket-patch-cli/tests/in_process_python_envs.rs +++ b/crates/socket-patch-cli/tests/in_process_python_envs.rs @@ -128,7 +128,6 @@ fn default_args(cwd: &Path, api_url: String) -> ScanArgs { api_url: Some(api_url), api_token: Some("fake".to_string()), ecosystems: Some(vec!["pypi".to_string()]), - download_mode: "diff".to_string(), dry_run: false, ..socket_patch_cli::args::GlobalArgs::default() }, diff --git a/crates/socket-patch-cli/tests/in_process_remote_ecosystems_apply.rs b/crates/socket-patch-cli/tests/in_process_remote_ecosystems_apply.rs index e4c951f15..b383e3f80 100644 --- a/crates/socket-patch-cli/tests/in_process_remote_ecosystems_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_remote_ecosystems_apply.rs @@ -128,7 +128,6 @@ fn default_scan_args(cwd: &Path, eco: &str, api_url: String) -> ScanArgs { api_url: Some(api_url), api_token: Some("fake".to_string()), ecosystems: Some(vec![eco.to_string()]), - download_mode: "diff".to_string(), dry_run: false, ..socket_patch_cli::args::GlobalArgs::default() }, diff --git a/crates/socket-patch-cli/tests/in_process_remove_repair_lifecycle.rs b/crates/socket-patch-cli/tests/in_process_remove_repair_lifecycle.rs index 296d39679..e3403f6a1 100644 --- a/crates/socket-patch-cli/tests/in_process_remove_repair_lifecycle.rs +++ b/crates/socket-patch-cli/tests/in_process_remove_repair_lifecycle.rs @@ -317,10 +317,10 @@ async fn remove_no_manifest_emits_not_found() { } // --------------------------------------------------------------------------- -// repair: download in both modes (file/diff); `package` fails hard +// repair: downloads per-file blobs (the only download since v5) // --------------------------------------------------------------------------- -fn make_repair_args(cwd: &Path, mode: &str) -> RepairArgs { +fn make_repair_args(cwd: &Path) -> RepairArgs { RepairArgs { common: socket_patch_cli::args::GlobalArgs { cwd: cwd.to_path_buf(), @@ -328,7 +328,6 @@ fn make_repair_args(cwd: &Path, mode: &str) -> RepairArgs { dry_run: false, offline: false, json: true, - download_mode: mode.to_string(), ..socket_patch_cli::args::GlobalArgs::default() }, download_only: false, @@ -337,140 +336,7 @@ fn make_repair_args(cwd: &Path, mode: &str) -> RepairArgs { #[tokio::test] #[serial] -async fn repair_diff_mode_downloads_diff_archives() { - let tmp = tempfile::tempdir().unwrap(); - let uuid = "12121212-1212-4121-8121-121212121212"; - let _after_hash = "abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc1"; - - let server = MockServer::start().await; - // Diff mode fetches /v0/orgs//patches/diff/ → tar.gz body. - let fake_archive = b"fake diff archive"; - Mock::given(method("GET")) - .and(path(format!("/v0/orgs/{ORG}/patches/diff/{uuid}"))) - .respond_with(ResponseTemplate::new(200).set_body_bytes(fake_archive.to_vec())) - .mount(&server) - .await; - // Fallback blob endpoint should also be available. - let real_blob = b"real blob content"; - let real_hash = git_sha256(real_blob); - Mock::given(method("GET")) - .and(path(format!("/v0/orgs/{ORG}/patches/blob/{real_hash}"))) - .respond_with(ResponseTemplate::new(200).set_body_bytes(real_blob.to_vec())) - .mount(&server) - .await; - - let socket = tmp.path().join(".socket"); - std::fs::create_dir_all(&socket).unwrap(); - std::fs::write( - socket.join("manifest.json"), - format!( - r#"{{ "patches": {{ - "pkg:npm/diff-test@1.0.0": {{ - "uuid": "{uuid}", - "exportedAt": "2024-01-01T00:00:00Z", - "files": {{ "package/x.js": {{ - "beforeHash": "0000000000000000000000000000000000000000000000000000000000000000", - "afterHash": "{real_hash}" - }}}}, - "vulnerabilities": {{}}, "description": "x", - "license": "MIT", "tier": "free" - }} - }}}}"# - ), - ) - .unwrap(); - - std::env::set_var("SOCKET_API_URL", server.uri()); - std::env::set_var("SOCKET_API_TOKEN", "fake"); - std::env::set_var("SOCKET_ORG_SLUG", ORG); - let code = repair_run(make_repair_args(tmp.path(), "diff")).await; - std::env::remove_var("SOCKET_API_URL"); - std::env::remove_var("SOCKET_API_TOKEN"); - std::env::remove_var("SOCKET_ORG_SLUG"); - assert_eq!(code, 0, "repair --download-mode diff must succeed"); - - // The diff archive should be on disk at .socket/diffs/.tar.gz, and - // its bytes must be exactly what the server served — a corrupt/empty - // write would otherwise still satisfy a bare `exists()` check. - let archive_path = socket.join(format!("diffs/{uuid}.tar.gz")); - assert!( - archive_path.exists(), - "diff archive must be persisted to {}", - archive_path.display() - ); - assert_eq!( - std::fs::read(&archive_path).unwrap(), - fake_archive, - "persisted diff archive bytes must match the served body" - ); - // Prove the real download path ran (not a short-circuit): the diff - // endpoint must have actually been requested. - let hits = server - .received_requests() - .await - .unwrap() - .into_iter() - .filter(|r| r.url.path() == format!("/v0/orgs/{ORG}/patches/diff/{uuid}")) - .count(); - assert_eq!(hits, 1, "diff endpoint must be fetched exactly once"); -} - -#[tokio::test] -#[serial] -async fn repair_package_mode_is_a_hard_failure() { - // `--download-mode package` was removed (no deployed server ever served - // its GET archive route). Repair must fail up front on mode parsing — - // before any network traffic — rather than silently degrade. - let tmp = tempfile::tempdir().unwrap(); - let uuid = "13131313-1313-4131-8131-131313131313"; - - let server = MockServer::start().await; - - let socket = tmp.path().join(".socket"); - std::fs::create_dir_all(&socket).unwrap(); - std::fs::write( - socket.join("manifest.json"), - format!( - r#"{{ "patches": {{ - "pkg:npm/pkg-test@1.0.0": {{ - "uuid": "{uuid}", - "exportedAt": "2024-01-01T00:00:00Z", - "files": {{ "package/x.js": {{ - "beforeHash": "0000000000000000000000000000000000000000000000000000000000000000", - "afterHash": "def456def456def456def456def456def456def456def456def456def456def4" - }}}}, - "vulnerabilities": {{}}, "description": "x", - "license": "MIT", "tier": "free" - }} - }}}}"# - ), - ) - .unwrap(); - - std::env::set_var("SOCKET_API_URL", server.uri()); - std::env::set_var("SOCKET_API_TOKEN", "fake"); - std::env::set_var("SOCKET_ORG_SLUG", ORG); - let code = repair_run(make_repair_args(tmp.path(), "package")).await; - std::env::remove_var("SOCKET_API_URL"); - std::env::remove_var("SOCKET_API_TOKEN"); - std::env::remove_var("SOCKET_ORG_SLUG"); - assert_ne!(code, 0, "removed download mode must be a hard failure"); - let package_hits = server - .received_requests() - .await - .unwrap() - .into_iter() - .filter(|r| r.url.path().contains("/patches/package/")) - .count(); - assert_eq!( - package_hits, 0, - "the removed mode must never reach the package archive route" - ); -} - -#[tokio::test] -#[serial] -async fn repair_file_mode_downloads_individual_blobs() { +async fn repair_downloads_individual_blobs() { let tmp = tempfile::tempdir().unwrap(); let blob_content = b"some patched content\n"; let after_hash = git_sha256(blob_content); @@ -506,7 +372,7 @@ async fn repair_file_mode_downloads_individual_blobs() { std::env::set_var("SOCKET_API_URL", server.uri()); std::env::set_var("SOCKET_API_TOKEN", "fake"); std::env::set_var("SOCKET_ORG_SLUG", ORG); - let code = repair_run(make_repair_args(tmp.path(), "file")).await; + let code = repair_run(make_repair_args(tmp.path())).await; std::env::remove_var("SOCKET_API_URL"); std::env::remove_var("SOCKET_API_TOKEN"); std::env::remove_var("SOCKET_ORG_SLUG"); @@ -533,6 +399,17 @@ async fn repair_file_mode_downloads_individual_blobs() { .filter(|r| r.url.path() == format!("/v0/orgs/{ORG}/patches/blob/{after_hash}")) .count(); assert_eq!(hits, 1, "blob endpoint must be fetched exactly once"); + // v5 removed the diff download path: no request may go to it, and + // nothing lands in the obsolete `.socket/diffs/`. + let diff_hits = server + .received_requests() + .await + .unwrap() + .into_iter() + .filter(|r| r.url.path().contains("/diff")) + .count(); + assert_eq!(diff_hits, 0, "repair must never request a diff archive"); + assert!(!socket.join("diffs").exists()); } #[tokio::test] @@ -577,7 +454,7 @@ async fn repair_dry_run_does_not_download() { ) .unwrap(); - let mut args = make_repair_args(tmp.path(), "file"); + let mut args = make_repair_args(tmp.path()); args.common.dry_run = true; args.common.offline = false; @@ -600,7 +477,7 @@ async fn repair_dry_run_does_not_download() { "dry-run must not download blobs" ); // The decisive check: the blob endpoint must never have been requested. - // If dry_run were ignored, fetch_missing_sources would have hit it. + // If dry_run were ignored, fetch_missing_blobs would have hit it. let hits = server .received_requests() .await @@ -622,7 +499,7 @@ async fn repair_dry_run_does_not_download() { #[serial] async fn repair_with_no_manifest_emits_error() { let tmp = tempfile::tempdir().unwrap(); - assert_eq!(repair_run(make_repair_args(tmp.path(), "file")).await, 1); + assert_eq!(repair_run(make_repair_args(tmp.path())).await, 1); } /// Regression: a repair where a missing artifact fails to download must @@ -665,7 +542,7 @@ async fn repair_download_failure_exits_nonzero() { std::env::set_var("SOCKET_API_URL", server.uri()); std::env::set_var("SOCKET_API_TOKEN", "fake"); std::env::set_var("SOCKET_ORG_SLUG", ORG); - let code = repair_run(make_repair_args(tmp.path(), "file")).await; + let code = repair_run(make_repair_args(tmp.path())).await; std::env::remove_var("SOCKET_API_URL"); std::env::remove_var("SOCKET_API_TOKEN"); std::env::remove_var("SOCKET_ORG_SLUG"); @@ -709,7 +586,7 @@ async fn repair_offline_with_present_blobs_succeeds() { std::fs::create_dir_all(&blobs).unwrap(); std::fs::write(blobs.join(&hash), blob).unwrap(); - let mut args = make_repair_args(tmp.path(), "file"); + let mut args = make_repair_args(tmp.path()); args.common.offline = true; assert_eq!(repair_run(args).await, 0); // The referenced blob is in use, so offline cleanup must leave it intact. @@ -853,7 +730,7 @@ async fn repair_telemetry_attributed_to_env_credentials() { std::env::remove_var("SOCKET_TELEMETRY_DISABLED"); std::env::remove_var("SOCKET_OFFLINE"); std::env::remove_var("VITEST"); - let code = repair_run(make_repair_args(tmp.path(), "file")).await; + let code = repair_run(make_repair_args(tmp.path())).await; std::env::remove_var("SOCKET_API_URL"); std::env::remove_var("SOCKET_API_TOKEN"); std::env::remove_var("SOCKET_ORG_SLUG"); @@ -892,7 +769,7 @@ async fn vlt_repair_redownloads_the_dir_then_remove_reverts_it() { let uuid_dir = root.join(format!(".socket/vendor/npm/{}", hosted::UUID)); std::fs::remove_dir_all(&uuid_dir).unwrap(); - let mut args = make_repair_args(root, "diff"); + let mut args = make_repair_args(root); let fixture = prebuilt_common::Server::project(root); args.common.offline = false; fixture.configure(&mut args.common); diff --git a/crates/socket-patch-cli/tests/in_process_scan.rs b/crates/socket-patch-cli/tests/in_process_scan.rs index 52bca9c6a..08aa56b58 100644 --- a/crates/socket-patch-cli/tests/in_process_scan.rs +++ b/crates/socket-patch-cli/tests/in_process_scan.rs @@ -74,7 +74,6 @@ fn default_args(cwd: &Path) -> ScanArgs { global_prefix: None, api_token: Some("fake".to_string()), ecosystems: None, - download_mode: "diff".to_string(), dry_run: false, ..socket_patch_cli::args::GlobalArgs::default() }, diff --git a/crates/socket-patch-cli/tests/in_process_target_ambiguity.rs b/crates/socket-patch-cli/tests/in_process_target_ambiguity.rs index dc047d8b8..b49ff5c86 100644 --- a/crates/socket-patch-cli/tests/in_process_target_ambiguity.rs +++ b/crates/socket-patch-cli/tests/in_process_target_ambiguity.rs @@ -210,7 +210,6 @@ fn get_args(identifier: &str, cwd: &Path, api_url: String) -> GetArgs { api_token: Some("fake-token-for-tests".to_string()), api_url: Some(api_url), json: true, - download_mode: "diff".to_string(), ..socket_patch_cli::args::GlobalArgs::default() }, identifier: identifier.to_string(), diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index 93dbf0c24..ce57fd164 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -3158,7 +3158,6 @@ async fn mount_gem_patch_api(mock: &wiremock::MockServer, patch_purl: &str) { )]); let sources = socket_patch_core::patch::apply::PatchSources { blobs_path: fx.root(), - diffs_path: None, mem_blobs: Some(&blobs), }; prebuilt_common::mount_record( diff --git a/crates/socket-patch-cli/tests/in_process_vendor/vlt.rs b/crates/socket-patch-cli/tests/in_process_vendor/vlt.rs index 8f0e37daf..0fc4c1c30 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor/vlt.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor/vlt.rs @@ -1009,7 +1009,6 @@ async fn vendor_vlt_new_uuid_downloads_independent_server_artifact() { let blobs = root.join(".socket/blobs"); let sources = socket_patch_core::patch::apply::PatchSources { blobs_path: &blobs, - diffs_path: None, mem_blobs: None, }; crate::prebuilt_common::mount_record( diff --git a/crates/socket-patch-cli/tests/prebuilt_common/mod.rs b/crates/socket-patch-cli/tests/prebuilt_common/mod.rs index f26a1bf01..4e740107e 100644 --- a/crates/socket-patch-cli/tests/prebuilt_common/mod.rs +++ b/crates/socket-patch-cli/tests/prebuilt_common/mod.rs @@ -155,7 +155,6 @@ async fn mount_project_with_roots(server: &MockServer, root: &Path, extra: Vec

PathBuf { socket } -/// The default cleanup now covers `.socket/diffs` (`.tar.gz`, kept iff -/// the uuid is still referenced by the post-removal manifest) and the legacy -/// `.socket/packages` (swept whole: v5.0 reads no package archives) in -/// addition to blobs. Removing A must sweep A's diff archive while B's — -/// still referenced by the second manifest entry — survives, and both -/// package archives go; the artifact carrier reports the count. +/// The default cleanup covers the obsolete `.socket/diffs` and +/// `.socket/packages` (both swept whole: v5.0 reads neither) in addition to +/// blobs. Removing A sweeps every archive, B's included even though B stays +/// in the manifest; the artifact carrier reports the count. #[test] fn default_remove_sweeps_archives_too() { let tmp = tempfile::tempdir().expect("tempdir"); @@ -509,41 +507,25 @@ fn default_remove_sweeps_archives_too() { "exactly A's manifest entry is removed" ); - // A's archives are gone from BOTH archive dirs; B's diff archive - // survives, its legacy package archive does not. + // Every archive is gone from BOTH archive dirs, the kept entry's too. for dir in ["diffs", "packages"] { - assert!( - !socket - .join(dir) - .join(format!("{ARCH_UUID_A}.tar.gz")) - .exists(), - "the removed entry's {dir} archive must be swept" - ); + for (label, uuid) in [("A", ARCH_UUID_A), ("B", ARCH_UUID_B)] { + assert!( + !socket.join(dir).join(format!("{uuid}.tar.gz")).exists(), + "entry {label}'s {dir} archive must be swept" + ); + } } - assert!( - socket - .join("diffs") - .join(format!("{ARCH_UUID_B}.tar.gz")) - .exists(), - "the kept entry's diff archive must survive" - ); - assert!( - !socket - .join("packages") - .join(format!("{ARCH_UUID_B}.tar.gz")) - .exists(), - "a legacy package archive is swept even for a kept entry" - ); - // The purl-less artifact carrier reports the three swept archives. + // The purl-less artifact carrier reports the four swept archives. let events = v["events"].as_array().expect("events array"); let carrier = events .iter() .find(|e| e["action"] == "removed" && e["purl"].is_null()) .unwrap_or_else(|| panic!("expected the artifact carrier event: {events:?}")); assert_eq!( - carrier["details"]["archivesRemoved"], 3, - "one diff + two package archives swept; carrier={carrier}" + carrier["details"]["archivesRemoved"], 4, + "two diff + two package archives swept; carrier={carrier}" ); // The keep-rule really is manifest-anchored: B's entry survives. diff --git a/crates/socket-patch-cli/tests/remove_rollback_api_overrides.rs b/crates/socket-patch-cli/tests/remove_rollback_api_overrides.rs index 168a59cf2..01dc0384b 100644 --- a/crates/socket-patch-cli/tests/remove_rollback_api_overrides.rs +++ b/crates/socket-patch-cli/tests/remove_rollback_api_overrides.rs @@ -39,7 +39,6 @@ const SOCKET_ENV_VARS: &[&str] = &[ "SOCKET_ORG_SLUG", "SOCKET_PROXY_URL", "SOCKET_ECOSYSTEMS", - "SOCKET_DOWNLOAD_MODE", "SOCKET_VENDOR_SOURCE", "SOCKET_VENDOR_URL", "SOCKET_PATCH_SERVER_URL", diff --git a/crates/socket-patch-cli/tests/repair/covgap_commands_repair.rs b/crates/socket-patch-cli/tests/repair/covgap_commands_repair.rs index ae6a32db2..0dce2b57a 100644 --- a/crates/socket-patch-cli/tests/repair/covgap_commands_repair.rs +++ b/crates/socket-patch-cli/tests/repair/covgap_commands_repair.rs @@ -221,10 +221,7 @@ fn repair_failed_human_mode_prints_error_to_stderr() { // Human-mode summaries // --------------------------------------------------------------------------- -/// The loud "All {artifacts} are present locally." summary. Every -/// existing loud run used the default diff mode with no `.tar.gz` -/// present (always "missing"), and every all-present run was `--json` — -/// so the print never executed. `--download-mode file` with the referenced +/// The loud "All blobs are present locally." summary: the referenced /// blob on disk is the all-present shape. #[test] fn repair_all_present_human_mode_prints_summary() { @@ -233,7 +230,7 @@ fn repair_all_present_human_mode_prints_summary() { write_blob(&socket, REFERENCED_HASH, b"patched content"); let out = socket_cmd(tmp.path()) - .args(["repair", "--offline", "--download-mode", "file"]) + .args(["repair", "--offline"]) .output() .expect("run socket-patch"); let stdout = String::from_utf8_lossy(&out.stdout); @@ -266,7 +263,7 @@ fn repair_offline_warning_truncates_missing_list_after_five() { // No blobs on disk → all 12 afterHashes are missing. let out = socket_cmd(tmp.path()) - .args(["repair", "--offline", "--download-mode", "file"]) + .args(["repair", "--offline"]) .output() .expect("run socket-patch"); let stdout = String::from_utf8_lossy(&out.stdout); @@ -316,7 +313,7 @@ fn repair_dry_run_preview_truncates_missing_list_after_ten() { let socket = write_twelve_file_manifest(tmp.path()); let out = socket_cmd(tmp.path()) - .args(["repair", "--dry-run", "--download-mode", "file"]) + .args(["repair", "--dry-run"]) .env("SOCKET_TELEMETRY_DISABLED", "1") .output() .expect("run socket-patch"); @@ -352,18 +349,16 @@ fn repair_dry_run_preview_truncates_missing_list_after_ten() { ); } -/// The loud orphan-archive removal print — each directory's summary names -/// its own artifact kind (`format_cleanup_result_for` takes the noun). One orphan in `diffs/` next to the -/// referenced `.tar.gz` that must survive, and two legacy archives in -/// `packages/` (one under the referenced uuid) that both go: v5.0 reads no -/// package archives, so the sweep keeps none. +/// The loud obsolete-archive removal print — each directory's summary names +/// its own artifact kind (`format_cleanup_result_for` takes the noun). Two +/// archives in `diffs/` and two in `packages/` (one of each under the +/// referenced uuid) all go: v5.0 reads no diff or package archives, so the +/// sweep keeps none. #[test] fn repair_removes_orphan_archives_human_mode_prints_relabeled_summary() { let tmp = tempfile::tempdir().expect("tempdir"); let socket = make_socket_dir(tmp.path()); write_blob(&socket, REFERENCED_HASH, b"kept"); - // The referenced diff archive keeps the default diff mode's - // missing-check happy AND must survive the sweep. write_archive(&socket, "diffs", REFERENCED_UUID, b"kept-diff"); write_archive(&socket, "packages", REFERENCED_UUID, b"kept-package"); const ORPHAN_DIFF: &str = "99999999-9999-4999-8999-999999999999"; @@ -385,7 +380,7 @@ fn repair_removes_orphan_archives_human_mode_prints_relabeled_summary() { // Each directory's summary names its own artifact kind (the formatter // takes the noun; no string rewriting of the blob wording). assert!( - stdout.contains("Removed 1 unused diff archive (17 B freed)"), + stdout.contains("Removed 2 unused diff archives (26 B freed)"), "the diffs sweep must print its own summary; stdout=\n{stdout}" ); assert!( @@ -393,17 +388,15 @@ fn repair_removes_orphan_archives_human_mode_prints_relabeled_summary() { "the packages sweep must print its own summary; stdout=\n{stdout}" ); assert!( - !stdout.contains("blob"), + !stdout.contains("unused blob"), "no archive line may use the blob wording; stdout=\n{stdout}" ); - // Bonus pin: with the referenced diff archive present, the default diff - // mode takes the all-present branch too. + // The download phase checks blobs only: the referenced one is present. assert!( - stdout.contains("All diff archives are present locally."), - "diff mode with the referenced archive present is all-present; stdout=\n{stdout}" + stdout.contains("All blobs are present locally."), + "the referenced blob is present; stdout=\n{stdout}" ); - // Disk effects: orphans and legacy archives gone, the referenced diff - // archive intact. + // Disk effects: every obsolete archive is gone. assert!( !socket .join("diffs") @@ -418,10 +411,7 @@ fn repair_removes_orphan_archives_human_mode_prints_relabeled_summary() { .exists(), "the orphan package archive must be swept" ); - assert!(socket - .join("diffs") - .join(format!("{REFERENCED_UUID}.tar.gz")) - .exists()); + assert!(!socket.join("diffs").exists()); assert!(!socket .join("packages") .join(format!("{REFERENCED_UUID}.tar.gz")) @@ -454,7 +444,7 @@ fn repair_archive_cleanup_failure_warns_and_continues() { // Loud human mode: stderr warning, exit 0, packages pass still ran. let loud = socket_cmd(tmp.path()) - .args(["repair", "--offline", "--download-mode", "file"]) + .args(["repair", "--offline"]) .output() .expect("run socket-patch"); let loud_stdout = String::from_utf8_lossy(&loud.stdout); @@ -485,7 +475,7 @@ fn repair_archive_cleanup_failure_warns_and_continues() { // the envelope as an informational skip while status stays success. write_archive(&socket, "packages", ORPHAN_PKG, b"orphan pkg bytes"); let json = socket_cmd(tmp.path()) - .args(["repair", "--json", "--offline", "--download-mode", "file"]) + .args(["repair", "--json", "--offline"]) .output() .expect("run socket-patch"); let json_stdout = String::from_utf8_lossy(&json.stdout); @@ -554,7 +544,7 @@ fn repair_exits_zero_and_stays_quiet_when_lock_file_unremovable() { .expect("chmod .socket read-only"); let out = socket_cmd(tmp.path()) - .args(["repair", "--offline", "--download-mode", "file"]) + .args(["repair", "--offline"]) .output() .expect("run socket-patch"); diff --git a/crates/socket-patch-cli/tests/repair/covgap_commands_repair_vendor.rs b/crates/socket-patch-cli/tests/repair/covgap_commands_repair_vendor.rs index 7d4bbc49a..568f271d7 100644 --- a/crates/socket-patch-cli/tests/repair/covgap_commands_repair_vendor.rs +++ b/crates/socket-patch-cli/tests/repair/covgap_commands_repair_vendor.rs @@ -333,7 +333,7 @@ async fn mount_gem_patch_api(mock: &MockServer) { mount_gem_routes(mock, AFTER).await; } -/// Serve an after-blob for `--download-mode file` repairs. +/// Serve an after-blob for agent repairs. async fn mount_blob_of(mock: &MockServer, content: &'static [u8]) { Mock::given(method("GET")) .and(path(format!( @@ -612,11 +612,7 @@ async fn repair_skips_when_manifest_uuid_moved_on() { ) .unwrap(); - let (code, stdout, stderr) = run_cli( - tmp.path(), - &mock.uri(), - &["repair", "--download-mode", "file"], - ); + let (code, stdout, stderr) = run_cli(tmp.path(), &mock.uri(), &["repair"]); assert_eq!(code, 0, "stdout={stdout} stderr={stderr}"); let v = parse_env(&stdout); assert!( @@ -667,11 +663,7 @@ async fn repair_prefers_the_moved_on_manifest_over_an_embedded_record() { ) .unwrap(); - let (code, stdout, stderr) = run_cli( - tmp.path(), - &mock.uri(), - &["repair", "--download-mode", "file"], - ); + let (code, stdout, stderr) = run_cli(tmp.path(), &mock.uri(), &["repair"]); assert_eq!(code, 0, "stdout={stdout} stderr={stderr}"); let v = parse_env(&stdout); assert!( @@ -1576,7 +1568,7 @@ async fn repair_no_backend_for_purl_restores_set_aside_bytes() { let (code, stdout, stderr) = run_cli_with( tmp.path(), &mock.uri(), - &["repair", "--download-mode", "file"], + &["repair"], true, &[("DENO_DIR", deno_home.path().to_str().unwrap())], ); @@ -1611,7 +1603,7 @@ async fn repair_no_backend_for_purl_restores_set_aside_bytes() { let (code, stdout, stderr) = run_cli_with( tmp.path(), &mock.uri(), - &["repair", "--download-mode", "file"], + &["repair"], true, &[("DENO_DIR", deno_home.path().to_str().unwrap())], ); diff --git a/crates/socket-patch-cli/tests/repair/repair_invariants.rs b/crates/socket-patch-cli/tests/repair/repair_invariants.rs index 365d063e5..f7c6800b6 100644 --- a/crates/socket-patch-cli/tests/repair/repair_invariants.rs +++ b/crates/socket-patch-cli/tests/repair/repair_invariants.rs @@ -26,7 +26,7 @@ fn binary() -> PathBuf { /// * `SOCKET_MANIFEST_PATH` / `SOCKET_CWD` could point the binary at a /// different manifest than the fixture each test writes, so the /// manifest-not-found / override assertions would be meaningless; -/// * `SOCKET_DOWNLOAD_ONLY` / `SOCKET_DOWNLOAD_MODE` / `SOCKET_DRY_RUN` +/// * `SOCKET_DOWNLOAD_ONLY` / `SOCKET_DRY_RUN` /// could flip the cleanup-vs-download branch out from under the test. /// /// Scrubbing is by prefix, not an explicit list: an explicit list drifts @@ -463,11 +463,9 @@ fn repair_download_only_skips_cleanup() { // We can't use `run_repair` here because it injects `--offline`, // and `--offline` is mutually exclusive with `--download-only` // (offline = strict airgap, download-only = network-only). Invoke - // the binary directly. We pin `--download-mode file` so the - // already-present `afterHash` blob fully satisfies the download - // phase — there's nothing missing to fetch, so the test stays - // hermetic (no network). The default `diff` mode would instead look - // for `.tar.gz`, which is absent, and try to hit the network. + // the binary directly. The already-present `afterHash` blob fully + // satisfies the download phase — there's nothing missing to fetch, so + // the test stays hermetic (no network). let tmp = tempfile::tempdir().expect("tempdir"); let socket = make_socket_dir(tmp.path()); write_blob(&socket, REFERENCED_HASH, b"patched content"); @@ -475,13 +473,7 @@ fn repair_download_only_skips_cleanup() { write_blob(&socket, &orphan_hash, b"orphaned content"); let out = socket_cmd(tmp.path()) - .args([ - "repair", - "--json", - "--download-only", - "--download-mode", - "file", - ]) + .args(["repair", "--json", "--download-only"]) .output() .expect("run socket-patch"); let code = out.status.code().unwrap_or(-1); @@ -786,8 +778,6 @@ async fn repair_json_reports_unresolved_org_fallback_in_warnings() { .args([ "repair", "--json", - "--download-mode", - "file", "--download-only", "--api-url", &mock.uri(), @@ -863,13 +853,7 @@ async fn repair_online_downloads_missing_blob() { std::fs::write(socket.join("manifest.json"), manifest).unwrap(); let out = socket_cmd(tmp.path()) - .args([ - "repair", - "--json", - "--download-mode", - "file", - "--download-only", - ]) + .args(["repair", "--json", "--download-only"]) .env("SOCKET_API_URL", mock.uri()) .env("SOCKET_API_TOKEN", "fake-token-for-test") .env("SOCKET_ORG_SLUG", ORG_SLUG) diff --git a/crates/socket-patch-cli/tests/rollback/rollback_multicopy_blob_gate.rs b/crates/socket-patch-cli/tests/rollback/rollback_multicopy_blob_gate.rs index 1ac5dbca0..b60d8fa61 100644 --- a/crates/socket-patch-cli/tests/rollback/rollback_multicopy_blob_gate.rs +++ b/crates/socket-patch-cli/tests/rollback/rollback_multicopy_blob_gate.rs @@ -11,8 +11,7 @@ //! and the rollback loop then failed the still-patched nested copy with //! `MissingBlob` ("Re-download the patch to enable rollback") on a run that //! was online and could have fetched the blob. Every retry failed the same -//! way. Twin of apply's `mismatch_blob_gaps`, which probes every copy for -//! exactly this reason. +//! way. //! //! The stub server plays the authenticated API so the test is hermetic. diff --git a/crates/socket-patch-cli/tests/scan/covgap_commands_fetch_stage.rs b/crates/socket-patch-cli/tests/scan/covgap_commands_fetch_stage.rs index 180311dac..99d030c12 100644 --- a/crates/socket-patch-cli/tests/scan/covgap_commands_fetch_stage.rs +++ b/crates/socket-patch-cli/tests/scan/covgap_commands_fetch_stage.rs @@ -153,25 +153,22 @@ fn apply_offline_nonquiet_lists_capped_missing_purls_and_repair_hint() { } // --------------------------------------------------------------------------- -// Non-quiet online staging: download announcement + diff→blob fallback. +// Non-quiet online staging: cold-cache blob download. // --------------------------------------------------------------------------- -/// A human-mode (no `--json`/`--silent`) online apply in the default -/// `diff` download mode, where the server has no diff archive but serves -/// the per-file blob: the download and fallback progress are transient -/// status lines (nothing permanent on a non-terminal stderr), the diff -/// archive's 404 is not reported (the blobs cover it), and only the -/// "Downloaded 1 blob" result line persists before the apply. `.socket/` -/// stays untouched (downloads land in the overlay tempdir). +/// A human-mode (no `--json`/`--silent`) online apply with a cold cache: +/// the download progress is a transient status line (nothing permanent on +/// a non-terminal stderr), only the "Downloaded 1 blob" result line +/// persists before the apply, and no request ever goes to a diff archive +/// route (v5 fetches per-file blobs only). `.socket/` stays untouched +/// (downloads land in the overlay tempdir). #[tokio::test] -async fn apply_online_nonquiet_prints_download_progress_and_diff_fallback() { +async fn apply_online_nonquiet_cold_cache_fetches_only_blobs() { let before_hash = git_sha256(BEFORE); let after_hash = git_sha256(AFTER); let mock = MockServer::start().await; - // Only the per-file blob endpoint is mounted; the diff/package archive - // endpoints 404 (wiremock's default for unmounted routes), so the - // default diff-mode fetch fails and the blob fallback closes the gap. + // Only the per-file blob endpoint is mounted. Mock::given(method("GET")) .and(path(format!( "/v0/orgs/{ORG_SLUG}/patches/blob/{after_hash}" @@ -211,7 +208,7 @@ async fn apply_online_nonquiet_prints_download_progress_and_diff_fallback() { } }, "vulnerabilities": {}, - "description": "diff fallback target", + "description": "cold cache target", "license": "MIT", "tier": "free", } @@ -239,25 +236,33 @@ async fn apply_online_nonquiet_prints_download_progress_and_diff_fallback() { // on a non-terminal stderr); its result lines stay, on stderr (stdout // is for results). assert!( - !stderr.contains("Downloading missing patch artifacts") - && !stderr.contains("unavailable; fetching"), + !stderr.contains("Downloading missing patch artifacts"), "progress is transient, never a permanent line; stderr={stderr}" ); assert!( - !stderr.contains("Failed to download") && !stderr.contains("Diff archive not found"), - "a missing diff archive the blob fallback covers is not reported as a failure; \ - stderr={stderr}" + !stderr.contains("Failed to download"), + "the blob download succeeded; stderr={stderr}" ); assert!( stderr.contains("Downloaded 1 blob"), - "the fallback's own result line is printed; stderr={stderr}" + "the blob download's result line is printed; stderr={stderr}" ); + let requests = mock.received_requests().await.unwrap(); + assert!( + requests.iter().all(|r| !r.url.path().contains("/diff")), + "a cold-cache apply must never request a diff archive; got {:?}", + requests + .iter() + .map(|r| r.url.path().to_string()) + .collect::>() + ); + assert!(!socket.join("diffs").exists()); assert!( !stdout.contains("Downloading") && !stdout.contains("Downloaded"), "no progress on stdout; stdout={stdout}" ); - // The fallback actually applied the patch… + // The download actually applied the patch… assert_eq!( std::fs::read(pkg.join("index.js")).unwrap(), AFTER, diff --git a/crates/socket-patch-cli/tests/spawn_env_hygiene.rs b/crates/socket-patch-cli/tests/spawn_env_hygiene.rs index 2447d2091..e6cbcaffd 100644 --- a/crates/socket-patch-cli/tests/spawn_env_hygiene.rs +++ b/crates/socket-patch-cli/tests/spawn_env_hygiene.rs @@ -68,7 +68,6 @@ const PENDING_RAW_SPAWNS: &[&str] = &[ "covgap_commands_vendor.rs", "covgap_commands_vex.rs", "covgap_utils_socket_cli_config.rs", - "diff_created_file_e2e.rs", "e2e_cargo.rs", "e2e_composer.rs", "e2e_composer_version_identity.rs", diff --git a/crates/socket-patch-core/Cargo.toml b/crates/socket-patch-core/Cargo.toml index b331a0734..46e168495 100644 --- a/crates/socket-patch-core/Cargo.toml +++ b/crates/socket-patch-core/Cargo.toml @@ -37,7 +37,6 @@ regex = { workspace = true } aho-corasick = { workspace = true } toml_edit = { workspace = true } once_cell = { workspace = true } -qbsdiff = { workspace = true } rayon = { workspace = true } tar = { workspace = true } flate2 = { workspace = true } diff --git a/crates/socket-patch-core/src/api/blob_fetcher.rs b/crates/socket-patch-core/src/api/blob_fetcher.rs index 4f8fd22d2..987cfe2d5 100644 --- a/crates/socket-patch-core/src/api/blob_fetcher.rs +++ b/crates/socket-patch-core/src/api/blob_fetcher.rs @@ -5,44 +5,6 @@ use crate::api::client::{ApiClient, ApiError, BinaryBody}; use crate::hash::git_sha256::{compute_git_sha256_from_bytes, compute_git_sha256_from_reader}; use crate::manifest::operations::get_after_hash_blobs; use crate::manifest::schema::PatchManifest; -use crate::patch::apply::PatchSources; - -/// Selects which kind of patch artifact `fetch_missing_sources` downloads. -/// -/// * `File` — per-file blobs (legacy, largest, always applicable). -/// * `Diff` — per-patch tar.gz of bsdiff deltas (smallest, only useful -/// when the original file is on disk). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DownloadMode { - Diff, - File, -} - -impl DownloadMode { - /// Short lowercase tag, suitable for JSON output and `--download-mode` - /// flag values. - pub fn as_tag(&self) -> &'static str { - match self { - DownloadMode::Diff => "diff", - DownloadMode::File => "file", - } - } - - /// Parse `--download-mode` flag values. - pub fn parse(s: &str) -> Result { - match s.to_ascii_lowercase().as_str() { - "diff" => Ok(DownloadMode::Diff), - // Removed: no deployed server ever served its GET archive route, - // so every fetch failed and fell back to per-file blobs. - "package" => Err("download mode 'package' was removed; use diff or file".to_string()), - "file" | "blob" => Ok(DownloadMode::File), - other => Err(format!( - "unknown download mode '{}'. Expected diff or file.", - other - )), - } - } -} /// Result of fetching a single blob. #[derive(Debug, Clone)] @@ -117,7 +79,7 @@ pub async fn fetch_missing_blobs( // (`stream_cache_entry_atomic`), never up front: a fetch that lands // nothing leaves no `.socket/blobs/` husk behind. let hashes: Vec = missing.into_iter().collect(); - download_entries(&hashes, blobs_path, client, on_progress, Entry::Blob).await + download_entries(&hashes, blobs_path, client, on_progress).await } /// Download specific blobs identified by their hashes. @@ -167,8 +129,7 @@ pub async fn fetch_blobs_by_hash( }; } - let download_result = - download_entries(&to_download, blobs_path, client, on_progress, Entry::Blob).await; + let download_result = download_entries(&to_download, blobs_path, client, on_progress).await; results.extend(download_result.results); FetchMissingBlobsResult { @@ -180,70 +141,6 @@ pub async fn fetch_blobs_by_hash( } } -/// Return the set of patch UUIDs whose archive at -/// `/.tar.gz` is missing from disk. Used as the -/// "what do I need to download" query for diff mode. -pub async fn get_missing_archives( - manifest: &PatchManifest, - archives_dir: &Path, -) -> HashSet { - let mut missing = HashSet::new(); - for record in manifest.patches.values() { - let archive_path = archives_dir.join(format!("{}.tar.gz", record.uuid)); - if tokio::fs::metadata(&archive_path).await.is_err() { - missing.insert(record.uuid.clone()); - } - } - missing -} - -/// Download all missing archives for the chosen [`DownloadMode`]. -/// -/// * [`DownloadMode::File`] delegates to [`fetch_missing_blobs`]. -/// * [`DownloadMode::Diff`] downloads each missing `.tar.gz` into -/// `sources.diffs_path` via [`ApiClient::fetch_diff`]. -/// -/// Returns a [`FetchMissingBlobsResult`] in which each `BlobFetchResult`'s -/// `hash` field carries the patch UUID (not a blob hash) for diff mode. A -/// `sources.diffs_path` of `None` while requesting diff mode yields an -/// immediate empty result — the caller is expected to fall back to a -/// different mode in that case. -pub async fn fetch_missing_sources( - manifest: &PatchManifest, - sources: &PatchSources<'_>, - mode: DownloadMode, - client: &ApiClient, - on_progress: Option<&OnProgress>, -) -> FetchMissingBlobsResult { - let dir = match mode { - DownloadMode::File => { - return fetch_missing_blobs(manifest, sources.blobs_path, client, on_progress).await - } - DownloadMode::Diff => sources.diffs_path, - }; - match dir { - Some(dir) => fetch_missing_diff_archives(manifest, dir, client, on_progress).await, - None => FetchMissingBlobsResult::default(), - } -} - -async fn fetch_missing_diff_archives( - manifest: &PatchManifest, - archives_dir: &Path, - client: &ApiClient, - on_progress: Option<&OnProgress>, -) -> FetchMissingBlobsResult { - let missing = get_missing_archives(manifest, archives_dir).await; - if missing.is_empty() { - return FetchMissingBlobsResult::default(); - } - - // `archives_dir` is created by the first successful write, never up - // front (see `fetch_missing_blobs`). - let uuids: Vec = missing.into_iter().collect(); - download_entries(&uuids, archives_dir, client, on_progress, Entry::Diff).await -} - /// What kind of artifact a fetch or cleanup result counts, for human /// output: the singular/plural noun and whether ids are long enough to be /// worth abbreviating (64-hex blob hashes are; patch UUIDs are the lookup @@ -282,7 +179,9 @@ pub const BLOB: ArtifactNoun = ArtifactNoun { abbreviate_ids: true, }; -/// Per-patch diff archives (`.socket/diffs/.tar.gz`). +/// Legacy per-patch diff archives (`.socket/diffs/.tar.gz`). v5 +/// removed the diff download path, so nothing writes or reads them any +/// more; only the cleanup sweeps name them. pub const DIFF_ARCHIVE: ArtifactNoun = ArtifactNoun { one: "diff archive", many: "diff archives", @@ -297,16 +196,6 @@ pub const PACKAGE_ARCHIVE: ArtifactNoun = ArtifactNoun { abbreviate_ids: false, }; -impl DownloadMode { - /// The artifact noun a download in this mode fetches. - pub fn noun(&self) -> ArtifactNoun { - match self { - DownloadMode::Diff => DIFF_ARCHIVE, - DownloadMode::File => BLOB, - } - } -} - /// How many failures a fetch result lists before "... and N more". const MAX_LISTED_FAILURES: usize = 5; @@ -375,7 +264,7 @@ pub fn format_fetch_failures(result: &FetchMissingBlobsResult, noun: ArtifactNou } /// Drop the id the client's error repeats: the line already starts with -/// it, so `Network error fetching diff : ` reads as +/// it, so `Network error fetching blob : ` reads as /// `network error: `. Anything else is returned unchanged. fn concise_fetch_error<'a>(err: &'a str, id: &str) -> std::borrow::Cow<'a, str> { if let Some(rest) = err.strip_prefix("Network error fetching ") { @@ -473,11 +362,11 @@ fn guard_cache_entry(dest: &Path) -> std::io::Result<()> { /// never held in memory whole (#571). /// /// The destinations here are *content-addressed* cache entries — -/// `blobs/` and `archives/.tar.gz`. A plain `tokio::fs::write` +/// `blobs/`. A plain `tokio::fs::write` /// truncates-then-writes in place, so an interrupted write (ENOSPC, crash, /// killed process) can leave a partial file at the final path. Because the -/// "is it already downloaded?" check ([`get_missing_blobs`] / -/// [`get_missing_archives`]) only tests for presence, such a truncated file +/// "is it already downloaded?" check ([`get_missing_blobs`]) only tests +/// for presence, such a truncated file /// is then trusted forever — its content no longer hashes to its name, yet /// it is never re-downloaded. Staging in the same directory and renaming /// makes the final path always either the complete bytes or absent, never a @@ -500,7 +389,7 @@ async fn stream_cache_entry_atomic( "cache entry path has no parent directory", )) })?; - // The cache directory (`.socket/blobs/`, `.socket/diffs/`) is created + // The cache directory (`.socket/blobs/`) is created // here, by the first download, and nowhere earlier. A fetch that lands // nothing (all 404, offline, every hash mismatched, every body cut // short) must not leave an empty directory behind for the user to @@ -594,25 +483,15 @@ fn blob_hash_matches(expected: &str, actual: &str) -> bool { expected.eq_ignore_ascii_case(actual) } -/// The two kinds of cache entry [`download_entries`] stores. -#[derive(Debug, Clone, Copy)] -enum Entry { - /// `blobs/`, verified against its git-sha256 name. - Blob, - /// `diffs/.tar.gz`, stored as served. - Diff, -} - -/// Download `ids` sequentially, streaming each into its cache entry under -/// `dir` (see [`stream_cache_entry_atomic`]). The one download loop behind -/// [`fetch_missing_blobs`], [`fetch_blobs_by_hash`] and diff-mode -/// [`fetch_missing_sources`]. +/// Download the blobs `ids` sequentially, streaming each into +/// `dir/` and verifying it against its git-sha256 name (see +/// [`stream_cache_entry_atomic`]). The one download loop behind +/// [`fetch_missing_blobs`] and [`fetch_blobs_by_hash`]. async fn download_entries( ids: &[String], dir: &Path, client: &ApiClient, on_progress: Option<&OnProgress>, - entry: Entry, ) -> FetchMissingBlobsResult { let total = ids.len(); let mut downloaded: usize = 0; @@ -624,29 +503,13 @@ async fn download_entries( cb(id, i + 1, total); } - let (fetched, dest, expected_hash, noun, not_found) = match entry { - Entry::Blob => ( - client.fetch_blob(id).await, - dir.join(id), - Some(id.as_str()), - "blob", - "Blob not found on server", - ), - Entry::Diff => ( - client.fetch_diff(id).await, - dir.join(format!("{}.tar.gz", id)), - None, - "archive", - "Diff archive not found on server", - ), - }; - let error = match fetched { + let error = match client.fetch_blob(id).await { Ok(Some(mut body)) => { - match stream_cache_entry_atomic(&dest, &mut body, expected_hash).await { + match stream_cache_entry_atomic(&dir.join(id), &mut body, Some(id)).await { Ok(()) => None, Err(EntryError::Body(e)) => Some(e.to_string()), Err(EntryError::Write(e)) => { - Some(format!("Failed to write {} to disk: {}", noun, e)) + Some(format!("Failed to write blob to disk: {}", e)) } Err(EntryError::HashMismatch(actual)) => Some(format!( "Content hash mismatch: expected {}, got {}", @@ -654,7 +517,7 @@ async fn download_entries( )), } } - Ok(None) => Some(not_found.to_string()), + Ok(None) => Some("Blob not found on server".to_string()), Err(e) => Some(e.to_string()), }; if error.is_none() { @@ -927,106 +790,6 @@ mod tests { assert!(output.contains("unknown error")); } - // ── DownloadMode + archive helpers ────────────────────────────── - - #[test] - fn test_download_mode_parse() { - assert_eq!(DownloadMode::parse("diff").unwrap(), DownloadMode::Diff); - assert_eq!(DownloadMode::parse("DIFF").unwrap(), DownloadMode::Diff); - // `package` was removed; the error names the surviving modes. - assert!(DownloadMode::parse("package") - .unwrap_err() - .contains("removed")); - assert_eq!(DownloadMode::parse("file").unwrap(), DownloadMode::File); - // `blob` aliases to `file` so users can think in pre-2.2 terms. - assert_eq!(DownloadMode::parse("blob").unwrap(), DownloadMode::File); - assert!(DownloadMode::parse("nope").is_err()); - } - - #[test] - fn test_download_mode_tag() { - assert_eq!(DownloadMode::Diff.as_tag(), "diff"); - assert_eq!(DownloadMode::File.as_tag(), "file"); - } - - fn make_manifest_with_uuids(uuids: &[&str]) -> PatchManifest { - let mut patches = HashMap::new(); - for (i, uuid) in uuids.iter().enumerate() { - let key = format!("pkg:npm/test-{}@1.0.0", i); - patches.insert( - key, - PatchRecord { - uuid: (*uuid).to_string(), - exported_at: "2024-01-01T00:00:00Z".to_string(), - files: HashMap::new(), - vulnerabilities: HashMap::new(), - description: "test".to_string(), - license: "MIT".to_string(), - tier: "free".to_string(), - }, - ); - } - PatchManifest { - patches, - setup: None, - } - } - - #[tokio::test] - async fn test_get_missing_archives_all_missing() { - let dir = tempfile::tempdir().unwrap(); - let archives = dir.path().join("packages"); - tokio::fs::create_dir_all(&archives).await.unwrap(); - - let u1 = "11111111-1111-4111-8111-111111111111"; - let u2 = "22222222-2222-4222-8222-222222222222"; - let manifest = make_manifest_with_uuids(&[u1, u2]); - - let missing = get_missing_archives(&manifest, &archives).await; - assert_eq!(missing.len(), 2); - assert!(missing.contains(u1)); - assert!(missing.contains(u2)); - } - - #[tokio::test] - async fn test_get_missing_archives_some_present() { - let dir = tempfile::tempdir().unwrap(); - let archives = dir.path().join("packages"); - tokio::fs::create_dir_all(&archives).await.unwrap(); - - let u1 = "11111111-1111-4111-8111-111111111111"; - let u2 = "22222222-2222-4222-8222-222222222222"; - - tokio::fs::write(archives.join(format!("{u1}.tar.gz")), b"data") - .await - .unwrap(); - - let manifest = make_manifest_with_uuids(&[u1, u2]); - let missing = get_missing_archives(&manifest, &archives).await; - assert_eq!(missing.len(), 1); - assert!(missing.contains(u2)); - assert!(!missing.contains(u1)); - } - - #[tokio::test] - async fn test_fetch_missing_sources_unsupported_mode_returns_empty() { - // Asking for Diff mode without a diffs_path yields an empty result - // rather than panicking. - let dir = tempfile::tempdir().unwrap(); - let blobs = dir.path().join("blobs"); - tokio::fs::create_dir_all(&blobs).await.unwrap(); - let sources = PatchSources::blobs_only(&blobs); - - let manifest = make_manifest_with_uuids(&["11111111-1111-4111-8111-111111111111"]); - let (client, _) = crate::api::client::get_api_client_from_env(None).await; - - let res = - fetch_missing_sources(&manifest, &sources, DownloadMode::Diff, &client, None).await; - assert_eq!(res.total, 0); - assert_eq!(res.downloaded, 0); - assert_eq!(res.failed, 0); - } - // ── Regression: skipped accounting in format ───────────────────── #[test] @@ -1128,7 +891,7 @@ mod tests { route: crate::api::client::ApiRoute::Proxy, }); let body = client - .fetch_diff("11111111-1111-4111-8111-111111111111") + .fetch_blob(&"a".repeat(64)) .await .unwrap() .expect("200 serves a body"); @@ -1350,8 +1113,6 @@ mod tests { assert_eq!(DIFF_ARCHIVE.count(1), "1 diff archive"); assert_eq!(DIFF_ARCHIVE.count(3), "3 diff archives"); assert_eq!(PACKAGE_ARCHIVE.count(1), "1 package archive"); - assert_eq!(DownloadMode::Diff.noun(), DIFF_ARCHIVE); - assert_eq!(DownloadMode::File.noun(), BLOB); } #[test] @@ -1371,33 +1132,6 @@ mod tests { assert_eq!(DIFF_ARCHIVE.display_id(uuid), uuid); } - #[test] - fn diff_mode_result_names_diff_archives_and_full_uuids_sorted() { - let result = failed_result(vec![ - failure( - "22222222-2222-4222-8222-222222222222", - "Diff archive not found on server", - ), - failure( - "11111111-1111-4111-8111-111111111111", - "Network error fetching diff 11111111-1111-4111-8111-111111111111: \ - error sending request for url (http://127.0.0.1:9/patch/diff/x)", - ), - ]); - assert_eq!( - format_fetch_result_for(&result, DIFF_ARCHIVE), - "Failed to download 2 diff archives\n\ - \x20 - 11111111-1111-4111-8111-111111111111: network error: \ - error sending request for url (http://127.0.0.1:9/patch/diff/x)\n\ - \x20 - 22222222-2222-4222-8222-222222222222: Diff archive not found on server" - ); - let empty = FetchMissingBlobsResult::default(); - assert_eq!( - format_fetch_result_for(&empty, DIFF_ARCHIVE), - "All diff archives are present locally." - ); - } - #[test] fn failures_are_listed_in_sorted_order_regardless_of_input_order() { let ids = ["e", "b", "a", "d", "c", "g", "f"]; diff --git a/crates/socket-patch-core/src/api/client.rs b/crates/socket-patch-core/src/api/client.rs index d12a0821c..07a18cf5f 100644 --- a/crates/socket-patch-core/src/api/client.rs +++ b/crates/socket-patch-core/src/api/client.rs @@ -177,9 +177,9 @@ pub async fn hold_back_debug(fut: impl std::future::Future) -> He HeldBack { value, debug } } -/// The body of a 200 blob or diff response, read chunk by chunk. +/// The body of a 200 blob response, read chunk by chunk. /// -/// [`ApiClient::fetch_blob`] / [`ApiClient::fetch_diff`] return this instead +/// [`ApiClient::fetch_blob`] returns this instead /// of the whole body so a large patch artifact streams to disk without being /// held in memory (#571). The per-read idle bound of [`ApiTimeouts`] applies /// to every chunk. @@ -262,7 +262,7 @@ pub struct ApiClient { /// Header-free twin of `client` (User-Agent only, never Authorization) /// for the public-proxy and grant-tokenized serve requests, where /// sending the Socket bearer would leak it to a third party. Built once - /// here so every blob/diff/tarball download shares one connection pool + /// here so every blob/tarball download shares one connection pool /// instead of paying a fresh TLS-config build + handshake per request. plain: reqwest::Client, api_url: String, @@ -1084,24 +1084,8 @@ impl ApiClient { self.fetch_binary("blob", hash).await } - /// Fetch a per-file diff archive (tar.gz of bsdiff deltas) by patch UUID. - /// - /// Returns the archive body as a [`BinaryBody`] stream, or `Ok(None)` if - /// not found (404). The public proxy serves these under - /// `/patch/diff/`; the authenticated API serves them under - /// `/v0/orgs//patches/diff/`. - pub async fn fetch_diff(&self, uuid: &str) -> Result, ApiError> { - if !is_uuid_shaped(uuid) { - return Err(ApiError::InvalidHash(format!( - "Invalid patch UUID: {}", - uuid - ))); - } - self.fetch_binary("diff", uuid).await - } - /// Build the URL (and an `is_authenticated` flag) for a binary fetch of - /// `kind` (`blob` / `diff`) identified by `identifier`. + /// `kind` (the URL segment, e.g. `blob`) identified by `identifier`. /// /// Follows the client's [`ApiRoute`], like the JSON endpoints: the org /// API's `/v0/orgs//patches/...` with the bearer, or the proxy's @@ -1124,10 +1108,10 @@ impl ApiClient { } } - /// Shared implementation for `fetch_blob` / `fetch_diff`. + /// Transport behind `fetch_blob`. /// - /// `kind` is the URL segment (`blob` / `diff`), doubling as the - /// noun in log + error messages. `identifier` is the hash or UUID + /// `kind` is the URL segment (`blob`), doubling as the + /// noun in log + error messages. `identifier` is the hash /// interpolated into the URL. A 200 returns the unread body: callers /// stream it to disk instead of buffering it whole. async fn fetch_binary( @@ -1170,8 +1154,8 @@ impl ApiClient { return Ok(None); } // Classify 401/403/429 identically to the JSON transport path - // (`handle_json_response`). Without this an authenticated blob/diff/ - // package fetch that 401s/403s would surface as `ApiError::Other`, + // (`handle_json_response`). Without this an authenticated blob + // fetch that 401s/403s would surface as `ApiError::Other`, // which `is_fallback_candidate` ignores — silently disabling the // auth→proxy fallback for binary downloads. `use_auth` is the // authenticated-endpoint flag, so `!use_auth` is the proxy case that @@ -3260,8 +3244,7 @@ mod tests { /// `fetch_blob` must reject a malformed hash *before* any network I/O: /// the client points at a closed port, so a regression that bypasses the /// `is_hex(hash, 64)` guard surfaces as `ApiError::Network` instead - /// of `InvalidHash` (mirrors `invalid_uuid_is_failed_without_network`; - /// `fetch_diff`'s twin guard is already covered). + /// of `InvalidHash` (mirrors `invalid_uuid_is_failed_without_network`). #[tokio::test] async fn fetch_blob_invalid_hash_rejected_without_network() { let client = ApiClient::new(ApiClientOptions { @@ -3830,21 +3813,6 @@ mod tests { assert!(!is_uuid_shaped("80630680xxxxx")); } - // ── fetch_diff validation tests ────────────────────────────────── - // - // These tests cover input validation only — they intentionally do - // NOT hit the network. The shared `fetch_binary` helper handles the - // transport, and `fetch_blob` already has integration coverage via - // the e2e_npm test. - - #[tokio::test] - async fn test_fetch_diff_rejects_invalid_uuid() { - std::env::remove_var("SOCKET_API_TOKEN"); - let (client, _) = get_api_client_from_env(None).await; - let result = client.fetch_diff("not-a-uuid").await; - assert!(matches!(result, Err(ApiError::InvalidHash(_)))); - } - // ── Token shape validation ───────────────────────────────────────── #[test] @@ -4012,7 +3980,7 @@ mod tests { // ── classify_auth_error: shared 401/403/429 classification ────────── // // Both transport paths (including `fetch_binary`) route through this - // shared classifier, so an authenticated blob/diff/package fetch that + // shared classifier, so an authenticated blob fetch that // 401s/403s is recognized by `is_fallback_candidate` and the auth→proxy // fallback fires. These pin its contract directly. @@ -4157,7 +4125,7 @@ mod tests { assert!(!looks_like_token_hash("")); } - // ── binary_url: proxy override must reach blob/diff/package fetches ── + // ── binary_url: proxy override must reach blob fetches ── // // `fetch_binary` must use the client's configured `api_url`, not // re-derive the proxy base from `SOCKET_PROXY_URL`/default, so a @@ -4180,15 +4148,6 @@ mod tests { assert_eq!(url, "https://custom.proxy.example/patch/blob/deadbeef"); } - #[test] - fn binary_url_proxy_covers_diff() { - let client = proxy_client("https://custom.proxy.example"); - assert_eq!( - client.binary_url("diff", "uuid-1").0, - "https://custom.proxy.example/patch/diff/uuid-1" - ); - } - #[test] fn binary_url_proxy_trims_trailing_slash() { // `new()` trims the trailing slash on api_url; binary_url also trims @@ -4207,11 +4166,11 @@ mod tests { api_token: Some("sktsec_x_api".into()), route: ApiRoute::org("my-org"), }); - let (url, use_auth) = client.binary_url("diff", "uuid-123"); + let (url, use_auth) = client.binary_url("blob", "deadbeef"); assert!(use_auth); assert_eq!( url, - "https://api.socket.dev/v0/orgs/my-org/patches/diff/uuid-123" + "https://api.socket.dev/v0/orgs/my-org/patches/blob/deadbeef" ); } diff --git a/crates/socket-patch-core/src/manifest/cleanup_blobs.rs b/crates/socket-patch-core/src/manifest/cleanup_blobs.rs index 1f003c937..455637ec4 100644 --- a/crates/socket-patch-core/src/manifest/cleanup_blobs.rs +++ b/crates/socket-patch-core/src/manifest/cleanup_blobs.rs @@ -19,12 +19,11 @@ pub struct CleanupResult { pub failed: Vec, } -/// The blob hashes and patch archives a cleanup pass must preserve. +/// The blob hashes a cleanup pass must preserve. /// These are references, not synthetic patch records: filenames and patch /// metadata cannot change which original or patched bytes remain reachable. pub struct ArtifactReferences { blobs: HashSet, - patch_uuids: HashSet, } impl ArtifactReferences { @@ -32,7 +31,6 @@ impl ArtifactReferences { pub fn for_apply(manifest: &PatchManifest) -> Self { Self { blobs: get_after_hash_blobs(manifest), - patch_uuids: manifest.patches.values().map(|r| r.uuid.clone()).collect(), } } @@ -50,16 +48,11 @@ impl ArtifactReferences { .into_iter() .filter_map(|purl| previous.patches.get(purl)), ) { - let mut has_original = false; for file in record.files.values() { if !file.before_hash.is_empty() { references.blobs.insert(file.before_hash.clone()); - has_original = true; } } - if has_original { - references.patch_uuids.insert(record.uuid.clone()); - } } references } @@ -72,21 +65,23 @@ impl ArtifactReferences { self.blobs.contains(name) }) .await, - diffs: cleanup_archives(&self.patch_uuids, &socket_dir.join("diffs"), dry_run).await, - // Nothing writes or reads legacy package archives any more. + // Nothing writes or reads legacy diff or package archives any + // more (v5 fetches patch content as per-file blobs only), so + // every file in either directory is an orphan. + diffs: cleanup_dir(&socket_dir.join("diffs"), dry_run, |_| false).await, packages: cleanup_dir(&socket_dir.join("packages"), dry_run, |_| false).await, } } } -/// Results from the independent blob, diff and legacy package sweeps. +/// Results from the independent blob and legacy diff/package sweeps. pub struct ArtifactSweep { pub blobs: std::io::Result, pub diffs: std::io::Result, pub packages: std::io::Result, } -/// Shared core for `cleanup_unused_blobs` / `cleanup_unused_archives`. +/// Shared core for `cleanup_unused_blobs` and [`ArtifactReferences::sweep`]. /// /// Walks `dir`, treats it as authoritative socket-patch state (so any /// regular non-hidden file is considered for removal), and asks @@ -186,44 +181,6 @@ pub async fn cleanup_unused_blobs( cleanup_dir(blobs_dir, dry_run, |name| used_blobs.contains(name)).await } -/// Cleans up unused per-patch archive files from `archives_dir`. -/// -/// Archives are named `.tar.gz`. Any file matching that -/// pattern whose UUID is not present in the manifest is removed. Files -/// that do *not* end in `.tar.gz` are treated as orphans and also -/// removed — these directories are managed exclusively by socket-patch, -/// so any stray non-archive file is assumed to be left over from an -/// older socket-patch version. Subdirectories and hidden files are -/// left untouched. -pub async fn cleanup_unused_archives( - manifest: &PatchManifest, - archives_dir: &Path, - dry_run: bool, -) -> Result { - let used_uuids: HashSet = manifest.patches.values().map(|r| r.uuid.clone()).collect(); - cleanup_archives(&used_uuids, archives_dir, dry_run).await -} - -async fn cleanup_archives( - used_uuids: &HashSet, - archives_dir: &Path, - dry_run: bool, -) -> Result { - cleanup_dir(archives_dir, dry_run, |name| { - // Strip the .tar.gz suffix to recover the UUID. A file that does - // not end in .tar.gz is never a valid archive, so it is always an - // orphan -- even if its bare name happens to equal a manifest UUID - // (e.g. a stray `` file with no extension). Returning false - // here keeps that contract: only well-formed `.tar.gz` files - // whose UUID is referenced are kept. - match name.strip_suffix(".tar.gz") { - Some(uuid_part) => used_uuids.contains(uuid_part), - None => false, - } - }) - .await -} - /// Formats a blob cleanup result for human-readable output (see /// [`format_cleanup_result_for`]). pub fn format_cleanup_result(result: &CleanupResult, dry_run: bool) -> String { @@ -412,7 +369,6 @@ mod tests { let keep_original = matches!(policy, "remaining" | "not-installed"); let keep_patched = matches!(policy, "apply" | "remaining"); let kept = 2 * usize::from(keep_original) + 3 * usize::from(keep_patched); - let keep_archive = keep_original || keep_patched; let preview = references.sweep(dir.path(), true).await; assert_eq!( @@ -420,10 +376,8 @@ mod tests { hashes.len() - kept, "{policy}" ); - assert_eq!( - preview.diffs.unwrap().blobs_removed, - 2 - usize::from(keep_archive) - ); + // Diff archives are obsolete: even a referenced UUID's goes. + assert_eq!(preview.diffs.unwrap().blobs_removed, 2, "{policy}"); assert_eq!(preview.packages.unwrap().blobs_removed, 1); assert_eq!(std::fs::read_dir(&blobs).unwrap().count(), hashes.len()); assert_eq!(std::fs::read_dir(&diffs).unwrap().count(), 2); @@ -435,10 +389,7 @@ mod tests { hashes.len() - kept, "{policy}" ); - assert_eq!( - swept.diffs.unwrap().blobs_removed, - 2 - usize::from(keep_archive) - ); + assert_eq!(swept.diffs.unwrap().blobs_removed, 2, "{policy}"); assert_eq!(swept.packages.unwrap().blobs_removed, 1); for hash in [BEFORE_HASH_1, BEFORE_HASH_2] { assert_eq!(blobs.join(hash).exists(), keep_original, "{policy}"); @@ -447,7 +398,7 @@ mod tests { assert_eq!(blobs.join(hash).exists(), keep_patched, "{policy}"); } assert!(!blobs.join(ORPHAN_HASH).exists()); - assert_eq!(diffs.join(archive).exists(), keep_archive, "{policy}"); + assert!(!diffs.exists(), "{policy}"); assert!(!packages.exists()); } } @@ -744,163 +695,6 @@ mod tests { ); } - // ── cleanup_unused_archives tests ────────────────────────────── - - const SECOND_UUID: &str = "22222222-2222-4222-8222-222222222222"; - - #[tokio::test] - async fn test_cleanup_archives_keeps_referenced_uuid() { - let dir = tempfile::tempdir().unwrap(); - let archives = dir.path().join("packages"); - tokio::fs::create_dir_all(&archives).await.unwrap(); - - let manifest = create_test_manifest(); - tokio::fs::write(archives.join(format!("{TEST_UUID}.tar.gz")), b"keep") - .await - .unwrap(); - tokio::fs::write(archives.join(format!("{SECOND_UUID}.tar.gz")), b"orphan") - .await - .unwrap(); - - let result = cleanup_unused_archives(&manifest, &archives, false) - .await - .unwrap(); - - assert_eq!(result.blobs_removed, 1); - assert!(result - .removed_blobs - .contains(&format!("{SECOND_UUID}.tar.gz"))); - assert!( - tokio::fs::metadata(archives.join(format!("{TEST_UUID}.tar.gz"))) - .await - .is_ok() - ); - assert!( - tokio::fs::metadata(archives.join(format!("{SECOND_UUID}.tar.gz"))) - .await - .is_err() - ); - } - - #[tokio::test] - async fn test_cleanup_archives_dry_run_does_not_delete() { - let dir = tempfile::tempdir().unwrap(); - let archives = dir.path().join("packages"); - tokio::fs::create_dir_all(&archives).await.unwrap(); - - let manifest = create_test_manifest(); - tokio::fs::write(archives.join(format!("{SECOND_UUID}.tar.gz")), b"orphan") - .await - .unwrap(); - - let result = cleanup_unused_archives(&manifest, &archives, true) - .await - .unwrap(); - - assert_eq!(result.blobs_removed, 1); - assert!( - tokio::fs::metadata(archives.join(format!("{SECOND_UUID}.tar.gz"))) - .await - .is_ok() - ); - } - - #[tokio::test] - async fn test_cleanup_archives_removes_non_archive_files() { - // Stray files (no .tar.gz suffix, or wrong UUID) are treated as - // orphans. This keeps the directory tidy when the on-disk format - // changes in the future. - let dir = tempfile::tempdir().unwrap(); - let archives = dir.path().join("packages"); - tokio::fs::create_dir_all(&archives).await.unwrap(); - - let manifest = create_test_manifest(); - tokio::fs::write(archives.join("stray.txt"), b"junk") - .await - .unwrap(); - tokio::fs::write(archives.join(format!("{TEST_UUID}.tar.gz")), b"keep") - .await - .unwrap(); - - let result = cleanup_unused_archives(&manifest, &archives, false) - .await - .unwrap(); - - assert_eq!(result.blobs_removed, 1); - assert!(result.removed_blobs.contains(&"stray.txt".to_string())); - } - - #[tokio::test] - async fn test_cleanup_archives_removes_bare_uuid_without_extension() { - // Regression: a stray file whose *bare* name equals a referenced - // manifest UUID but lacks the `.tar.gz` extension is NOT a valid - // archive and must be removed as an orphan. The previous - // `strip_suffix(..).unwrap_or(name)` form fell back to matching the - // whole filename against the UUID set and incorrectly KEPT it. - let dir = tempfile::tempdir().unwrap(); - let archives = dir.path().join("packages"); - tokio::fs::create_dir_all(&archives).await.unwrap(); - - let manifest = create_test_manifest(); - // Bare UUID, no extension -- must be treated as an orphan. - tokio::fs::write(archives.join(TEST_UUID), b"not an archive") - .await - .unwrap(); - // The legitimate archive for the same UUID must survive. - tokio::fs::write(archives.join(format!("{TEST_UUID}.tar.gz")), b"keep") - .await - .unwrap(); - - let result = cleanup_unused_archives(&manifest, &archives, false) - .await - .unwrap(); - - assert_eq!(result.blobs_removed, 1); - assert!(result.removed_blobs.contains(&TEST_UUID.to_string())); - assert!(tokio::fs::metadata(archives.join(TEST_UUID)).await.is_err()); - assert!( - tokio::fs::metadata(archives.join(format!("{TEST_UUID}.tar.gz"))) - .await - .is_ok() - ); - } - - #[tokio::test] - async fn test_cleanup_archives_removes_wrong_suffix_with_uuid_stem() { - // A file named `.tar.gz.bak` (or any non-`.tar.gz` suffix) does - // not end in `.tar.gz`, so it is an orphan regardless of its stem. - let dir = tempfile::tempdir().unwrap(); - let archives = dir.path().join("packages"); - tokio::fs::create_dir_all(&archives).await.unwrap(); - - let manifest = create_test_manifest(); - tokio::fs::write(archives.join(format!("{TEST_UUID}.tar.gz.bak")), b"junk") - .await - .unwrap(); - - let result = cleanup_unused_archives(&manifest, &archives, false) - .await - .unwrap(); - - assert_eq!(result.blobs_removed, 1); - assert!(result - .removed_blobs - .contains(&format!("{TEST_UUID}.tar.gz.bak"))); - } - - #[tokio::test] - async fn test_cleanup_archives_nonexistent_dir() { - let dir = tempfile::tempdir().unwrap(); - let archives = dir.path().join("does-not-exist"); - let manifest = create_test_manifest(); - - let result = cleanup_unused_archives(&manifest, &archives, false) - .await - .unwrap(); - assert_eq!(result.blobs_checked, 0); - assert_eq!(result.blobs_removed, 0); - } - #[tokio::test] async fn test_cleanup_does_not_count_subdirs_or_hidden_files() { // Regression: blobs_checked must only count regular, non-hidden files diff --git a/crates/socket-patch-core/src/manifest/schema.rs b/crates/socket-patch-core/src/manifest/schema.rs index ac88535e9..f3619e4c3 100644 --- a/crates/socket-patch-core/src/manifest/schema.rs +++ b/crates/socket-patch-core/src/manifest/schema.rs @@ -675,7 +675,6 @@ mod tests { &pkg, files, &PatchSources::blobs_only(&blobs), - None, false, MismatchPolicy::Strict, ) diff --git a/crates/socket-patch-core/src/patch/apply.rs b/crates/socket-patch-core/src/patch/apply.rs index fa7005178..ac8c1eed7 100644 --- a/crates/socket-patch-core/src/patch/apply.rs +++ b/crates/socket-patch-core/src/patch/apply.rs @@ -6,9 +6,7 @@ use std::path::PathBuf; use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::PatchFileInfo; -use crate::patch::diff::apply_diff; use crate::patch::file_hash::compute_file_git_sha256; -use crate::patch::package::read_archive_filtered; use crate::utils::fs::read_regular_to_bytes; /// Status of a file patch verification. @@ -39,9 +37,8 @@ pub struct VerifyResult { /// NEITHER `beforeHash` nor `afterHash` (and a pre-existing file that is /// missing). /// -/// Mismatch tolerance is safe content-wise in every mode: the diff -/// strategy self-disables on a wrong base, and the archive/blob -/// strategies verify their bytes hash to exactly `afterHash` BEFORE any +/// Mismatch tolerance is safe content-wise in every mode: the blob +/// source verifies its bytes hash to exactly `afterHash` BEFORE any /// write — a tolerated mismatch is overwritten with the verified patched /// content or fails, never silently corrupted. What tolerance can do is /// discard local modifications to the dependency file, which is why @@ -62,12 +59,13 @@ pub enum MismatchPolicy { } /// Which patch source actually wrote the patched bytes for a file. +/// +/// Per-file blobs are the only source since v5 removed the diff download +/// path; the type stays so the `appliedVia` JSON key keeps its shape. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AppliedVia { - /// Bytes were produced by applying a bsdiff delta from - /// `.socket/diffs/.tar.gz`. - Diff, - /// Bytes came from a per-file blob in `.socket/blobs/`. + /// Bytes came from a per-file blob (`.socket/blobs/` or the in-memory + /// overlay). Blob, } @@ -75,7 +73,6 @@ impl AppliedVia { /// Short lowercase tag, suitable for JSON and human output. pub fn as_tag(&self) -> &'static str { match self { - AppliedVia::Diff => "diff", AppliedVia::Blob => "blob", } } @@ -83,12 +80,10 @@ impl AppliedVia { /// Patch sources the apply pipeline may use to obtain patched bytes. /// -/// `blobs_path` is always required and serves as the universal fallback. -/// `diffs_path` is an optional opt-in. +/// `blobs_path` is always required; `mem_blobs` is consulted first. #[derive(Debug, Clone, Copy)] pub struct PatchSources<'a> { pub blobs_path: &'a Path, - pub diffs_path: Option<&'a Path>, /// In-memory blob overlay (`afterHash` → patched bytes), consulted /// BEFORE the on-disk blob dir. The vendor flows stage their patch /// content here so vendoring writes no `.socket/blobs` entries and no @@ -97,14 +92,13 @@ pub struct PatchSources<'a> { } impl<'a> PatchSources<'a> { - /// Construct a `PatchSources` that only knows about the legacy + /// Construct a `PatchSources` that only knows about the on-disk /// per-file blob directory. All remaining callers are same-crate /// tests, hence the `cfg(test)` gate. #[cfg(test)] pub(crate) fn blobs_only(blobs_path: &'a Path) -> Self { Self { blobs_path, - diffs_path: None, mem_blobs: None, } } @@ -761,14 +755,10 @@ async fn chown_blocking( /// /// For each file in `files`, this function: /// 1. Verifies the file is ready to be patched (or already patched). -/// 2. If not dry_run, tries patch sources in order: diff archive → -/// per-file blob. The diff strategy is opt-in via `sources`. +/// 2. If not dry_run, writes each file's patched bytes from its per-file +/// blob (`sources`), verified against `afterHash`. /// 3. Returns a summary of what happened. /// -/// `uuid` is the patch UUID. Pass `Some` to enable diff-archive lookup -/// (`sources.diffs_path` must also be set). Pass `None` to restrict the -/// pipeline to per-file blobs only. -/// /// For npm packages, one on-disk `pkg_path` is not necessarily the only /// physical home of `package@version`: pnpm and vlt materialize a separate /// store copy per peer-dependency (or vlt modifier) combination @@ -790,12 +780,11 @@ pub async fn apply_package_patch( pkg_path: &Path, files: &HashMap, sources: &PatchSources<'_>, - uuid: Option<&str>, dry_run: bool, policy: MismatchPolicy, ) -> ApplyResult { crate::patch::store_copies::fan_out(package_key, pkg_path, |path| async move { - apply_package_patch_at(package_key, &path, files, sources, uuid, dry_run, policy).await + apply_package_patch_at(package_key, &path, files, sources, dry_run, policy).await }) .await } @@ -871,7 +860,6 @@ async fn apply_package_patch_at( pkg_path: &Path, files: &HashMap, sources: &PatchSources<'_>, - uuid: Option<&str>, dry_run: bool, policy: MismatchPolicy, ) -> ApplyResult { @@ -924,8 +912,7 @@ async fn apply_package_patch_at( // Mismatch tolerated (default + force): promote to Ready. // The promoted result KEEPS `expected_hash`/`current_hash` // — the signature callers use to surface the warning. The - // diff strategy self-disables on the wrong base; the - // archive/blob strategies are hash-gated to afterHash. + // blob source is hash-gated to afterHash. (VerifyStatus::HashMismatch, MismatchPolicy::Warn | MismatchPolicy::Force) => { verify_result.status = VerifyStatus::Ready; } @@ -1030,13 +1017,6 @@ async fn apply_package_patch_at( None }; - // Eagerly load the diff archive (if any) into memory so we don't - // reparse the tar.gz once per file. - let diff_entries = match (uuid, sources.diffs_path) { - (Some(uuid), Some(dir)) => load_archive_if_present(dir, uuid, files).await, - _ => None, - }; - // Advisory notes from writes that committed but could not fully restore // metadata (see `apply_file_patch_at`); reported on `error` alongside // `success` — the success-with-note shape the `--force` skip uses. @@ -1053,44 +1033,26 @@ async fn apply_package_patch_at( let normalized = normalize_file_path(file_name); - // Resolve the patched bytes from the first applicable source, in - // order: per-file diff → in-memory blob overlay - // (the vendor flows stage there, so vendoring writes no - // `.socket/blobs` entries) → on-disk blob. A diff candidate is - // applicable only when its product hashes to `afterHash`; a - // stale or corrupt entry falls through, it is not an error. The - // blob is the universal fallback: failing to read it fails the - // file. The diff needs the pre-apply on-disk hash that - // `verify_file_patch` captured — under `--force` a HashMismatch is - // promoted to Ready but `current_hash` keeps the real value, so - // the diff still bails instead of producing garbage. - let current_hash = verify_result.and_then(|v| v.current_hash.as_deref()); - let (patched_content, via): (Cow<'_, [u8]>, AppliedVia) = if let Some(bytes) = - resolve_from_diff( - diff_entries.as_ref(), - normalized, - pkg_path, - file_info, - current_hash, - ) - .await - { - (Cow::Owned(bytes), AppliedVia::Diff) - } else if let Some(bytes) = sources.mem_blobs.and_then(|m| m.get(&file_info.after_hash)) { - (Cow::Borrowed(bytes.as_slice()), AppliedVia::Blob) - } else { - match read_blob(sources.blobs_path, &file_info.after_hash).await { - Ok(bytes) => (Cow::Owned(bytes), AppliedVia::Blob), - Err(msg) => { - result.error = Some(msg); - return result; + // Resolve the patched bytes: the in-memory blob overlay first (the + // vendor flows stage there, so vendoring writes no `.socket/blobs` + // entries), then the on-disk blob. Failing to read the blob fails + // the file. + let (patched_content, via): (Cow<'_, [u8]>, AppliedVia) = + if let Some(bytes) = sources.mem_blobs.and_then(|m| m.get(&file_info.after_hash)) { + (Cow::Borrowed(bytes.as_slice()), AppliedVia::Blob) + } else { + match read_blob(sources.blobs_path, &file_info.after_hash).await { + Ok(bytes) => (Cow::Owned(bytes), AppliedVia::Blob), + Err(msg) => { + result.error = Some(msg); + return result; + } } - } - }; + }; // ONE write site for every source, so a write failure (EACCES on - // the stage, ENOSPC, a failed rename) is reported as what it is - // instead of masquerading as the next source's miss. Single copy: + // the stage, ENOSPC, a failed rename) is reported as what it is. + // Single copy: // the public `apply_package_patch` wrapper fans out to pnpm // peer-variant copies itself, with per-copy verification. match apply_file_patch_at(pkg_path, file_name, &patched_content, &file_info.after_hash) @@ -1169,32 +1131,7 @@ async fn apply_package_patch_at( result } -/// Strategy 1 — per-file diff: apply the bsdiff delta for -/// `normalized_path` to the on-disk file and return the product. Not -/// applicable (`None`) when there is no delta, the entry is a new file -/// (nothing to diff against), `current_hash` is missing or is not the -/// `beforeHash` the delta was authored against — the strong gate: `--force` -/// promotes a HashMismatch to Ready but the captured on-disk hash is still -/// the real one — or the read, the delta or the product hash fails. -async fn resolve_from_diff( - diff_entries: Option<&HashMap>>, - normalized_path: &str, - pkg_path: &Path, - file_info: &PatchFileInfo, - current_hash: Option<&str>, -) -> Option> { - let delta = diff_entries?.get(normalized_path)?; - if file_info.before_hash.is_empty() || current_hash != Some(file_info.before_hash.as_str()) { - return None; - } - let before_bytes = read_regular_to_bytes(&pkg_path.join(normalized_path)) - .await - .ok()?; - let patched = apply_diff(&before_bytes, delta).ok()?; - (compute_git_sha256_from_bytes(&patched) == file_info.after_hash).then_some(patched) -} - -/// Strategy 2 (on-disk half) — read `blobs_path/` fail-closed. +/// The on-disk blob source — read `blobs_path/` fail-closed. /// /// SECURITY: `hash` comes from a committed `.socket/manifest.json` that the /// CI `apply` step applies without user action, so it is validated as a blob @@ -1235,46 +1172,6 @@ pub(crate) async fn read_blob_entry(blob_path: &Path) -> std::io::Result read_regular_to_bytes(blob_path).await } -/// True when a manifest `uuid` is safe to use as the archive file stem: a -/// non-empty run of ASCII alphanumerics, `-` and `_`. Every real -/// `xxxxxxxx-xxxx-…` patch id passes; a separator, `.`, NUL or anything -/// else that could change the joined path is refused. -fn is_safe_archive_uuid(uuid: &str) -> bool { - !uuid.is_empty() - && uuid - .bytes() - .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') -} - -/// Open `

/.tar.gz` (if it exists) and return its entries -/// filtered to the patched files in `files`. Errors and missing files -/// both yield `None` so the caller silently falls through to the next -/// strategy. SECURITY: `uuid` comes from the committed manifest and is -/// used as a path component — anything but a plain single path segment -/// (`../../x`, an absolute path) is treated as "no archive", never joined. -async fn load_archive_if_present( - dir: &Path, - uuid: &str, - files: &HashMap, -) -> Option>> { - if !is_safe_archive_uuid(uuid) { - return None; - } - let archive_path = dir.join(format!("{uuid}.tar.gz")); - if tokio::fs::metadata(&archive_path).await.is_err() { - return None; - } - // `read_archive_filtered` is synchronous (tar + flate2 are sync). Run - // it on the blocking pool so we don't stall the executor for large - // archives. - let archive_path_owned = archive_path.clone(); - let files_owned = files.clone(); - tokio::task::spawn_blocking(move || read_archive_filtered(&archive_path_owned, &files_owned)) - .await - .ok() - .and_then(|r| r.ok()) -} - #[cfg(test)] mod tests { use super::*; @@ -1888,7 +1785,6 @@ mod tests { pkg_dir.path(), &files, &PatchSources::blobs_only(blobs_dir.path()), - None, false, MismatchPolicy::Warn, ) @@ -1940,7 +1836,6 @@ mod tests { pkg_dir.path(), &files, &PatchSources::blobs_only(blobs_dir.path()), - None, false, MismatchPolicy::Warn, ) @@ -1977,7 +1872,6 @@ mod tests { pkg_dir.path(), &files, &PatchSources::blobs_only(blobs_dir.path()), - None, true, MismatchPolicy::Warn, ) @@ -2019,7 +1913,6 @@ mod tests { pkg_dir.path(), &files, &PatchSources::blobs_only(blobs_dir.path()), - None, false, MismatchPolicy::Warn, ) @@ -2052,7 +1945,6 @@ mod tests { pkg_dir.path(), &files, &PatchSources::blobs_only(blobs_dir.path()), - None, false, MismatchPolicy::Warn, ) @@ -2097,7 +1989,6 @@ mod tests { pkg_dir.path(), &files, &PatchSources::blobs_only(blobs_dir.path()), - None, false, policy, ) @@ -2128,7 +2019,6 @@ mod tests { pkg_dir.path(), &files, &PatchSources::blobs_only(blobs_dir.path()), - None, false, MismatchPolicy::Strict, ) @@ -2155,7 +2045,6 @@ mod tests { pkg_dir.path(), &files, &PatchSources::blobs_only(blobs_dir.path()), - None, false, policy, ) @@ -2184,7 +2073,6 @@ mod tests { pkg_dir.path(), &files, &PatchSources::blobs_only(blobs_dir.path()), - None, false, MismatchPolicy::Warn, ) @@ -2197,7 +2085,6 @@ mod tests { pkg_dir.path(), &files, &PatchSources::blobs_only(blobs_dir.path()), - None, false, MismatchPolicy::Force, ) @@ -2227,7 +2114,6 @@ mod tests { pkg_dir.path(), &files, &PatchSources::blobs_only(blobs_dir.path()), - None, true, MismatchPolicy::Warn, ) @@ -2246,81 +2132,34 @@ mod tests { ); } - // ── Fallback-chain tests ───────────────────────────────────────── - // - // Tests below exercise the per-file diff archive - // (.socket/diffs/.tar.gz) and the priority order diff → blob. - - use flate2::write::GzEncoder; - use flate2::Compression as GzCompression; - use qbsdiff::Bsdiff; - - const TEST_UUID: &str = "11111111-1111-4111-8111-111111111111"; - - /// Write a tar.gz archive at `/.tar.gz` containing the - /// given (entry name → bytes) pairs. - fn write_uuid_archive(dir: &Path, uuid: &str, entries: &[(&str, &[u8])]) { - let archive_path = dir.join(format!("{uuid}.tar.gz")); - let file = std::fs::File::create(&archive_path).unwrap(); - let gz = GzEncoder::new(file, GzCompression::default()); - let mut builder = tar::Builder::new(gz); - for (name, data) in entries { - let mut header = tar::Header::new_gnu(); - header.set_size(data.len() as u64); - header.set_mode(0o644); - header.set_cksum(); - builder.append_data(&mut header, name, *data).unwrap(); - } - builder.into_inner().unwrap().finish().unwrap(); - } - - fn make_delta(before: &[u8], after: &[u8]) -> Vec { - let mut delta = Vec::new(); - Bsdiff::new(before, after) - .compare(std::io::Cursor::new(&mut delta)) - .unwrap(); - delta - } - - /// Returns a fully-populated two-source fixture: original file on - /// disk, both (diff, blob) available with valid patched content. - /// Caller can then delete sources to test fallback. + /// A blob-only fixture: the original file on disk and its patched + /// content staged as a per-file blob. Only the macOS `chflags` test + /// below still uses it. + #[cfg(target_os = "macos")] async fn make_fixture() -> ( - tempfile::TempDir, // root holding pkg/, blobs/, diffs/ + tempfile::TempDir, // root holding pkg/ and blobs/ std::path::PathBuf, // pkg dir std::path::PathBuf, // blobs dir - std::path::PathBuf, // diffs dir HashMap, Vec, // original bytes - Vec, // patched bytes ) { let root = tempfile::tempdir().unwrap(); let pkg_dir = root.path().join("pkg"); let blobs_dir = root.path().join("blobs"); - let diffs_dir = root.path().join("diffs"); tokio::fs::create_dir_all(&pkg_dir).await.unwrap(); tokio::fs::create_dir_all(&blobs_dir).await.unwrap(); - tokio::fs::create_dir_all(&diffs_dir).await.unwrap(); let original: Vec = b"the original content of the file".to_vec(); let patched: Vec = b"the PATCHED content of the file!".to_vec(); let before_hash = compute_git_sha256_from_bytes(&original); let after_hash = compute_git_sha256_from_bytes(&patched); - - // On-disk file at pkg/index.js tokio::fs::write(pkg_dir.join("index.js"), &original) .await .unwrap(); - - // Per-file blob at blobs/ tokio::fs::write(blobs_dir.join(&after_hash), &patched) .await .unwrap(); - // Diff archive containing bsdiff(original -> patched) - let delta = make_delta(&original, &patched); - write_uuid_archive(&diffs_dir, TEST_UUID, &[("index.js", &delta)]); - let mut files = HashMap::new(); files.insert( "index.js".to_string(), @@ -2329,157 +2168,7 @@ mod tests { after_hash, }, ); - - ( - root, pkg_dir, blobs_dir, diffs_dir, files, original, patched, - ) - } - - #[tokio::test] - async fn test_apply_via_diff_when_archive_present() { - let (_root, pkg_dir, blobs_dir, diffs_dir, files, _orig, patched) = make_fixture().await; - - let sources = PatchSources { - blobs_path: &blobs_dir, - diffs_path: Some(&diffs_dir), - mem_blobs: None, - }; - let result = apply_package_patch( - "pkg:npm/x@1.0.0", - &pkg_dir, - &files, - &sources, - Some(TEST_UUID), - false, - MismatchPolicy::Warn, - ) - .await; - - assert!(result.success, "expected success: {:?}", result.error); - assert_eq!(result.applied_via.get("index.js"), Some(&AppliedVia::Diff)); - let written = tokio::fs::read(pkg_dir.join("index.js")).await.unwrap(); - assert_eq!(written, patched); - } - - #[tokio::test] - async fn test_apply_falls_back_to_blob_when_no_archives() { - let (_root, pkg_dir, blobs_dir, diffs_dir, files, _orig, patched) = make_fixture().await; - // Delete the diff archive. - tokio::fs::remove_file(diffs_dir.join(format!("{TEST_UUID}.tar.gz"))) - .await - .unwrap(); - - let sources = PatchSources { - blobs_path: &blobs_dir, - diffs_path: Some(&diffs_dir), - mem_blobs: None, - }; - let result = apply_package_patch( - "pkg:npm/x@1.0.0", - &pkg_dir, - &files, - &sources, - Some(TEST_UUID), - false, - MismatchPolicy::Warn, - ) - .await; - - assert!(result.success); - assert_eq!(result.applied_via.get("index.js"), Some(&AppliedVia::Blob)); - let written = tokio::fs::read(pkg_dir.join("index.js")).await.unwrap(); - assert_eq!(written, patched); - } - - #[tokio::test] - async fn test_apply_uuid_none_disables_alt_sources() { - // Even if archives exist, passing `uuid = None` must restrict the - // pipeline to the blob path. - let (_root, pkg_dir, blobs_dir, diffs_dir, files, _orig, _patched) = make_fixture().await; - - let sources = PatchSources { - blobs_path: &blobs_dir, - diffs_path: Some(&diffs_dir), - mem_blobs: None, - }; - let result = apply_package_patch( - "pkg:npm/x@1.0.0", - &pkg_dir, - &files, - &sources, - None, - false, - MismatchPolicy::Warn, - ) - .await; - - assert!(result.success); - assert_eq!(result.applied_via.get("index.js"), Some(&AppliedVia::Blob)); - } - - #[tokio::test] - async fn test_apply_via_diff_falls_through_when_before_hash_mismatch() { - // Corrupt the on-disk file so its hash no longer matches - // before_hash. Diff strategy must NOT run (its output would never - // match after_hash), so we fall through to the blob. - let (_root, pkg_dir, blobs_dir, diffs_dir, files, _orig, patched) = make_fixture().await; - // Overwrite on-disk content with garbage; use --force so verify - // promotes the HashMismatch to Ready and the pipeline still tries - // to apply. - tokio::fs::write(pkg_dir.join("index.js"), b"garbage") - .await - .unwrap(); - - let sources = PatchSources { - blobs_path: &blobs_dir, - diffs_path: Some(&diffs_dir), - mem_blobs: None, - }; - let result = apply_package_patch( - "pkg:npm/x@1.0.0", - &pkg_dir, - &files, - &sources, - Some(TEST_UUID), - false, - MismatchPolicy::Force, - ) - .await; - - assert!(result.success); - // Diff would produce wrong output → strategy skipped → blob writes. - assert_eq!(result.applied_via.get("index.js"), Some(&AppliedVia::Blob)); - let written = tokio::fs::read(pkg_dir.join("index.js")).await.unwrap(); - assert_eq!(written, patched); - } - - #[tokio::test] - async fn test_apply_dry_run_does_not_touch_alternative_sources() { - // Even with a diff archive present, dry-run must not modify - // files on disk. - let (_root, pkg_dir, blobs_dir, diffs_dir, files, original, _patched) = - make_fixture().await; - - let sources = PatchSources { - blobs_path: &blobs_dir, - diffs_path: Some(&diffs_dir), - mem_blobs: None, - }; - let result = apply_package_patch( - "pkg:npm/x@1.0.0", - &pkg_dir, - &files, - &sources, - Some(TEST_UUID), - true, // dry-run - MismatchPolicy::Warn, - ) - .await; - - assert!(result.success); - assert!(result.files_patched.is_empty()); - let on_disk = tokio::fs::read(pkg_dir.join("index.js")).await.unwrap(); - assert_eq!(on_disk, original); + (root, pkg_dir, blobs_dir, files, original) } /// New file in a NEW subdirectory inside a read-only package @@ -2731,17 +2420,9 @@ mod tests { #[test] fn test_applied_via_as_tag() { - assert_eq!(AppliedVia::Diff.as_tag(), "diff"); assert_eq!(AppliedVia::Blob.as_tag(), "blob"); } - #[test] - fn test_patch_sources_blobs_only_disables_other_strategies() { - let dir = tempfile::tempdir().unwrap(); - let sources = PatchSources::blobs_only(dir.path()); - assert!(sources.diffs_path.is_none()); - } - /// Retried partial apply must not wedge cargo: a previous apply /// that failed partway (e.g. a missing blob for the second file) left /// the first file PATCHED on disk but returned before the sidecar @@ -2788,7 +2469,6 @@ mod tests { pkg, &files, &PatchSources::blobs_only(blobs.path()), - None, false, MismatchPolicy::Force, ) @@ -2866,7 +2546,6 @@ mod tests { pkg, &files, &PatchSources::blobs_only(blobs_dir.path()), - None, false, MismatchPolicy::Warn, ) @@ -2954,7 +2633,6 @@ mod tests { pkg_dir.path(), &files, &PatchSources::blobs_only(blobs_dir.path()), - None, false, MismatchPolicy::Warn, ) @@ -3006,7 +2684,6 @@ mod tests { pkg_dir.path(), &files, &PatchSources::blobs_only(blobs_dir.path()), - None, false, MismatchPolicy::Strict, ) @@ -3129,7 +2806,6 @@ mod tests { &pkg, &files, &PatchSources::blobs_only(blobs_dir.path()), - None, false, policy, ) @@ -3199,7 +2875,6 @@ mod tests { &pkg, &files, &PatchSources::blobs_only(blobs_dir.path()), - None, false, MismatchPolicy::Warn, ) @@ -3221,174 +2896,6 @@ mod tests { assert!(!root.path().join("escape.js").exists()); } - // ── resolve_from_diff bail-outs ────────────────────────────────── - // - // Direct-call tests for the private diff resolver's fail-soft - // contract: each bail yields `None` (the pipeline falls through to - // the blob) and touches NOTHING on disk. - - #[tokio::test] - async fn test_resolve_from_diff_bails_on_new_file_entry() { - // A diff entry for a file with empty beforeHash (malformed or - // adversarial patch data): there is no before content to diff - // against, so the strategy must refuse. - let dir = tempfile::tempdir().unwrap(); - let mut entries = HashMap::new(); - entries.insert("new.js".to_string(), make_delta(b"", b"x")); - let info = PatchFileInfo { - before_hash: String::new(), - after_hash: compute_git_sha256_from_bytes(b"x"), - }; - - let applied = resolve_from_diff( - Some(&entries), - "new.js", - dir.path(), - &info, - Some("anything"), - ) - .await; - assert!( - applied.is_none(), - "new-file entries must never apply via diff" - ); - assert!(!dir.path().join("new.js").exists(), "nothing written"); - } - - #[tokio::test] - async fn test_resolve_from_diff_bails_when_target_unreadable() { - // The current_hash gate passes (verify/apply race or permission - // loss) but the on-disk read fails: fail soft, fall through. - let dir = tempfile::tempdir().unwrap(); - let original = b"the original content"; - let patched = b"the patched content!"; - let before_hash = compute_git_sha256_from_bytes(original); - let mut entries = HashMap::new(); - entries.insert("index.js".to_string(), make_delta(original, patched)); - let info = PatchFileInfo { - before_hash: before_hash.clone(), - after_hash: compute_git_sha256_from_bytes(patched), - }; - - // NO file on disk, but current_hash claims the before state. - let applied = resolve_from_diff( - Some(&entries), - "index.js", - dir.path(), - &info, - Some(&before_hash), - ) - .await; - assert!(applied.is_none(), "unreadable target must bail"); - assert!(!dir.path().join("index.js").exists(), "nothing written"); - } - - #[tokio::test] - async fn test_resolve_from_diff_bails_on_corrupt_delta() { - // `.socket/diffs` is on-disk and user-tamperable: garbage delta - // bytes must fail apply_diff and leave the target untouched. - let dir = tempfile::tempdir().unwrap(); - let original = b"the original content"; - let before_hash = compute_git_sha256_from_bytes(original); - tokio::fs::write(dir.path().join("index.js"), original) - .await - .unwrap(); - - let mut entries = HashMap::new(); - entries.insert( - "index.js".to_string(), - b"not a real bsdiff delta header".to_vec(), - ); - let info = PatchFileInfo { - before_hash: before_hash.clone(), - after_hash: compute_git_sha256_from_bytes(b"whatever"), - }; - - let applied = resolve_from_diff( - Some(&entries), - "index.js", - dir.path(), - &info, - Some(&before_hash), - ) - .await; - assert!(applied.is_none(), "corrupt delta must bail"); - assert_eq!( - tokio::fs::read(dir.path().join("index.js")).await.unwrap(), - original, - "target untouched after corrupt delta" - ); - } - - #[tokio::test] - async fn test_resolve_from_diff_bails_on_wrong_target_delta() { - // A delta authored against the RIGHT base but toward the WRONG - // target: apply_diff succeeds, but the product's hash differs - // from afterHash — nothing may be written. - let dir = tempfile::tempdir().unwrap(); - let original = b"the original content"; - let before_hash = compute_git_sha256_from_bytes(original); - tokio::fs::write(dir.path().join("index.js"), original) - .await - .unwrap(); - - let mut entries = HashMap::new(); - entries.insert( - "index.js".to_string(), - make_delta(original, b"unexpected target"), - ); - let info = PatchFileInfo { - before_hash: before_hash.clone(), - // The declared target is DIFFERENT from what the delta produces. - after_hash: compute_git_sha256_from_bytes(b"the real patched content"), - }; - - let applied = resolve_from_diff( - Some(&entries), - "index.js", - dir.path(), - &info, - Some(&before_hash), - ) - .await; - assert!(applied.is_none(), "wrong-target delta must bail"); - assert_eq!( - tokio::fs::read(dir.path().join("index.js")).await.unwrap(), - original, - "nothing written when the product hash mismatches" - ); - } - - /// Pipeline variant: a corrupt diff archive entry must fall through - /// to the blob strategy and still patch successfully. - #[tokio::test] - async fn test_apply_corrupt_diff_falls_through_to_blob() { - let (_root, pkg_dir, blobs_dir, diffs_dir, files, _orig, patched) = make_fixture().await; - // Diff archive holds garbage delta bytes. - write_uuid_archive(&diffs_dir, TEST_UUID, &[("index.js", b"garbage delta")]); - - let sources = PatchSources { - blobs_path: &blobs_dir, - diffs_path: Some(&diffs_dir), - mem_blobs: None, - }; - let result = apply_package_patch( - "pkg:npm/x@1.0.0", - &pkg_dir, - &files, - &sources, - Some(TEST_UUID), - false, - MismatchPolicy::Warn, - ) - .await; - - assert!(result.success, "expected success: {:?}", result.error); - assert_eq!(result.applied_via.get("index.js"), Some(&AppliedVia::Blob)); - let written = tokio::fs::read(pkg_dir.join("index.js")).await.unwrap(); - assert_eq!(written, patched); - } - // ── hygiene / hardening pins ───────────────────────────────────── /// A manifest key whose parent component is a regular FILE: the open @@ -3455,7 +2962,6 @@ mod tests { &pkg_dir, &files, &PatchSources::blobs_only(&blobs_dir), - None, false, MismatchPolicy::Warn, ) @@ -3514,7 +3020,6 @@ mod tests { &pkg_dir, &files, &PatchSources::blobs_only(&blobs_dir), - None, false, MismatchPolicy::Warn, ) @@ -3537,60 +3042,14 @@ mod tests { ); } - /// SECURITY: the patch `uuid` is joined as `/.tar.gz`. A - /// traversal uuid that would resolve to a real archive elsewhere is - /// treated as "no archive" (diff strategy skipped, blob applies) — - /// never joined. - #[tokio::test] - async fn test_apply_unsafe_uuid_skips_archives() { - let (root, pkg_dir, blobs_dir, diffs_dir, files, original, patched) = make_fixture().await; - // `diffs/../escape/.tar.gz` IS a valid diff archive. - let escape_dir = root.path().join("escape"); - tokio::fs::create_dir_all(&escape_dir).await.unwrap(); - let delta = make_delta(&original, &patched); - write_uuid_archive(&escape_dir, TEST_UUID, &[("index.js", &delta)]); - let escaping_uuid = format!("../escape/{TEST_UUID}"); - let sources = PatchSources { - blobs_path: &blobs_dir, - diffs_path: Some(&diffs_dir), - mem_blobs: None, - }; - let result = apply_package_patch( - "pkg:npm/x@1.0.0", - &pkg_dir, - &files, - &sources, - Some(&escaping_uuid), - false, - MismatchPolicy::Warn, - ) - .await; - - assert!(result.success, "expected success: {:?}", result.error); - assert_eq!( - result.applied_via.get("index.js"), - Some(&AppliedVia::Blob), - "an escaping uuid must not reach the escaped diff archive" - ); - let written = tokio::fs::read(pkg_dir.join("index.js")).await.unwrap(); - assert_eq!(written, patched); - } - - /// A write failure under the archive strategy must surface as itself, - /// not be swallowed as "not applicable" and reported as the blob - /// fallback's `Failed to read blob …: No such file`. + /// A write failure must surface as itself, not be reported as a + /// `Failed to read blob …` miss. /// `chflags uchg` on the package dir is the unprivileged deterministic /// route to a stage-creation failure (the guard defeats 0o555). #[cfg(target_os = "macos")] #[tokio::test] async fn test_apply_write_failure_is_reported_not_masked_as_missing_blob() { - let (_root, pkg_dir, blobs_dir, diffs_dir, files, original, _patched) = - make_fixture().await; - // Only the archives are staged — no blob to fall back on. - let after_hash = &files["index.js"].after_hash; - tokio::fs::remove_file(blobs_dir.join(after_hash)) - .await - .unwrap(); + let (_root, pkg_dir, blobs_dir, files, original) = make_fixture().await; let status = std::process::Command::new("chflags") .arg("uchg") @@ -3599,17 +3058,12 @@ mod tests { .expect("chflags must be runnable"); assert!(status.success(), "chflags uchg failed"); - let sources = PatchSources { - blobs_path: &blobs_dir, - diffs_path: Some(&diffs_dir), - mem_blobs: None, - }; + let sources = PatchSources::blobs_only(&blobs_dir); let result = apply_package_patch( "pkg:npm/x@1.0.0", &pkg_dir, &files, &sources, - Some(TEST_UUID), false, MismatchPolicy::Warn, ) @@ -3827,16 +3281,9 @@ mod tests { let (_root, [a, b], key, blobs, files, original, _patched) = shared_store_fixture(pdm); let sources = PatchSources::blobs_only(&blobs); for dry_run in [true, false] { - let result = apply_package_patch( - purl, - &a, - &files, - &sources, - None, - dry_run, - MismatchPolicy::Warn, - ) - .await; + let result = + apply_package_patch(purl, &a, &files, &sources, dry_run, MismatchPolicy::Warn) + .await; assert!(!result.success, "{purl} dry_run={dry_run}: must refuse"); let err = result.error.unwrap_or_default(); assert!( @@ -3862,7 +3309,6 @@ mod tests { &a, &files, &PatchSources::blobs_only(&blobs), - None, false, MismatchPolicy::Warn, ) @@ -3925,8 +3371,7 @@ mod tests { for policy in [MismatchPolicy::Warn, MismatchPolicy::Force] { for dry_run in [true, false] { let result = - apply_package_patch(purl, &pkg, &files, &sources, None, dry_run, policy) - .await; + apply_package_patch(purl, &pkg, &files, &sources, dry_run, policy).await; assert!(!result.success, "{}: must refuse", pkg.display()); let err = result.error.unwrap_or_default(); assert!( @@ -3970,7 +3415,6 @@ mod tests { &nm.join("left-pad"), &files, &PatchSources::blobs_only(&blobs), - None, false, MismatchPolicy::Warn, ) diff --git a/crates/socket-patch-core/src/patch/diff.rs b/crates/socket-patch-core/src/patch/diff.rs deleted file mode 100644 index 783857d40..000000000 --- a/crates/socket-patch-core/src/patch/diff.rs +++ /dev/null @@ -1,256 +0,0 @@ -//! Per-file diff (bsdiff) apply support. -//! -//! A `diff` is a binary delta in bsdiff 4.x format that transforms the -//! `beforeHash` bytes of a file into the `afterHash` bytes. We store diffs -//! grouped by patch UUID — see [`crate::patch::package`] for the tar.gz -//! archive layout. - -use qbsdiff::Bspatch; - -/// Upper bound on how many bytes we pre-reserve for the patched output. -/// -/// `Bspatch::hint_target_size()` returns the target size read verbatim from -/// the bsdiff header (bytes 24..32) and never validates it — so a malformed or -/// hostile delta can claim an arbitrary target size (up to `i64::MAX`) while -/// carrying only a few bytes of data. (qbsdiff's `> patch.len()` check on the -/// control/diff block lengths is itself bypassable via integer overflow; see -/// [`validate_bsdiff_header`].) -/// -/// Feeding that value straight into `Vec::with_capacity` lets a tiny delta -/// request a multi-exabyte reservation, which either panics with "capacity -/// overflow" or aborts the process via the allocator. Neither is something -/// the caller can recover from, so it breaks the never-panic-on-bad-input -/// contract the patch engine depends on (see the tests below). -/// -/// The reservation is a pure optimization: `apply` is driven entirely by the -/// control stream and grows the output `Vec` on demand as it writes, so -/// clamping the hint never changes the result — it only bounds the number of -/// reallocations for legitimately large files. -const MAX_PREALLOC_BYTES: u64 = 64 * 1024 * 1024; // 64 MiB - -/// Decode a bsdiff "offtin" integer (8 little-endian bytes, sign-magnitude). -/// -/// This mirrors `qbsdiff`'s private `decode_int`: the top bit of the most -/// significant byte is a sign flag, not part of a two's-complement value. -fn decode_offtin(b: &[u8; 8]) -> i64 { - let x = u64::from_le_bytes(*b); - if x >> 63 == 0 || x == 1 << 63 { - x as i64 - } else { - ((x & ((1u64 << 63) - 1)) as i64).wrapping_neg() - } -} - -/// Reject bsdiff headers that would make `qbsdiff::Bspatch::new` panic. -/// -/// `qbsdiff`'s parser reads the compressed control- and diff-block lengths -/// from header bytes 8..16 and 16..24 with the sign-magnitude decoder above, -/// casts them to `u64`, then guards with `32 + csize + dsize > patch.len()` -/// using *wrapping* `u64` arithmetic before doing `split_at(csize)`. A header -/// whose length field has the sign bit set decodes to a "negative" value whose -/// `as u64` is enormous: the sum wraps back below `patch.len()`, slips past the -/// guard, and then either the addition overflows (debug builds) or -/// `split_at(huge)` indexes out of bounds (release builds) — a hard panic on -/// attacker-controlled input. -/// -/// We pre-validate with checked arithmetic so `apply_diff` always surfaces a -/// recoverable `io::Error` instead. Malformed-but-not-overflowing headers -/// (bad magic, too short) are left for `Bspatch::new` to report so the error -/// text stays consistent with the upstream parser. -fn validate_bsdiff_header(delta: &[u8]) -> Result<(), std::io::Error> { - // Defer the "too short / bad magic" cases to qbsdiff's own error. - if delta.len() < 32 || &delta[..8] != b"BSDIFF40" { - return Ok(()); - } - let csize = decode_offtin(delta[8..16].try_into().expect("8 bytes")); - let dsize = decode_offtin(delta[16..24].try_into().expect("8 bytes")); - let lengths_ok = csize >= 0 - && dsize >= 0 - && 32u64 - .checked_add(csize as u64) - .and_then(|s| s.checked_add(dsize as u64)) - .is_some_and(|needed| needed <= delta.len() as u64); - if lengths_ok { - Ok(()) - } else { - Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "bsdiff header: block lengths are negative or exceed the payload", - )) - } -} - -/// Apply a bsdiff delta to `before` and return the resulting bytes. -/// -/// Returns an `std::io::Error` when the delta is malformed or applying it -/// fails (for example, the delta was produced from a different source). -pub fn apply_diff(before: &[u8], delta: &[u8]) -> Result, std::io::Error> { - // Guard the header before handing it to qbsdiff: a forged block-length - // field would otherwise panic its parser (see `validate_bsdiff_header`). - validate_bsdiff_header(delta)?; - let patcher = Bspatch::new(delta)?; - // Clamp the attacker-controlled size hint: a corrupt/hostile header must - // not be able to turn a small delta into a process-killing allocation. - let prealloc = patcher.hint_target_size().min(MAX_PREALLOC_BYTES) as usize; - let mut out = Vec::with_capacity(prealloc); - patcher.apply(before, std::io::Cursor::new(&mut out))?; - Ok(out) -} - -#[cfg(test)] -mod tests { - use super::*; - use qbsdiff::Bsdiff; - - fn make_delta(before: &[u8], after: &[u8]) -> Vec { - let mut delta = Vec::new(); - Bsdiff::new(before, after) - .compare(std::io::Cursor::new(&mut delta)) - .expect("compare"); - delta - } - - #[test] - fn test_apply_diff_text_round_trip() { - let before = b"the quick brown fox jumps over the lazy dog"; - let after = b"the quick brown cat jumps over the lazy dog"; - let delta = make_delta(before, after); - let result = apply_diff(before, &delta).unwrap(); - assert_eq!(result, after); - } - - #[test] - fn test_apply_diff_binary_round_trip() { - let before: Vec = (0..1024u32).map(|i| (i % 251) as u8).collect(); - let mut after = before.clone(); - // Mutate a handful of bytes scattered through the buffer. - for i in [10usize, 200, 500, 900] { - after[i] = after[i].wrapping_add(7); - } - let delta = make_delta(&before, &after); - let result = apply_diff(&before, &delta).unwrap(); - assert_eq!(result, after); - } - - #[test] - fn test_apply_diff_empty_to_nonempty() { - let before: &[u8] = b""; - let after = b"hello"; - let delta = make_delta(before, after); - let result = apply_diff(before, &delta).unwrap(); - assert_eq!(result, after); - } - - #[test] - fn test_apply_diff_malformed_errors() { - // Random bytes are extremely unlikely to be a valid bsdiff header. - let bogus_delta = b"not a real bsdiff delta"; - let result = apply_diff(b"anything", bogus_delta); - assert!(result.is_err(), "expected malformed-delta error"); - } - - #[test] - fn test_apply_diff_wrong_source_does_not_panic() { - // Build a delta from one source then try to apply it to a different - // source. qbsdiff's bspatch is content-agnostic but should still - // produce *some* output without panicking — the caller is - // responsible for verifying the result hash matches the expected - // `after_hash`. This test exists to lock in the - // never-panic-on-bad-input contract callers depend on. - let src_a = b"AAAAAAAAAAAAAAAAAAAA"; - let src_b = b"BBBBBBBBBBBBBBBBBBBB"; - let target = b"CCCCCCCCCCCCCCCCCCCC"; - let delta = make_delta(src_a, target); - // Result may or may not equal target — what matters is no panic. - let _ = apply_diff(src_b, &delta); - } - - #[test] - fn test_apply_diff_forged_oversize_header_is_safe() { - // The header's target size (bytes 24..32) is unvalidated; see - // `MAX_PREALLOC_BYTES`. We build a genuine, small delta and then overwrite only the target - // size field with ~1.15 EiB. Because `apply` is driven by the control - // stream and ignores the hint, the clamp lets the patch still produce - // the correct bytes instead of dying on the allocation. - let before = b"the quick brown fox jumps over the lazy dog"; - let after = b"the quick brown cat jumps over the lazy dog"; - let mut forged = make_delta(before, after); - assert!(forged.len() >= 32, "delta must contain a full header"); - // Stay positive (top bit clear) so qbsdiff decodes it as a large - // unsigned size rather than a negative offset. - let huge: u64 = 1 << 60; - forged[24..32].copy_from_slice(&huge.to_le_bytes()); - - let result = apply_diff(before, &forged).expect("clamped apply must succeed"); - assert_eq!( - result, after, - "forging the size hint must not corrupt output" - ); - } - - #[test] - fn test_apply_diff_forged_negative_block_length_does_not_panic() { - // A csize field with the sign bit set would panic qbsdiff's parser - // (see `validate_bsdiff_header`); `apply_diff` must reject it as a - // normal `io::Error`. - let before = b"the quick brown fox jumps over the lazy dog"; - let after = b"the quick brown cat jumps over the lazy dog"; - let mut forged = make_delta(before, after); - assert!(forged.len() >= 32, "delta must contain a full header"); - // Sign-magnitude encoding of -16: magnitude 16 with the sign bit set. - let neg: u64 = 16u64 | (1u64 << 63); - forged[8..16].copy_from_slice(&neg.to_le_bytes()); - - let result = apply_diff(before, &forged); - assert!( - result.is_err(), - "a forged negative block length must error, not panic" - ); - } - - #[test] - fn test_apply_diff_forged_negative_diff_block_length_does_not_panic() { - // Same class of bug as the csize case above, but via the diff-block - // length field (header bytes 16..24). Both feed `split_at` after the - // wrapping-overflow guard, so both must be rejected up front. - let before = b"alpha beta gamma delta epsilon zeta eta theta"; - let after = b"alpha beta gamma DELTA epsilon zeta eta theta"; - let mut forged = make_delta(before, after); - assert!(forged.len() >= 32, "delta must contain a full header"); - let neg: u64 = 8u64 | (1u64 << 63); - forged[16..24].copy_from_slice(&neg.to_le_bytes()); - - let result = apply_diff(before, &forged); - assert!( - result.is_err(), - "a forged negative diff-block length must error, not panic" - ); - } - - #[test] - fn test_validate_bsdiff_header_accepts_real_delta() { - // The guard must be transparent to honest deltas: a freshly built - // delta has well-formed, in-bounds block lengths and must pass. - let before = b"the quick brown fox jumps over the lazy dog"; - let after = b"the quick brown cat jumps over the lazy dog"; - let delta = make_delta(before, after); - validate_bsdiff_header(&delta).expect("honest header must validate"); - // ...and short / bad-magic inputs are deferred to Bspatch::new, so the - // guard returns Ok for them rather than masking the canonical error. - validate_bsdiff_header(b"too short").expect("short input deferred"); - validate_bsdiff_header(b"NOTBSDIFF.........................").expect("bad magic deferred"); - } - - #[test] - fn test_apply_diff_capacity_hint_is_clamped() { - // Pin the clamp itself so the bound can't silently regress back to an - // unbounded reservation. The output capacity is never reserved beyond - // MAX_PREALLOC_BYTES regardless of what the header claims. - let huge_hint: u64 = u64::MAX; - let clamped = huge_hint.min(MAX_PREALLOC_BYTES) as usize; - assert_eq!(clamped, MAX_PREALLOC_BYTES as usize); - // A modest, honest hint passes through untouched. - let small_hint: u64 = 4096; - assert_eq!(small_hint.min(MAX_PREALLOC_BYTES) as usize, 4096); - } -} diff --git a/crates/socket-patch-core/src/patch/mod.rs b/crates/socket-patch-core/src/patch/mod.rs index c0d2aaed5..48ca5ea9f 100644 --- a/crates/socket-patch-core/src/patch/mod.rs +++ b/crates/socket-patch-core/src/patch/mod.rs @@ -2,7 +2,6 @@ pub mod apply; pub mod apply_lock; // `fresh_copy`/`remove_tree` are shared by the Go redirect and the vendor backends. pub mod copy_tree; -pub mod diff; pub(crate) mod file_hash; pub mod jvm_jar; pub mod package; diff --git a/crates/socket-patch-core/src/patch/package.rs b/crates/socket-patch-core/src/patch/package.rs index 77647ff8c..39f1183d9 100644 --- a/crates/socket-patch-core/src/patch/package.rs +++ b/crates/socket-patch-core/src/patch/package.rs @@ -1,10 +1,9 @@ -//! Patch-archive tarball helpers. +//! Bounded tarball readers. //! -//! Diff archives (`.socket/diffs/.tar.gz`) are a gzipped tar -//! containing one entry per patched file. The entry's path matches the -//! **normalized** relative file path (i.e. without the `package/` prefix -//! used by the API), and each entry holds a bsdiff delta that transforms -//! the corresponding `beforeHash` content into the `afterHash` content. +//! The vendored and hosted backends read registry tarballs (npm `.tgz`, +//! PyPI sdists) into memory through these helpers. Entry paths are +//! **normalized** (the `package/` prefix is stripped), and every reader +//! enforces the size, entry-count and path-safety limits below. use std::collections::HashMap; use std::io::Read; @@ -13,7 +12,6 @@ use std::path::Path; use flate2::read::GzDecoder; use tar::Archive; -use crate::manifest::schema::PatchFileInfo; use crate::patch::apply::{is_safe_relative_subpath, normalize_file_path}; /// Maximum cumulative *decompressed* bytes we accept from a single @@ -310,26 +308,6 @@ fn read_archive_from_reader( Ok(out) } -/// Subset of `read_archive_to_map` that only keeps entries whose normalized -/// path appears in `expected_files`. Anything else in the archive is -/// silently dropped — this is defense-in-depth so a malicious archive -/// cannot drop arbitrary files into the package directory. -pub fn read_archive_filtered( - archive_path: &Path, - expected_files: &HashMap, -) -> Result>, ArchiveError> { - let allowed: std::collections::HashSet = expected_files - .keys() - .map(|k| normalize_file_path(k).to_string()) - .collect(); - - let all = read_archive_to_map(archive_path)?; - Ok(all - .into_iter() - .filter(|(k, _)| allowed.contains(k)) - .collect()) -} - #[cfg(test)] mod tests { use super::*; @@ -365,25 +343,6 @@ mod tests { builder.into_inner().unwrap().finish().unwrap(); } - fn make_file_info() -> HashMap { - let mut files = HashMap::new(); - files.insert( - "package/index.js".to_string(), - PatchFileInfo { - before_hash: "a".repeat(64), - after_hash: "b".repeat(64), - }, - ); - files.insert( - "lib/util.js".to_string(), - PatchFileInfo { - before_hash: "c".repeat(64), - after_hash: "d".repeat(64), - }, - ); - files - } - #[test] fn test_read_archive_basic() { let dir = tempfile::tempdir().unwrap(); @@ -696,28 +655,6 @@ mod tests { assert!(map.is_empty()); } - #[test] - fn test_read_archive_filtered_drops_unexpected_entries() { - let dir = tempfile::tempdir().unwrap(); - let archive = dir.path().join("arc.tar.gz"); - write_archive( - &archive, - &[ - ("package/index.js", b"patched index"), - ("lib/util.js", b"patched util"), - ("bonus/extra.js", b"unwanted"), - ], - ); - - let files = make_file_info(); - let map = read_archive_filtered(&archive, &files).unwrap(); - // Only the two expected paths survive. - assert_eq!(map.len(), 2); - assert!(map.contains_key("index.js")); - assert!(map.contains_key("lib/util.js")); - assert!(!map.contains_key("bonus/extra.js")); - } - #[test] fn test_read_archive_missing_file() { let result = read_archive_to_map(Path::new("/nonexistent/archive.tar.gz")); diff --git a/crates/socket-patch-core/src/patch/redirect/golang_local.rs b/crates/socket-patch-core/src/patch/redirect/golang_local.rs index 2c5868ec2..15447d087 100644 --- a/crates/socket-patch-core/src/patch/redirect/golang_local.rs +++ b/crates/socket-patch-core/src/patch/redirect/golang_local.rs @@ -19,7 +19,7 @@ //! //! The copy is produced by **delegating to the hardened //! [`apply_package_patch`] pipeline** pointed at the fresh copy, reusing all the -//! verify → package/diff/blob → atomic-write machinery unchanged. +//! verify → blob → atomic-write machinery unchanged. use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; @@ -175,7 +175,6 @@ pub async fn apply_go_redirect<'a>( base_rel: &str, files: &HashMap, sources: &PatchSources<'_>, - uuid: Option<&str>, dry_run: bool, policy: MismatchPolicy, ) -> ApplyResult { @@ -244,7 +243,7 @@ pub async fn apply_go_redirect<'a>( // "would patch" report, without creating the copy or editing go.mod. let pristine_src = pristine_src.path(); let mut result = - apply_package_patch(purl, pristine_src, files, sources, uuid, true, policy).await; + apply_package_patch(purl, pristine_src, files, sources, true, policy).await; result.package_path = copy_dir.display().to_string(); result.sidecar = None; // a replace copy is not the cache (no go.sum advisory) return result; @@ -284,8 +283,7 @@ pub async fn apply_go_redirect<'a>( } // Delegate to the hardened pipeline, pointed at the copy. - let mut result = - apply_package_patch(purl, ©_dir, files, sources, uuid, false, policy).await; + let mut result = apply_package_patch(purl, ©_dir, files, sources, false, policy).await; result.package_path = copy_dir.display().to_string(); // The golang sidecar advisory ("go mod verify will fail against go.sum") // is about in-cache patching; a `replace` copy bypasses go.sum entirely, so @@ -803,7 +801,6 @@ mod tests { GO_PATCHES_DIR, &files, &sources, - None, false, MismatchPolicy::Warn, ) @@ -843,7 +840,6 @@ mod tests { GO_PATCHES_DIR, &files, &sources, - None, false, MismatchPolicy::Warn, ) @@ -892,7 +888,6 @@ mod tests { GO_PATCHES_DIR, &files, &sources, - None, false, MismatchPolicy::Warn, ) @@ -912,7 +907,6 @@ mod tests { GO_PATCHES_DIR, &files, &sources, - None, false, MismatchPolicy::Warn, ) @@ -948,7 +942,6 @@ mod tests { GO_PATCHES_DIR, &files, &sources, - None, false, MismatchPolicy::Warn, ) @@ -966,7 +959,6 @@ mod tests { GO_PATCHES_DIR, &files, &sources, - None, false, MismatchPolicy::Warn, ) @@ -992,7 +984,6 @@ mod tests { GO_PATCHES_DIR, &files, &sources, - None, true, MismatchPolicy::Warn, ) @@ -1027,7 +1018,6 @@ mod tests { GO_PATCHES_DIR, &files, &sources, - None, false, MismatchPolicy::Warn, ) @@ -1065,7 +1055,6 @@ mod tests { GO_PATCHES_DIR, &files, &sources, - None, false, MismatchPolicy::Warn, ) @@ -1093,7 +1082,6 @@ mod tests { GO_PATCHES_DIR, &files, &empty_sources, - None, false, MismatchPolicy::Warn, ) @@ -1128,7 +1116,6 @@ mod tests { GO_PATCHES_DIR, &files, &sources, - None, false, MismatchPolicy::Warn, ) @@ -1152,7 +1139,6 @@ mod tests { GO_PATCHES_DIR, &files, &sources, - None, false, MismatchPolicy::Warn, ) @@ -1195,7 +1181,6 @@ mod tests { GO_PATCHES_DIR, &files, &sources, - None, false, MismatchPolicy::Warn, ) @@ -1231,7 +1216,6 @@ mod tests { GO_PATCHES_DIR, &files, &sources, - None, false, MismatchPolicy::Warn, ) @@ -1271,7 +1255,6 @@ mod tests { GO_PATCHES_DIR, &files, &sources, - None, false, MismatchPolicy::Warn, ) @@ -1305,7 +1288,6 @@ mod tests { GO_PATCHES_DIR, &files, &sources, - None, false, MismatchPolicy::Warn, ) @@ -1343,7 +1325,6 @@ mod tests { GO_PATCHES_DIR, &files, &sources, - None, false, MismatchPolicy::Warn, ) @@ -1379,7 +1360,6 @@ mod tests { GO_PATCHES_DIR, &files, &sources, - None, false, MismatchPolicy::Warn, ) @@ -1424,7 +1404,6 @@ mod tests { GO_PATCHES_DIR, &files, &sources, - None, false, MismatchPolicy::Warn, ) @@ -1471,7 +1450,6 @@ mod tests { GO_PATCHES_DIR, &files, &sources, - None, false, MismatchPolicy::Warn, ) @@ -1524,7 +1502,6 @@ mod tests { GO_PATCHES_DIR, &files, &sources, - None, false, MismatchPolicy::Warn, ) @@ -1576,7 +1553,6 @@ mod tests { GO_PATCHES_DIR, &files, &sources, - None, false, MismatchPolicy::Warn, ) @@ -1610,7 +1586,6 @@ mod tests { GO_PATCHES_DIR, &files, &sources, - None, false, MismatchPolicy::Warn, ) @@ -1654,7 +1629,6 @@ mod tests { GO_PATCHES_DIR, &files, &sources, - None, false, MismatchPolicy::Warn, ) @@ -1695,7 +1669,6 @@ mod tests { GO_PATCHES_DIR, &files, &sources, - None, false, MismatchPolicy::Warn, ) @@ -1731,7 +1704,6 @@ mod tests { GO_PATCHES_DIR, &files, &sources, - None, false, MismatchPolicy::Warn, ) @@ -1790,7 +1762,6 @@ mod tests { GO_PATCHES_DIR, &files, &sources, - None, false, MismatchPolicy::Warn, ) @@ -1871,7 +1842,6 @@ mod tests { GO_PATCHES_DIR, &files, &sources, - None, false, MismatchPolicy::Warn, ) @@ -1924,7 +1894,6 @@ mod tests { GO_PATCHES_DIR, &files, &sources, - None, false, MismatchPolicy::Warn, ) @@ -1978,7 +1947,6 @@ mod tests { GO_PATCHES_DIR, &files, &sources, - None, false, MismatchPolicy::Warn, ) @@ -2019,7 +1987,6 @@ mod tests { GO_PATCHES_DIR, &files, &sources, - None, false, MismatchPolicy::Warn, ) @@ -2261,7 +2228,6 @@ mod tests { GO_PATCHES_DIR, &files, &sources, - None, false, MismatchPolicy::Warn, ) diff --git a/crates/socket-patch-core/src/patch/redirect/vlt_heal.rs b/crates/socket-patch-core/src/patch/redirect/vlt_heal.rs index 4c7f9d5be..13a28b124 100644 --- a/crates/socket-patch-core/src/patch/redirect/vlt_heal.rs +++ b/crates/socket-patch-core/src/patch/redirect/vlt_heal.rs @@ -490,7 +490,6 @@ mod tests { use super::*; use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::PatchFileInfo; - use crate::patch::redirect::FileEdit; use std::collections::HashMap; const ID: &str = "~npm~left-pad@1.3.0"; diff --git a/crates/socket-patch-core/src/patch/store_copies.rs b/crates/socket-patch-core/src/patch/store_copies.rs index 372dcfe79..2a0c8fe22 100644 --- a/crates/socket-patch-core/src/patch/store_copies.rs +++ b/crates/socket-patch-core/src/patch/store_copies.rs @@ -176,7 +176,6 @@ mod regression_tests { &primary, &files, &PatchSources::blobs_only(&blobs), - None, false, MismatchPolicy::Warn, ) @@ -220,7 +219,6 @@ mod regression_tests { &primary, &files, &PatchSources::blobs_only(&blobs), - None, false, MismatchPolicy::Force, ) @@ -249,7 +247,6 @@ mod regression_tests { &primary, &files, &PatchSources::blobs_only(&blobs), - None, true, MismatchPolicy::Warn, ) @@ -273,7 +270,6 @@ mod regression_tests { &primary, &files, &PatchSources::blobs_only(&blobs), - None, false, MismatchPolicy::Warn, ) diff --git a/crates/socket-patch-core/src/telemetry.rs b/crates/socket-patch-core/src/telemetry.rs index 8a2998e03..38dc46725 100644 --- a/crates/socket-patch-core/src/telemetry.rs +++ b/crates/socket-patch-core/src/telemetry.rs @@ -702,14 +702,14 @@ pub fn spawn_patch_scan_failed( pending.spawn_prepared(prepare_patch_scan_failed(error, fallback_to_proxy, auth)); } -/// Track a successful `get`. Reports patch identity + delivery mode and -/// whether the call was downgraded to the public proxy after an -/// auth-endpoint 401/403. +/// Track a successful `get`. Reports patch identity and whether the call +/// was downgraded to the public proxy after an auth-endpoint 401/403. +/// `download_mode` is always `"file"`: v5 fetches patch content only as +/// per-file blobs, and the field stays so the event schema is unchanged. pub async fn track_patch_fetched( uuid: &str, tier: &str, ecosystem: &str, - download_mode: &str, fallback_to_proxy: bool, auth: &TelemetryAuth, ) { @@ -720,7 +720,7 @@ pub async fn track_patch_fetched( "uuid": uuid, "tier": tier, "ecosystem": ecosystem, - "download_mode": download_mode, + "download_mode": "file", "fallback_to_proxy": fallback_to_proxy, }), None::<&str>, diff --git a/crates/socket-patch-core/src/vendor/golang.rs b/crates/socket-patch-core/src/vendor/golang.rs index c0ddd3376..824d5c915 100644 --- a/crates/socket-patch-core/src/vendor/golang.rs +++ b/crates/socket-patch-core/src/vendor/golang.rs @@ -1039,7 +1039,6 @@ mod tests { GO_PATCHES_DIR, &record.files, &sources, - Some(UUID), false, MismatchPolicy::Warn, ) @@ -1101,7 +1100,6 @@ mod tests { GO_PATCHES_DIR, &record.files, &sources, - Some(UUID), false, MismatchPolicy::Warn, ) @@ -1410,7 +1408,6 @@ mod tests { GO_PATCHES_DIR, &record.files, &sources, - Some(UUID), false, MismatchPolicy::Warn, ) @@ -2166,7 +2163,6 @@ mod tests { GO_PATCHES_DIR, &record.files, &sources, - Some(UUID), false, MismatchPolicy::Warn, ) diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index e47391f5f..ba0860dbc 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -646,7 +646,6 @@ pub async fn lock_text_refusals( let nowhere = nowhere_buf.as_path(); let no_sources = PatchSources { blobs_path: nowhere, - diffs_path: None, mem_blobs: None, }; let mut refusals = Vec::with_capacity(packages.len()); @@ -917,7 +916,6 @@ mod lock_text_refusal_tests { let nowhere = root.join("not-installed"); let sources = PatchSources { blobs_path: &nowhere, - diffs_path: None, mem_blobs: None, }; for (i, code) in [ diff --git a/crates/socket-patch-core/tests/api_timeout_e2e.rs b/crates/socket-patch-core/tests/api_timeout_e2e.rs index cdfa72928..f6d4af53a 100644 --- a/crates/socket-patch-core/tests/api_timeout_e2e.rs +++ b/crates/socket-patch-core/tests/api_timeout_e2e.rs @@ -195,7 +195,6 @@ async fn authenticated_calls_fail_as_network_on_a_stalled_server() { ) .await; assert_stall_is_network("fetch_blob", api.fetch_blob(HASH)).await; - assert_stall_is_network("fetch_diff", api.fetch_diff(UUID)).await; } #[tokio::test] @@ -210,7 +209,6 @@ async fn public_proxy_calls_fail_as_network_on_a_stalled_server() { ) .await; assert_stall_is_network("fetch_blob", api.fetch_blob(HASH)).await; - assert_stall_is_network("fetch_diff", api.fetch_diff(UUID)).await; } #[tokio::test] @@ -242,7 +240,7 @@ async fn stalled_json_bodies_are_network_errors_on_both_clients() { ); } -/// Read a blob/diff body to the end. +/// Read a blob body to the end. async fn drain(mut body: BinaryBody) -> Result, ApiError> { let mut bytes = Vec::new(); while let Some(chunk) = body.chunk().await? { @@ -258,10 +256,7 @@ async fn stalled_binary_bodies_are_network_errors_on_both_clients() { let uri = stalled_json_body_server().await; for proxy in [false, true] { let api = client(&uri, proxy); - for (what, body) in [ - ("fetch_blob", api.fetch_blob(HASH).await), - ("fetch_diff", api.fetch_diff(UUID).await), - ] { + for (what, body) in [("fetch_blob", api.fetch_blob(HASH).await)] { let body = body .unwrap_or_else(|e| panic!("{what} proxy={proxy}: headers arrived: {e:?}")) .expect("200 serves a body"); diff --git a/crates/socket-patch-core/tests/binary_fetch_error_classification_e2e.rs b/crates/socket-patch-core/tests/binary_fetch_error_classification_e2e.rs index 86059be24..abea28e4a 100644 --- a/crates/socket-patch-core/tests/binary_fetch_error_classification_e2e.rs +++ b/crates/socket-patch-core/tests/binary_fetch_error_classification_e2e.rs @@ -1,4 +1,4 @@ -//! The binary transport path (`fetch_blob` / `fetch_diff`, both sharing +//! The binary transport path (`fetch_blob`, via //! `fetch_binary`) must classify authenticated 401 / 403 / 429 responses the //! same way the JSON path does. //! diff --git a/crates/socket-patch-core/tests/blob_fetcher_edges_e2e.rs b/crates/socket-patch-core/tests/blob_fetcher_edges_e2e.rs index a9a745511..2fbcb157e 100644 --- a/crates/socket-patch-core/tests/blob_fetcher_edges_e2e.rs +++ b/crates/socket-patch-core/tests/blob_fetcher_edges_e2e.rs @@ -5,12 +5,10 @@ //! loop). use socket_patch_core::api::blob_fetcher::{ - fetch_blobs_by_hash, fetch_missing_blobs, fetch_missing_sources, get_missing_archives, - get_missing_blobs, DownloadMode, + fetch_blobs_by_hash, fetch_missing_blobs, get_missing_blobs, }; use socket_patch_core::api::client::{ApiClient, ApiClientOptions}; use socket_patch_core::manifest::schema::{PatchFileInfo, PatchManifest, PatchRecord}; -use socket_patch_core::patch::apply::PatchSources; use std::collections::HashMap; use std::collections::HashSet; use std::path::Path; @@ -133,130 +131,6 @@ async fn fetch_blobs_by_hash_empty_set_short_circuits() { assert_eq!(dir_entry_count(&blobs), 0, "no blobs should be created"); } -/// `get_missing_archives` against an empty manifest returns empty -/// — no patches means no archives to look for. -#[tokio::test] -async fn get_missing_archives_empty_manifest_returns_empty_set() { - let tmp = tempfile::tempdir().unwrap(); - let archives_dir = tmp.path().join("archives"); - std::fs::create_dir(&archives_dir).unwrap(); - let manifest = PatchManifest::new(); - let missing = get_missing_archives(&manifest, &archives_dir).await; - assert!(missing.is_empty()); -} - -/// Discriminator: a non-empty manifest whose archive is absent from disk -/// must be reported as missing — proving `get_missing_archives` actually -/// inspects manifest+disk rather than being a constant-empty stub. -#[tokio::test] -async fn get_missing_archives_reports_missing_archive() { - let tmp = tempfile::tempdir().unwrap(); - let archives_dir = tmp.path().join("archives"); - std::fs::create_dir(&archives_dir).unwrap(); - let manifest = manifest_with_after_hashes(&[&"a".repeat(64)]); - let uuid = "11111111-1111-4111-8111-111111111111"; - - // Archive absent → reported missing. - let missing = get_missing_archives(&manifest, &archives_dir).await; - assert_eq!(missing.len(), 1); - assert!(missing.contains(uuid)); - - // Stage the archive → no longer missing. - std::fs::write(archives_dir.join(format!("{uuid}.tar.gz")), b"data").unwrap(); - let missing = get_missing_archives(&manifest, &archives_dir).await; - assert!( - missing.is_empty(), - "archive present on disk must not be reported missing" - ); -} - -/// `fetch_missing_sources` with `DownloadMode::Diff` and no diffs_path -/// returns the empty-result envelope without I/O — covers the "no path -/// configured" fallback hint documented in the function's rustdoc. -#[tokio::test] -async fn fetch_missing_sources_diff_mode_with_no_diffs_path() { - let tmp = tempfile::tempdir().unwrap(); - let blobs = tmp.path().join("blobs"); - std::fs::create_dir(&blobs).unwrap(); - let sources = PatchSources { - blobs_path: &blobs, - diffs_path: None, - mem_blobs: None, - }; - let manifest = manifest_with_after_hashes(&[&"a".repeat(64)]); - let client = dummy_client(); - - // Control: File mode against the same manifest genuinely tries to work. - let file_mode = - fetch_missing_sources(&manifest, &sources, DownloadMode::File, &client, None).await; - assert_eq!(file_mode.total, 1, "File mode must find the missing blob"); - assert_eq!(file_mode.failed, 1, "and attempt (failing) to download it"); - - let result = - fetch_missing_sources(&manifest, &sources, DownloadMode::Diff, &client, None).await; - assert_eq!( - result.total, 0, - "Diff mode w/o diffs_path must short-circuit" - ); - assert_eq!(result.downloaded, 0); - assert_eq!(result.failed, 0); - assert_eq!(result.skipped, 0); - assert!(result.results.is_empty()); - assert_eq!( - dir_entry_count(&blobs), - 0, - "Diff-mode short-circuit did zero I/O" - ); -} - -/// `DownloadMode::parse` accepts all documented values plus the -/// `"blob"` synonym for `File`, and rejects unknown strings. -#[test] -fn download_mode_parse_covers_all_branches() { - assert_eq!(DownloadMode::parse("diff").unwrap(), DownloadMode::Diff); - assert_eq!(DownloadMode::parse("file").unwrap(), DownloadMode::File); - assert_eq!(DownloadMode::parse("blob").unwrap(), DownloadMode::File); - // Case-insensitive. - assert_eq!(DownloadMode::parse("DIFF").unwrap(), DownloadMode::Diff); - assert_eq!(DownloadMode::parse("FILE").unwrap(), DownloadMode::File); - // `package` was removed; its error is a removal notice (case-insensitive), - // distinct from the generic unknown-mode error. - for spelling in ["package", "Package"] { - let err = DownloadMode::parse(spelling).unwrap_err(); - assert!(err.contains("removed"), "want removal notice: {err}"); - } - assert_eq!(DownloadMode::parse("Blob").unwrap(), DownloadMode::File); - // Unknown value → Err, and the message names the offending input. - let err = DownloadMode::parse("invalid").unwrap_err(); - assert!( - err.contains("invalid"), - "error should echo the bad value: {err}" - ); - assert!(DownloadMode::parse("").is_err()); - // A near-miss must not be silently coerced to a valid mode. - assert!(DownloadMode::parse("diffs").is_err()); - assert!(DownloadMode::parse("files").is_err()); -} - -/// `DownloadMode::as_tag` round-trips with `parse` for all variants, and -/// each variant maps to a *distinct* tag. -#[test] -fn download_mode_as_tag_round_trips_with_parse() { - let variants = [DownloadMode::Diff, DownloadMode::File]; - let mut seen_tags = HashSet::new(); - for mode in variants { - let tag = mode.as_tag(); - assert!( - seen_tags.insert(tag), - "tag {tag:?} must be unique per variant" - ); - assert_eq!(DownloadMode::parse(tag).unwrap(), mode); - } - // Pin the exact tag strings so a silent rename is caught. - assert_eq!(DownloadMode::Diff.as_tag(), "diff"); - assert_eq!(DownloadMode::File.as_tag(), "file"); -} - /// `fetch_blobs_by_hash` with a hash whose blob is already on disk /// short-circuits the network call and reports `skipped: 1`, leaving the /// existing file byte-for-byte untouched. Covers the `skip if already on @@ -526,173 +400,6 @@ async fn fetch_missing_blobs_accepts_uppercase_manifest_hash() { assert_eq!(std::fs::read(blobs.join(&hash_upper)).unwrap(), content); } -// ── Archive (diff) download path ───────────────────────────────────── -// -// `fetch_missing_diff_archives` (driven via `fetch_missing_sources` in -// Diff mode) is otherwise only reached on the closed-port -// transport-error arm. These drive the success-write, 404, and -// progress-callback arms against a mock proxy. Archives are uuid-named -// and have no content hash, so the only integrity guarantee is the atomic -// write — assert no staging litter survives. - -/// Build a manifest carrying a set of patch UUIDs (each as its own PURL). -fn manifest_with_uuids(uuids: &[&str]) -> PatchManifest { - let mut patches = HashMap::new(); - for (i, uuid) in uuids.iter().enumerate() { - patches.insert( - format!("pkg:npm/test-{i}@1.0.0"), - PatchRecord { - uuid: (*uuid).to_string(), - exported_at: "2024-01-01T00:00:00Z".to_string(), - files: HashMap::new(), - vulnerabilities: HashMap::new(), - description: "test".to_string(), - license: "MIT".to_string(), - tier: "free".to_string(), - }, - ); - } - PatchManifest { - patches, - setup: None, - } -} - -#[tokio::test] -async fn fetch_missing_sources_diff_downloads_and_writes_archive() { - let uuid = "11111111-1111-4111-8111-111111111111"; - let archive_bytes = b"\x1f\x8b\x08 fake-but-opaque tar.gz payload"; - - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path_matcher(format!("/patch/diff/{uuid}"))) - .respond_with(ResponseTemplate::new(200).set_body_bytes(archive_bytes.to_vec())) - .expect(1) - .mount(&server) - .await; - - let tmp = tempfile::tempdir().unwrap(); - let blobs = tmp.path().join("blobs"); - let diffs = tmp.path().join("diffs"); - std::fs::create_dir(&blobs).unwrap(); - std::fs::create_dir(&diffs).unwrap(); - let sources = PatchSources { - blobs_path: &blobs, - diffs_path: Some(&diffs), - mem_blobs: None, - }; - let manifest = manifest_with_uuids(&[uuid]); - let client = proxy_client(&server.uri()); - - let result = - fetch_missing_sources(&manifest, &sources, DownloadMode::Diff, &client, None).await; - assert_eq!(result.total, 1); - assert_eq!(result.downloaded, 1, "diff archive must be downloaded"); - assert_eq!(result.failed, 0); - // The result's `hash` field carries the UUID for archive modes. - assert_eq!(result.results[0].hash, uuid); - // Written under `.tar.gz`, byte-for-byte, with no staging litter. - assert_eq!( - std::fs::read(diffs.join(format!("{uuid}.tar.gz"))).unwrap(), - archive_bytes - ); - let names: Vec = std::fs::read_dir(&diffs) - .unwrap() - .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) - .collect(); - assert_eq!( - names, - vec![format!("{uuid}.tar.gz")], - "no temp files: {names:?}" - ); - // A re-run finds the archive present and short-circuits (no second GET; - // the mock's `.expect(1)` would trip on a second request). - let again = fetch_missing_sources(&manifest, &sources, DownloadMode::Diff, &client, None).await; - assert_eq!(again.total, 0, "already-present archive → nothing to do"); -} - -#[tokio::test] -async fn fetch_missing_sources_diff_404_is_failure_with_kind_message() { - let uuid = "33333333-3333-4333-8333-333333333333"; - - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path_matcher(format!("/patch/diff/{uuid}"))) - .respond_with(ResponseTemplate::new(404)) - .expect(1) - .mount(&server) - .await; - - let tmp = tempfile::tempdir().unwrap(); - let blobs = tmp.path().join("blobs"); - let diffs = tmp.path().join("diffs"); - std::fs::create_dir(&blobs).unwrap(); - std::fs::create_dir(&diffs).unwrap(); - let sources = PatchSources { - blobs_path: &blobs, - diffs_path: Some(&diffs), - mem_blobs: None, - }; - let manifest = manifest_with_uuids(&[uuid]); - let client = proxy_client(&server.uri()); - - let result = - fetch_missing_sources(&manifest, &sources, DownloadMode::Diff, &client, None).await; - assert_eq!(result.total, 1); - assert_eq!(result.downloaded, 0); - assert_eq!(result.failed, 1); - let err = result.results[0].error.as_deref().unwrap(); - assert!(err.contains("Diff"), "message should name the kind: {err}"); - assert!( - err.contains("not found"), - "message should say not found: {err}" - ); - // Nothing written for a 404. - assert_eq!(dir_entry_count(&diffs), 0); -} - -/// The progress callback fires once per downloaded archive with a 1-based -/// index and the correct total. -#[tokio::test] -async fn fetch_missing_sources_diff_invokes_progress_callback() { - use std::sync::Mutex; - let uuid = "44444444-4444-4444-8444-444444444444"; - - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path_matcher(format!("/patch/diff/{uuid}"))) - .respond_with(ResponseTemplate::new(200).set_body_bytes(b"x".to_vec())) - .mount(&server) - .await; - - let tmp = tempfile::tempdir().unwrap(); - let blobs = tmp.path().join("blobs"); - let diffs = tmp.path().join("diffs"); - std::fs::create_dir(&blobs).unwrap(); - std::fs::create_dir(&diffs).unwrap(); - let sources = PatchSources { - blobs_path: &blobs, - diffs_path: Some(&diffs), - mem_blobs: None, - }; - let manifest = manifest_with_uuids(&[uuid]); - let client = proxy_client(&server.uri()); - - let calls: std::sync::Arc>> = - std::sync::Arc::new(Mutex::new(Vec::new())); - let calls_cb = calls.clone(); - let cb: socket_patch_core::api::blob_fetcher::OnProgress = - Box::new(move |h: &str, idx: usize, total: usize| { - calls_cb.lock().unwrap().push((h.to_string(), idx, total)); - }); - - let _ = - fetch_missing_sources(&manifest, &sources, DownloadMode::Diff, &client, Some(&cb)).await; - - let recorded = calls.lock().unwrap().clone(); - assert_eq!(recorded, vec![(uuid.to_string(), 1, 1)]); -} - /// `get_missing_blobs` against a manifest that lists no patches /// returns the empty set. Covers the early-return inside the /// function — the existing apply tests always stage at least one @@ -795,72 +502,54 @@ fn stage_len(dir: &Path) -> Option { }) } -/// Blob and diff downloads stream to disk: the first part of a body is -/// already in the stage file while the server is still holding back the -/// rest. Before #571 `fetch_binary` buffered the whole body in memory, so -/// nothing reached disk until the response completed. +/// Blob downloads stream to disk: the first part of a body is already in +/// the stage file while the server is still holding back the rest. Before +/// #571 `fetch_binary` buffered the whole body in memory, so nothing +/// reached disk until the response completed. #[tokio::test] -async fn blob_and_diff_bodies_reach_disk_before_the_response_completes() { +async fn blob_bodies_reach_disk_before_the_response_completes() { let head = vec![b'a'; 256 * 1024]; let tail = vec![b'b'; 256 * 1024]; let content = [head.clone(), tail.clone()].concat(); let hash = compute_git_sha256_from_bytes(&content); - let uuid = "11111111-1111-4111-8111-111111111111"; - - for mode in [DownloadMode::File, DownloadMode::Diff] { - let release = std::sync::Arc::new(tokio::sync::Notify::new()); - let uri = split_body_server( - head.clone(), - Some(tail.clone()), - content.len(), - release.clone(), - ) - .await; - let tmp = tempfile::tempdir().unwrap(); - let blobs = tmp.path().join("blobs"); - let diffs = tmp.path().join("diffs"); - let (manifest, dir) = match mode { - DownloadMode::File => (manifest_with_after_hashes(&[&hash]), blobs.clone()), - DownloadMode::Diff => (manifest_with_uuids(&[uuid]), diffs.clone()), - }; - let sources = PatchSources { - blobs_path: &blobs, - diffs_path: Some(&diffs), - mem_blobs: None, - }; - let client = proxy_client(&uri); - - let watch = async { - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); - let mut streamed = false; - while std::time::Instant::now() < deadline { - if stage_len(&dir) == Some(head.len() as u64) { - streamed = true; - break; - } - tokio::time::sleep(std::time::Duration::from_millis(10)).await; + + let release = std::sync::Arc::new(tokio::sync::Notify::new()); + let uri = split_body_server( + head.clone(), + Some(tail.clone()), + content.len(), + release.clone(), + ) + .await; + let tmp = tempfile::tempdir().unwrap(); + let blobs = tmp.path().join("blobs"); + let manifest = manifest_with_after_hashes(&[&hash]); + let client = proxy_client(&uri); + + let watch = async { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + let mut streamed = false; + while std::time::Instant::now() < deadline { + if stage_len(&blobs) == Some(head.len() as u64) { + streamed = true; + break; } - // Release the tail either way so the download can finish. - release.notify_one(); - streamed - }; - let (result, streamed) = tokio::join!( - fetch_missing_sources(&manifest, &sources, mode, &client, None), - watch - ); - assert!( - streamed, - "{mode:?}: the first {} bytes must be on disk while the rest is held back", - head.len() - ); - assert_eq!(result.downloaded, 1, "{mode:?}: {:?}", result.results); - let entry = match mode { - DownloadMode::File => blobs.join(&hash), - DownloadMode::Diff => diffs.join(format!("{uuid}.tar.gz")), - }; - assert_eq!(std::fs::read(&entry).unwrap(), content, "{mode:?}"); - assert_eq!(dir_entry_count(&dir), 1, "{mode:?}: no stage litter"); - } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + // Release the tail either way so the download can finish. + release.notify_one(); + streamed + }; + let (result, streamed) = + tokio::join!(fetch_missing_blobs(&manifest, &blobs, &client, None), watch); + assert!( + streamed, + "the first {} bytes must be on disk while the rest is held back", + head.len() + ); + assert_eq!(result.downloaded, 1, "{:?}", result.results); + assert_eq!(std::fs::read(blobs.join(&hash)).unwrap(), content); + assert_eq!(dir_entry_count(&blobs), 1, "no stage litter"); } /// A body cut short mid-stream fails that entry with the body-read error @@ -871,31 +560,23 @@ async fn blob_and_diff_bodies_reach_disk_before_the_response_completes() { async fn failed_streams_leave_no_stage_and_no_created_cache_dir() { let content = vec![b'c'; 64 * 1024]; let hash = compute_git_sha256_from_bytes(&content); - let uuid = "11111111-1111-4111-8111-111111111111"; let release = std::sync::Arc::new(tokio::sync::Notify::new()); // Cut short: the server declares twice what it sends, then closes. let uri = split_body_server(content.clone(), None, content.len() * 2, release.clone()).await; - for mode in [DownloadMode::File, DownloadMode::Diff] { - let tmp = tempfile::tempdir().unwrap(); - let blobs = tmp.path().join("blobs"); - let diffs = tmp.path().join("diffs"); - let manifest = match mode { - DownloadMode::File => manifest_with_after_hashes(&[&hash]), - DownloadMode::Diff => manifest_with_uuids(&[uuid]), - }; - let sources = PatchSources { - blobs_path: &blobs, - diffs_path: Some(&diffs), - mem_blobs: None, - }; - let result = - fetch_missing_sources(&manifest, &sources, mode, &proxy_client(&uri), None).await; - assert_eq!(result.failed, 1, "{mode:?}"); - let error = result.results[0].error.as_deref().unwrap(); - assert!(error.contains("Error reading"), "{mode:?}: {error}"); - assert!(!blobs.exists() && !diffs.exists(), "{mode:?}: no cache dir"); - } + let tmp = tempfile::tempdir().unwrap(); + let blobs = tmp.path().join("blobs"); + let result = fetch_missing_blobs( + &manifest_with_after_hashes(&[&hash]), + &blobs, + &proxy_client(&uri), + None, + ) + .await; + assert_eq!(result.failed, 1); + let error = result.results[0].error.as_deref().unwrap(); + assert!(error.contains("Error reading"), "{error}"); + assert!(!blobs.exists(), "no cache dir"); // Mismatch: the full body arrives but hashes to something else. let wrong = compute_git_sha256_from_bytes(b"something else"); diff --git a/crates/socket-patch-core/tests/covgap_api_blob_fetcher.rs b/crates/socket-patch-core/tests/covgap_api_blob_fetcher.rs index 8aab3b9b3..7f2d4af0b 100644 --- a/crates/socket-patch-core/tests/covgap_api_blob_fetcher.rs +++ b/crates/socket-patch-core/tests/covgap_api_blob_fetcher.rs @@ -20,13 +20,11 @@ //! `tests/common/`, and these are api-suite-specific). use socket_patch_core::api::blob_fetcher::{ - fetch_blobs_by_hash, fetch_missing_blobs, fetch_missing_sources, format_fetch_result, - DownloadMode, OnProgress, + fetch_blobs_by_hash, fetch_missing_blobs, format_fetch_result, OnProgress, }; use socket_patch_core::api::client::{ApiClient, ApiClientOptions}; use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; use socket_patch_core::manifest::schema::{PatchFileInfo, PatchManifest, PatchRecord}; -use socket_patch_core::patch::apply::PatchSources; use std::collections::{HashMap, HashSet}; use std::path::Path; use std::sync::{Arc, Mutex}; @@ -46,7 +44,7 @@ fn dummy_client() -> ApiClient { } /// Public-proxy client pointed at a mock server `base` (binary fetches -/// go to `/patch/blob/` and `/patch/diff/`). +/// go to `/patch/blob/`). fn proxy_client(base: &str) -> ApiClient { ApiClient::new(ApiClientOptions { api_url: base.to_string(), @@ -86,29 +84,6 @@ fn manifest_with_after_hashes(after: &[&str]) -> PatchManifest { } } -/// Manifest carrying a set of patch UUIDs (each as its own PURL). -fn manifest_with_uuids(uuids: &[&str]) -> PatchManifest { - let mut patches = HashMap::new(); - for (i, uuid) in uuids.iter().enumerate() { - patches.insert( - format!("pkg:npm/test-{i}@1.0.0"), - PatchRecord { - uuid: (*uuid).to_string(), - exported_at: "2024-01-01T00:00:00Z".to_string(), - files: HashMap::new(), - vulnerabilities: HashMap::new(), - description: "test".to_string(), - license: "MIT".to_string(), - tier: "free".to_string(), - }, - ); - } - PatchManifest { - patches, - setup: None, - } -} - /// Count the directory entries under `dir` (proves an error path wrote /// nothing — neither a final entry nor `.socket-dl-*` staging litter). fn dir_entry_count(dir: &Path) -> usize { @@ -120,7 +95,7 @@ fn dir_entry_count(dir: &Path) -> usize { // The target directory path is routed through a REGULAR FILE // (`tmp/notadir/`), so the writer's on-demand `create_dir_all` fails // with ENOTDIR on every platform, even as root. The presence probes that -// run first (`get_missing_blobs` / `get_missing_archives`) also fail to +// run first (`get_missing_blobs`) also fail to // stat through the file, so everything is reported missing and every // entry is fetched. Each download succeeds and hash-verifies; only the // disk write fails — so the outcome is the ordinary per-entry @@ -221,56 +196,6 @@ async fn fetch_blobs_by_hash_uncreatable_blobs_dir_is_per_blob_write_failure() { assert!(notadir.is_file(), "the blocking file must be left alone"); } -/// `fetch_missing_sources` in Diff mode when the archives directory -/// cannot be created: `get_missing_archives` reports the uuid missing -/// through the broken path, the archive is fetched, and the write is a -/// per-archive "Failed to write archive to disk" failure. The blobs dir -/// is not involved and stays empty. -#[tokio::test] -async fn fetch_missing_sources_diff_uncreatable_archives_dir_is_per_archive_write_failure() { - let tmp = tempfile::tempdir().unwrap(); - let blobs = tmp.path().join("blobs"); - std::fs::create_dir(&blobs).unwrap(); - let notadir = tmp.path().join("notadir"); - std::fs::write(¬adir, b"file blocking the path").unwrap(); - let diffs = notadir.join("diffs"); - let sources = PatchSources { - blobs_path: &blobs, - diffs_path: Some(&diffs), - mem_blobs: None, - }; - - let uuid = "11111111-1111-4111-8111-111111111111"; - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path_matcher(format!("/patch/diff/{uuid}"))) - .respond_with(ResponseTemplate::new(200).set_body_bytes(b"payload".to_vec())) - .expect(1) - .mount(&server) - .await; - let manifest = manifest_with_uuids(&[uuid]); - let client = proxy_client(&server.uri()); - - let result = - fetch_missing_sources(&manifest, &sources, DownloadMode::Diff, &client, None).await; - assert_eq!(result.total, 1); - assert_eq!(result.failed, 1); - assert_eq!(result.downloaded, 0); - assert_eq!(result.skipped, 0); - assert_eq!(result.results.len(), 1); - let entry = &result.results[0]; - assert_eq!(entry.hash, uuid, "diff-mode results carry the patch uuid"); - assert!(!entry.success); - let err = entry.error.as_deref().unwrap(); - assert!( - err.contains("Failed to write archive to disk"), - "per-archive disk-write message expected: {err}" - ); - assert!(notadir.is_file(), "the blocking file must be left alone"); - // Diff mode never touches the blobs dir. - assert_eq!(dir_entry_count(&blobs), 0); -} - /// A fetch that lands nothing creates nothing: with every blob 404 the /// blobs directory — and the `.socket/` above it — must not come into /// existence. The dir is the writer's to create, on the first verified @@ -435,64 +360,6 @@ async fn fetch_missing_blobs_disk_write_failure_is_per_blob_failure() { std::fs::set_permissions(&blobs, std::fs::Permissions::from_mode(0o755)).unwrap(); } -/// Diff-archive twin: `fetch_diff` succeeds but the archive write -/// fails → "Failed to write archive to disk", uuid carried in `hash`, -/// no `.tar.gz` and no stage litter. -#[cfg(unix)] -#[tokio::test] -async fn fetch_missing_sources_diff_disk_write_failure_is_per_archive_failure() { - use std::os::unix::fs::PermissionsExt; - - let tmp = tempfile::tempdir().unwrap(); - let blobs = tmp.path().join("blobs"); - let diffs = tmp.path().join("diffs"); - std::fs::create_dir(&blobs).unwrap(); - std::fs::create_dir(&diffs).unwrap(); - std::fs::set_permissions(&diffs, std::fs::Permissions::from_mode(0o555)).unwrap(); - if !write_into_dir_denied(&diffs) { - eprintln!("skipping: directory mode bits do not deny writes here (root?)"); - return; - } - - let uuid = "55555555-5555-4555-8555-555555555555"; - - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path_matcher(format!("/patch/diff/{uuid}"))) - .respond_with(ResponseTemplate::new(200).set_body_bytes(b"payload".to_vec())) - .expect(1) - .mount(&server) - .await; - - let sources = PatchSources { - blobs_path: &blobs, - diffs_path: Some(&diffs), - mem_blobs: None, - }; - let manifest = manifest_with_uuids(&[uuid]); - let client = proxy_client(&server.uri()); - - let result = - fetch_missing_sources(&manifest, &sources, DownloadMode::Diff, &client, None).await; - assert_eq!(result.total, 1); - assert_eq!(result.downloaded, 0); - assert_eq!(result.failed, 1); - assert_eq!(result.results[0].hash, uuid); - let err = result.results[0].error.as_deref().unwrap(); - assert!( - err.contains("Failed to write archive to disk"), - "archive disk-write arm message expected: {err}" - ); - assert!(!diffs.join(format!("{uuid}.tar.gz")).exists()); - assert_eq!( - dir_entry_count(&diffs), - 0, - "no partial file, no stage litter" - ); - - std::fs::set_permissions(&diffs, std::fs::Permissions::from_mode(0o755)).unwrap(); -} - // ── Mixed-outcome aggregation ──────────────────────────────────────── /// One run combining all three `download_entries` arms: a good blob, a diff --git a/crates/socket-patch-core/tests/covgap_patch_apply.rs b/crates/socket-patch-core/tests/covgap_patch_apply.rs index 9056c458f..83926de6e 100644 --- a/crates/socket-patch-core/tests/covgap_patch_apply.rs +++ b/crates/socket-patch-core/tests/covgap_patch_apply.rs @@ -60,7 +60,6 @@ async fn apply_foo(root: &Path, primary: &Path) -> socket_patch_core::patch::app ); let sources = PatchSources { blobs_path: &blobs, - diffs_path: None, mem_blobs: None, }; apply_package_patch( @@ -68,7 +67,6 @@ async fn apply_foo(root: &Path, primary: &Path) -> socket_patch_core::patch::app primary, &files, &sources, - None, false, MismatchPolicy::Warn, ) diff --git a/crates/socket-patch-core/tests/crawler_npm_e2e.rs b/crates/socket-patch-core/tests/crawler_npm_e2e.rs index d2ab7e18f..affc134e5 100644 --- a/crates/socket-patch-core/tests/crawler_npm_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_npm_e2e.rs @@ -2272,7 +2272,6 @@ async fn apply_and_rollback_reach_every_pnpm_peer_variant_copy() { let sources = PatchSources { blobs_path: &blobs, - diffs_path: None, mem_blobs: None, }; let result = apply_package_patch( @@ -2280,7 +2279,6 @@ async fn apply_and_rollback_reach_every_pnpm_peer_variant_copy() { &primary, &files, &sources, - None, false, MismatchPolicy::Warn, ) @@ -2381,7 +2379,6 @@ async fn apply_heals_unpatched_pnpm_twin_when_primary_already_patched() { ); let sources = PatchSources { blobs_path: &blobs, - diffs_path: None, mem_blobs: None, }; let result = apply_package_patch( @@ -2389,7 +2386,6 @@ async fn apply_heals_unpatched_pnpm_twin_when_primary_already_patched() { &nm.join("foo"), &files, &sources, - None, false, MismatchPolicy::Warn, ) @@ -3349,7 +3345,6 @@ async fn vlt_apply( use socket_patch_core::patch::apply::{apply_package_patch, MismatchPolicy, PatchSources}; let sources = PatchSources { blobs_path: &patch.blobs, - diffs_path: None, mem_blobs: None, }; apply_package_patch( @@ -3357,7 +3352,6 @@ async fn vlt_apply( primary, &patch.files, &sources, - None, false, MismatchPolicy::Warn, ) diff --git a/crates/socket-patch-core/tests/diff_e2e.rs b/crates/socket-patch-core/tests/diff_e2e.rs deleted file mode 100644 index 438d1d0da..000000000 --- a/crates/socket-patch-core/tests/diff_e2e.rs +++ /dev/null @@ -1,188 +0,0 @@ -//! Integration coverage for `socket_patch_core::patch::diff::apply_diff`. -//! -//! Mirrors the lib-level unit tests but lives in `tests/` so it -//! appears as integration coverage (counted by `cargo llvm-cov` -//! against the e2e bar) rather than lib coverage. - -use qbsdiff::Bsdiff; -use socket_patch_core::patch::diff::apply_diff; -use std::io::Cursor; - -/// Local helper: produce a bsdiff 4 delta from `before` → `after`. -fn make_delta(before: &[u8], after: &[u8]) -> Vec { - let mut delta = Vec::new(); - Bsdiff::new(before, after) - .compare(Cursor::new(&mut delta)) - .expect("bsdiff compare"); - delta -} - -/// Happy path: round-trip a small text mutation through bsdiff + -/// apply_diff. -#[test] -fn text_delta_round_trip() { - let before = b"the quick brown fox jumps over the lazy dog"; - let after = b"the quick brown cat jumps over the lazy dog"; - let delta = make_delta(before, after); - let result = apply_diff(before, &delta).unwrap(); - assert_eq!(result, after); -} - -/// Binary buffer with scattered mutations — exercises the -/// non-textual code path of qbsdiff. -#[test] -fn binary_delta_round_trip() { - let before: Vec = (0..1024u32).map(|i| (i % 251) as u8).collect(); - let mut after = before.clone(); - for i in [10usize, 200, 500, 900] { - after[i] = after[i].wrapping_add(7); - } - let delta = make_delta(&before, &after); - let result = apply_diff(&before, &delta).unwrap(); - assert_eq!(result, after); -} - -/// Edge case: empty `before` → non-empty `after`. Some bsdiff -/// implementations special-case the no-source branch; verify -/// ours doesn't. -#[test] -fn empty_to_nonempty() { - let before: &[u8] = b""; - let after = b"hello"; - let delta = make_delta(before, after); - let result = apply_diff(before, &delta).unwrap(); - assert_eq!(result, after); -} - -/// Malformed delta header must surface as an Io error, not a -/// panic. -#[test] -fn malformed_delta_errors() { - // Garbage that cannot be a valid bsdiff 4 magic/header. - let bogus = b"not a real bsdiff delta header"; - let result = apply_diff(b"anything", bogus); - assert!(result.is_err(), "expected Err on garbage delta"); - - // An empty delta has no header at all and must also error, not panic - // or silently return an empty/zero-length patch. - let empty = apply_diff(b"anything", b""); - assert!(empty.is_err(), "expected Err on empty delta"); - - // A truncated header (valid-looking start, cut short) must error too — - // this guards against a path that reads the size hint before validating - // the payload length. - let real = make_delta(b"abc", b"abcd"); - assert!(real.len() > 8, "sanity: real delta has a header"); - let truncated = &real[..8]; - let trunc_res = apply_diff(b"abc", truncated); - assert!( - trunc_res.is_err(), - "expected Err on truncated delta header, got {trunc_res:?}" - ); -} - -/// Applying a delta to the *wrong* source must not panic — the -/// caller is expected to verify the resulting `after_hash` -/// against the manifest, but the library itself never traps. -#[test] -fn wrong_source_does_not_panic() { - let src_a = b"AAAAAAAAAAAAAAAAAAAA"; - let src_b = b"BBBBBBBBBBBBBBBBBBBB"; - let target = b"CCCCCCCCCCCCCCCCCCCC"; - let delta = make_delta(src_a, target); - // The contract is never-panic, and the result must be a well-formed - // Result either way — bind and match it so the call is actually driven - // to completion (not optimized into a no-op) and any future panic in - // bspatch surfaces as a test failure. - match apply_diff(src_b, &delta) { - // qbsdiff is content-agnostic: applying to the wrong source may - // succeed with garbage bytes whose length matches the delta's - // target. If it does succeed, the output must at least be the - // declared target length (the control stream drives the length), - // never an out-of-bounds read. - Ok(out) => assert_eq!( - out.len(), - target.len(), - "bspatch output length is fixed by the control stream" - ), - Err(_) => { /* equally acceptable: a checksum/bounds rejection */ } - } -} - -/// Security (mirrors the lib's -/// `test_apply_diff_forged_oversize_header_is_safe`): a hostile delta can -/// claim an arbitrary target size in header bytes 24..32. qbsdiff does NOT -/// validate that field against the real payload, so feeding it straight into -/// `Vec::with_capacity` would let a tiny delta request a multi-exabyte -/// reservation — aborting the process or panicking with "capacity overflow". -/// `apply_diff` must clamp the hint and still produce correct output. -/// -/// Without the clamp this test panics/aborts on the allocation, so it fails -/// loudly if the bound is ever removed. -#[test] -fn forged_oversize_header_is_safe() { - let before = b"the quick brown fox jumps over the lazy dog"; - let after = b"the quick brown cat jumps over the lazy dog"; - let mut forged = make_delta(before, after); - assert!(forged.len() >= 32, "delta must contain a full header"); - - // Overwrite ONLY the target-size field (LE bytes 24..32) with ~1.15 EiB. - // Keep the top bit clear so it decodes as a huge unsigned size, not a - // negative offset. - let huge: u64 = 1 << 60; - forged[24..32].copy_from_slice(&huge.to_le_bytes()); - - let result = apply_diff(before, &forged) - .expect("clamped apply must still succeed on a forged size hint"); - assert_eq!( - result, after, - "forging the size hint must not corrupt the patched output" - ); -} - -/// A delta whose forged target size is the maximum `u64` must be handled -/// identically — pins that the clamp covers the extreme end of the range, -/// not just one convenient value. -#[test] -fn forged_max_u64_header_is_safe() { - let before = b"alpha beta gamma delta epsilon"; - let after = b"alpha beta GAMMA delta epsilon"; - let mut forged = make_delta(before, after); - assert!(forged.len() >= 32, "delta must contain a full header"); - // i64::MAX keeps the top bit clear (qbsdiff reads this as a signed-ish - // length); a value with the top bit set would be rejected as negative. - let huge: u64 = i64::MAX as u64; - forged[24..32].copy_from_slice(&huge.to_le_bytes()); - - let result = - apply_diff(before, &forged).expect("clamped apply must succeed on a max-size forged hint"); - assert_eq!( - result, after, - "max-size forged hint must not corrupt output" - ); -} - -/// Security (mirrors the lib's -/// `test_apply_diff_forged_negative_block_length_does_not_panic`): the -/// compressed control/diff block lengths in header bytes 8..24 are decoded -/// with a sign-magnitude scheme. A field with the sign bit set decodes to a -/// "negative" length whose `as u64` is enormous; qbsdiff's only guard -/// (`32 + csize + dsize > patch.len()`) uses *wrapping* arithmetic, so the sum -/// wraps back in-bounds and the subsequent `split_at` panics on -/// attacker-controlled input. `apply_diff` must reject it as a plain error. -#[test] -fn forged_negative_block_length_does_not_panic() { - let before = b"the quick brown fox jumps over the lazy dog"; - let after = b"the quick brown cat jumps over the lazy dog"; - let mut forged = make_delta(before, after); - assert!(forged.len() >= 32, "delta must contain a full header"); - // Sign-magnitude encoding of a negative control-block length (bytes 8..16). - let neg: u64 = 16u64 | (1u64 << 63); - forged[8..16].copy_from_slice(&neg.to_le_bytes()); - - let result = apply_diff(before, &forged); - assert!( - result.is_err(), - "a forged negative block length must error, not panic the process" - ); -} diff --git a/crates/socket-patch-core/tests/package_e2e.rs b/crates/socket-patch-core/tests/package_e2e.rs index 2bfd8f7ff..432254c74 100644 --- a/crates/socket-patch-core/tests/package_e2e.rs +++ b/crates/socket-patch-core/tests/package_e2e.rs @@ -1,7 +1,6 @@ //! Integration coverage for `socket_patch_core::patch::package`. //! -//! Exercises both `read_archive_to_map` and `read_archive_filtered` -//! across the happy path, the `package/` prefix stripping rule, +//! Exercises `read_archive_to_map` across the happy path, the `package/` prefix stripping rule, //! the unsafe-path guards (absolute paths, parent traversal, //! Windows-style backslash paths), the validate-AFTER-normalize //! guards (`package/`-prefixed escapes that only become unsafe once @@ -9,14 +8,12 @@ //! (symlinks). Lives in `tests/` so the coverage tool counts it //! against the integration bar rather than the lib bar. -use std::collections::HashMap; use std::io::Write; use std::path::Path; use flate2::write::GzEncoder; use flate2::Compression; -use socket_patch_core::manifest::schema::PatchFileInfo; -use socket_patch_core::patch::package::{read_archive_filtered, read_archive_to_map, ArchiveError}; +use socket_patch_core::patch::package::{read_archive_to_map, ArchiveError}; use tar::Builder; /// Helper: write a small gzipped tar archive containing `(name, @@ -273,89 +270,3 @@ fn read_archive_to_map_handles_corrupt_gzip() { let result = read_archive_to_map(&archive); assert!(result.is_err()); } - -// ── read_archive_filtered ────────────────────────────────────────── - -fn make_file_info() -> HashMap { - let mut files = HashMap::new(); - files.insert( - "package/index.js".to_string(), - PatchFileInfo { - before_hash: "a".repeat(64), - after_hash: "b".repeat(64), - }, - ); - files.insert( - "lib/util.js".to_string(), - PatchFileInfo { - before_hash: "c".repeat(64), - after_hash: "d".repeat(64), - }, - ); - files -} - -#[test] -fn read_archive_filtered_keeps_only_listed_entries() { - let tmp = tempfile::tempdir().unwrap(); - let archive = tmp.path().join("arc.tar.gz"); - write_archive( - &archive, - &[ - ("package/index.js", b"patched index"), - ("lib/util.js", b"patched util"), - ("bonus/extra.js", b"unwanted"), - ], - ); - - let filtered = read_archive_filtered(&archive, &make_file_info()).unwrap(); - assert_eq!( - filtered.len(), - 2, - "exactly the two listed entries survive: {filtered:?}" - ); - // The listed `package/index.js` key must match the normalized - // `index.js` entry, carrying its exact bytes through the filter. - assert_eq!( - filtered.get("index.js").map(|v| v.as_slice()), - Some(b"patched index".as_slice()), - "package-prefixed listing must match normalized entry with intact bytes" - ); - assert_eq!( - filtered.get("lib/util.js").map(|v| v.as_slice()), - Some(b"patched util".as_slice()), - "non-prefixed listing must match verbatim with intact bytes" - ); - assert!( - !filtered.contains_key("bonus/extra.js"), - "filter must drop entries not listed in patch files map" - ); - // And it must not leak the unlisted bytes under any key. - assert!( - !filtered.values().any(|v| v.as_slice() == b"unwanted"), - "unlisted entry bytes must never survive the filter: {filtered:?}" - ); -} - -#[test] -fn read_archive_filtered_propagates_unsafe_path_errors() { - // If the underlying read trips an unsafe-path guard, filter - // must propagate rather than swallow. - let tmp = tempfile::tempdir().unwrap(); - let archive = tmp.path().join("arc.tar.gz"); - write_raw_archive(&archive, b"/etc/shadow", b"evil"); - let err = read_archive_filtered(&archive, &make_file_info()).unwrap_err(); - assert_unsafe_path_containing(err, "/etc/shadow"); -} - -#[test] -fn read_archive_filtered_propagates_package_prefixed_escape() { - // The filter delegates to `read_archive_to_map`, so the post-strip - // validation must propagate here too. `package//etc/shadow` would - // escape the package dir if validation regressed to pre-strip. - let tmp = tempfile::tempdir().unwrap(); - let archive = tmp.path().join("arc.tar.gz"); - write_raw_archive(&archive, b"package//etc/shadow", b"evil"); - let err = read_archive_filtered(&archive, &make_file_info()).unwrap_err(); - assert_unsafe_path_containing(err, "package//etc/shadow"); -} diff --git a/crates/socket-patch-core/tests/vlt_locks.rs b/crates/socket-patch-core/tests/vlt_locks.rs index a72910926..2f9b694e1 100644 --- a/crates/socket-patch-core/tests/vlt_locks.rs +++ b/crates/socket-patch-core/tests/vlt_locks.rs @@ -547,7 +547,6 @@ fn stage(case: &Case, crlf: bool) -> Staged { async fn vendor(case: &Case, staged: &Staged) -> VendorOutcome { let sources = PatchSources { blobs_path: &staged.blobs, - diffs_path: None, mem_blobs: None, }; use base64::Engine as _; diff --git a/docs/migrating-to-v5.md b/docs/migrating-to-v5.md index 25b12b257..156d55b90 100644 --- a/docs/migrating-to-v5.md +++ b/docs/migrating-to-v5.md @@ -160,10 +160,12 @@ whole root, not a `vendor_jvm_degraded` warning on mixed Maven + Gradle roots. | `get --no-apply` | `get --save-only` (`SOCKET_SAVE_ONLY` is unchanged) | | `socket-patch download` | `socket-patch get` | | `socket-patch gc` | `socket-patch repair` | +| `--download-mode`, `SOCKET_DOWNLOAD_MODE` | No replacement; patch content is always fetched as per-file blobs | | `SOCKET_FORCE` | Pass `--force` to the one command that needs it (`apply`, `vendor`, `--update`); the variable is now ignored | A removed spelling is a usage error (exit 2). `scan --sync` stays as the shorthand for `scan --mode agent --prune`. -Legacy `.socket/packages/` archives are no longer read. Patch data uses diff -archives or blobs; cleanup commands remove obsolete package archives. +Legacy `.socket/packages/` and `.socket/diffs/` archives are no longer read. +Patch data uses per-file blobs (`.socket/blobs/`); cleanup commands remove the +obsolete archives.