diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0b14d8d4..dcb173795 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -800,6 +800,19 @@ jobs: - name: Run npm dispatch tests run: node --test npm/socket-patch/bin/socket-patch.test.mjs + - name: Run npm schema tests + # The `./schema` export's zod tests. `npm install --no-save`, not + # `npm ci`, for the reason publish-npm.yml gives (a version-synced + # lock can name platform packages not yet on the registry); the + # platform binaries are optional and not needed here. The pack + # test runs again once `npm test` has built dist/, so it also + # checks that the compiled schema ships. + working-directory: npm/socket-patch + run: | + npm install --no-save --ignore-scripts --no-audit --no-fund --omit=optional + npm test + node --test --test-name-pattern="npm package contents" bin/socket-patch.test.mjs + # Compiles the CLI and every CLI test target once per OS (--all-features, # so this is also the feature-gated suites' compile-rot check) and uploads # the binaries the e2e, e2e-full and cargo-vex legs run. The legs run the diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 7eddb127c..9632ea389 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -904,7 +904,7 @@ A bare `rollback` (or a scoped one, for its scope) restores the SYSTEM to unpatc 5. **Manifest cleanup** — entries are removed ONLY for in-scope purls whose legs fully succeeded, were not-installed, or were release-variant siblings narrowed away by an attempted variant that succeeded (half a variant group never lingers — `remove` parity); drift-kept and failed purls keep their records, and a failed variant holds its whole group. No-op removals never rewrite the file. A failed write surfaces as `manifest_write_failed` (warning + `partial_failure` exit 1; GC still runs against the unchanged manifest). 6. **GC** — blob, diff and legacy package-archive sweeps against the post-removal manifest, using the same artifact-reference policy as `remove`, retaining beforeHash blobs for (a) removed-but-not-installed entries (a crawler miss must not destroy the only local revert data — `remove` parity) and (b) EVERY entry remaining in the post-removal manifest — still-active patches (failed, drift-kept, eco-/path-excluded) keep their revert data, so a scoped or failed run never destroys the blobs a later rollback needs; only blobs referenced solely by genuinely-removed entries are swept. GC errors warn (`cleanup_failed`) and continue — they never affect the exit (repair's posture). -**Confirmation prompt.** A wet, non-preserve run with work prompts once, remove-style, composing only the clauses that apply into one English list (`a and b`, `a, b, and c`) with counted nouns: `Roll back N patches`, `remove them from the local manifest`, `delete M vendored artifacts and their ledger records`, `restore H hosted packages to the upstream registry` (e.g. `Roll back 1 patch, remove it from the local manifest, and restore 1 hosted package to the upstream registry?`) — default yes, auto-accepted under `--yes`/`--json`/non-TTY (the shared `confirm` semantics; CI unaffected). Decline prints `Rollback cancelled.` and exits 0. `--dry-run` and `--preserve-state` runs are prompt-free (they delete no local state). +**Confirmation prompt.** A wet, non-preserve run with work prompts once, remove-style, composing only the clauses that apply into one English list (`a and b`, `a, b, and c`) with counted nouns: `Roll back N patches`, `remove them from the local manifest`, `delete M vendored artifacts and their ledger records`, `restore H hosted packages to the upstream registry` (e.g. `Roll back 1 patch, remove it from the local manifest, and restore 1 hosted package to the upstream registry?`) — default yes, auto-accepted under `--yes`/`--json`/non-TTY (the shared `confirm` semantics; CI unaffected). Decline prints `Cancelled; no changes made.` (stdout) and exits 0. `--dry-run` and `--preserve-state` runs are prompt-free (they delete no local state). ### `--preserve-state` (opt-out, both `rollback` and `remove`) @@ -1133,7 +1133,7 @@ The v3.0 legacy names `SOCKET_PATCH_PROXY_URL`, `SOCKET_PATCH_DEBUG` and `SOCKET ## JSON output shapes -Every `--json` invocation emits a single JSON object that follows the **unified envelope** below. The envelope was introduced in v3.0; older per-command shapes are deprecated. See `src/json_envelope.rs` for the source of truth and `tests/cli_parse_*.rs` for snapshot tests that lock the shape. +Every `--json` invocation emits a single JSON object that follows the **unified envelope** below. The envelope was introduced in v3.0; older per-command shapes are deprecated. See `src/json_envelope.rs` for the source of truth; its unit tests pin the serialized names, and each command's e2e tests assert the envelope it emits. The `tests/cli_parse_*.rs` files pin the parsed clap arguments, not this shape (a few, such as `cli_parse_list.rs`, also spot-check `list`'s envelope). ### Envelope shape diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index e28629891..2db2eab60 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -137,8 +137,9 @@ fn refuse( } /// The apply lock for a WET hosted run: the same `/apply.lock` -/// `apply`/`rollback`/`remove`/`vendor` hold, so the takeover pre-reverts, -/// the ledger merge and the lockfile writes never race them. `acquire` +/// `apply`/`rollback`/`remove`/`vendor` hold, so the takeover pre-reverts +/// (lockfiles + the vendored ledger) and the lockfile writes never race +/// them. `acquire` /// creates a missing `.socket/` and the guard's drop unlinks the lock file /// and prunes an otherwise-empty `.socket/`, so a run that ends up writing /// nothing leaves no residue. Contention / IO failures render through the @@ -299,11 +300,10 @@ async fn installed_stale_positive_evidence( /// refuses as a write root is still READ here /// (`verification_only_gem_paths`): bundler installs into it. /// * Records are found BY UUID (the fetch key, stable across purl -/// spellings): this run's fetched records first, then the redirect -/// ledger's persisted ones — a re-scan whose `/patches/view` fetch failed -/// transiently still re-fires from the ledger instead of silently -/// dropping the warning (`record_fetch_failed` covers the fetch failure -/// itself). Record availability is part of the candidate filter, and the +/// spellings) among this run's fetched records; v5 keeps no hosted +/// ledger to fall back on, so a uuid whose `/patches/view` fetch failed +/// is not judged (`record_fetch_failed` surfaces that failure). Record +/// availability is part of the candidate filter, and the /// probe returns before any crawler work (or `gem env` subprocess spawn) /// when no judgment is possible. /// * PATCHED means [`verify_patch_record`] `Ok` — the one shared oracle @@ -326,8 +326,7 @@ async fn gem_stale_install_warnings( global: bool, global_prefix: Option, confirmed: &[(String, String)], - // This run's fetched records MERGED with the ledger's persisted ones - // (the caller hands the post-merge ledger map). + // This run's fetched records, by uuid. records: &std::collections::BTreeMap, gem_artifact_shas: &std::collections::BTreeMap<(String, String), String>, ) -> StaleInstallOutcome { @@ -629,20 +628,20 @@ pub(super) async fn run_redirect( /// ([`socket_patch_core::hosted::engine`], over a /// [`ProjectView::Disk`](socket_patch_core::vendor::lock_inventory::ProjectView)); /// what stays here is what needs the host: reference grants and the other -/// network fetches, the apply lock (wet runs with a grant), the redirect -/// ledger load, the vendored→hosted takeover pre-revert (symlink-checked -/// first), the `pipenv --version` probe, the symlink guard, the ledger -/// merge-then-persist and the file writes, the gem / Python / vlt +/// network fetches, the apply lock (wet runs with a grant), the +/// vendored→hosted takeover pre-revert (symlink-checked first), the +/// `pipenv --version` probe, the symlink guard, the file writes, the gem / +/// Python / vlt /// stale-install probes, and the optional VEX. Shared VERBATIM by `scan /// --mode hosted` (its `--json` arm through the `run_redirect` wrapper, its /// human arm through [`boxed_run_redirect_selected`] in `scan/mod.rs`; both /// select via `discover_selected`, with no prompt) and by `get --mode /// hosted` (which pins the advisory-resolved uuid), so all produce -/// identical on-disk results for the same selection. The redirect ledger -/// is loaded HERE, under the apply lock whenever this run holds one (never -/// handed in pre-loaded: a copy read before the lock could merge over a -/// concurrent writer's edits); a dry run or a zero-grant run reads it -/// strictly but writes nothing, quarantine included. +/// identical on-disk results for the same selection. v5 hosted mode keeps +/// no ledger (the lockfiles are the only record); the VENDORED ledger the +/// takeover needs is loaded HERE, under the apply lock whenever this run +/// holds one (never handed in pre-loaded: a copy read before the lock could +/// be saved over a concurrent writer's edits). /// /// `scan_result` must be `Some` exactly when `common.json` is set (the /// human/JSON split keys on `common.json`; a `--json` caller passing `None` @@ -1091,10 +1090,10 @@ pub(crate) async fn run_redirect_selected( std::collections::BTreeMap::new(); let mut record_warnings: Vec = Vec::new(); - // SYMLINK GUARD (see `engine::guard`) — before the ledger and before any - // write, dry runs included, so a dry run predicts the refusal. The - // revert side (replay.rs) already refuses linked files, so the write - // side must too. + // SYMLINK GUARD (see `engine::guard`) — before any write, dry runs + // included, so a dry run predicts the refusal. The revert side (the + // hosted → upstream restore's staged flush) already refuses linked + // files, so the write side must too. if let Some(refusal) = engine::guard(&view, &done, &candidates) { return refuse(common, scan_result.take(), &refusal); } @@ -1176,8 +1175,8 @@ pub(crate) async fn run_redirect_selected( // the writes so the warning describes the project as this run leaves it. // Idempotent re-scans re-confirm and re-probe, so the warning keeps // firing until the stale materialization is actually gone. Skipped - // EXPLICITLY on --dry-run: the probe's ledger-record fallback would - // otherwise judge state the run did not (re)create. + // EXPLICITLY on --dry-run: nothing was written, so the probe would + // judge state the run did not (re)create. let gem_stale: StaleInstallOutcome = if common.dry_run { StaleInstallOutcome::default() } else { @@ -1330,7 +1329,7 @@ pub(crate) async fn run_redirect_selected( // Stale-flagged purls are EXCLUDED from assume_applied: the same-run // envelope carries a redirect_gem_stale_install warning proving the // installed materialization unpatched, so attesting that purl from - // the ledger would contradict the run's own warning. Excluded purls + // this run's records would contradict the run's own warning. Excluded purls // fall back to `vex`'s normal installed-tree verification. // A confirmed uuid whose bundled instance the rewriter had to skip // (#469) leaves that copy unpatched, so it too is verified, never @@ -3382,8 +3381,8 @@ mod tests { /// Probe invocation with the default surface (project-local discovery, /// no artifact shas) — tests override the knobs they exercise. - /// `records` is the merged map production hands over (this run's - /// fetched records plus the ledger's persisted ones). + /// `records` is the map production hands over: this run's fetched + /// records, by uuid. async fn probe( cwd: &std::path::Path, confirmed: &[(String, String)], @@ -3712,25 +3711,22 @@ mod tests { ); } - /// RE-FIRE guarantee: when this run's record fetch failed (no fresh - /// records), the merged map the caller hands over still carries the - /// redirect ledger's PERSISTED record under whatever purl key the - /// ledger used — and the probe's uuid lookup judges from it, so a - /// transient /patches/view failure cannot silently retire the warning - /// while the stale materialization is still there. + /// The probe links a record to a confirmed purl by uuid alone: a + /// record keyed under the API's qualified purl spelling (not the + /// confirmed purl) must still judge the stale materialization. #[tokio::test] - async fn gem_stale_probe_judges_from_persisted_ledger_records() { + async fn gem_stale_probe_matches_records_by_uuid_not_purl_key() { let stale = tempfile::tempdir().unwrap(); materialize_gem(stale.path(), GEM_UPSTREAM); - // Persisted under the API's qualified spelling, not the confirmed + // Keyed under the API's qualified spelling, not the confirmed // purl: only the uuid links them. - let mut ledger_only = std::collections::BTreeMap::new(); - ledger_only.insert(format!("{GEM_PURL}?platform=ruby"), gem_record()); - let out = probe(stale.path(), &one_confirmed(), &ledger_only).await; + let mut qualified = std::collections::BTreeMap::new(); + qualified.insert(format!("{GEM_PURL}?platform=ruby"), gem_record()); + let out = probe(stale.path(), &one_confirmed(), &qualified).await; assert_eq!( out.warnings.len(), 1, - "the ledger records must keep the warning firing across flaky fetches" + "a record keyed under another purl spelling must still match by uuid" ); } diff --git a/crates/socket-patch-cli/src/commands/scan/policy.rs b/crates/socket-patch-cli/src/commands/scan/policy.rs index 98b1ecc45..5f5fe8afe 100644 --- a/crates/socket-patch-cli/src/commands/scan/policy.rs +++ b/crates/socket-patch-cli/src/commands/scan/policy.rs @@ -69,7 +69,10 @@ pub(crate) fn load_invocation_policy(args: &ScanArgs) -> Result Vec { let mut markers: Vec = std::fs::read_dir(dir) .map(|entries| { diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 161c7d3d4..91d2662e4 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -8,7 +8,7 @@ //! 1. [`build_candidates`] — reference grants → rewriter overrides. //! 2. [`bun_lockb_symlinked`] — the binary-lock symlink refusal. //! 3. vlt artifact preflight ([`super::vlt`]) + [`withhold_everywhere`]. -//! 4. (caller) the apply lock, the ledger, the vendored→hosted takeover. +//! 4. (caller) the apply lock and the vendored→hosted takeover. //! 5. [`read_candidate_files`] → [`wheel_targets`] → (caller) wheel metadata, //! and [`yarn_berry_manifest_targets`] → (caller) served npm manifests. //! 6. [`rewrite`] — the rewriters, the pnpm `trustLockfile` and npm @@ -17,8 +17,7 @@ //! //! Nothing here writes, spawns, reads the environment or touches the //! network: every host effect (locking, probes, record fetches, the commit -//! of the rewritten files, the redirect ledger in [`super::ledger`]) stays -//! with the caller. +//! of the rewritten files) stays with the caller. use std::collections::{BTreeMap, BTreeSet, HashMap}; @@ -1233,14 +1232,13 @@ pub async fn rewrite( ); if let Some((text, edit)) = trust_config_write { rewrite.files.insert(PNPM_WORKSPACE_REL.to_string(), text); - // Appended last: `--revert` walks edits in reverse, so the trust key - // is unwound before the lock originals are restored. + // Appended last, after the lock edits it serves. v5 keeps no hosted + // ledger, so nothing replays these edits; the order is write order. rewrite.edits.push(edit); } if let Some((text, edit)) = npmrc_config_write { rewrite.files.insert(NPMRC_REL.to_string(), text); - // Appended after the lock edits for the same reason: a whole-ledger - // replay unwinds the setting before the lock originals it served. + // Appended after the lock edits, like the pnpm trust key above. rewrite.edits.push(edit); } let rewritten: Vec = rewrite @@ -1903,8 +1901,8 @@ fn file_ecosystem(rel: &str) -> Option<&'static str> { .then_some("pypi") } -/// SYMLINK GUARD — fail-closed, whole rewrite, before the ledger and before -/// any write (hosted rewrites are transactional). The writer stages next to +/// SYMLINK GUARD — fail-closed, whole rewrite, before any write (hosted +/// rewrites are transactional). The writer stages next to /// the path and renames over it, which REPLACES a symbolic link with a /// detached regular copy: the link target goes stale and a revert restores /// bytes but never the link. Applies to every ecosystem's files and to dry diff --git a/crates/socket-patch-core/src/patch/redirect/staged.rs b/crates/socket-patch-core/src/patch/redirect/staged.rs index d57675577..c632f9391 100644 --- a/crates/socket-patch-core/src/patch/redirect/staged.rs +++ b/crates/socket-patch-core/src/patch/redirect/staged.rs @@ -1,13 +1,12 @@ -//! Staged, fail-closed file I/O shared by the hosted-redirect reverts — the -//! per-purl takeover ([`super::takeover`]) and the whole-ledger replay -//! ([`super::replay`]). +//! Staged, fail-closed file I/O for the hosted → upstream restore +//! ([`super::upstream`]). //! -//! Both reverts resolve every inverse against a STAGED view of the project -//! and let nothing reach disk until all of them have resolved, so a drift -//! refusal leaves the project byte-identical. This module is that staging -//! layer: FIFO-safe reads of untrusted project files, the staged view, and -//! one flush with the same guards on both sides (a symlink or FIFO squatting -//! a path refuses; every write is atomic and keeps the file's mode). +//! The restore resolves every pin against a STAGED view of the project and +//! lets nothing reach disk until all of them have resolved, so a refusal +//! leaves the project byte-identical. This module is that staging layer: +//! FIFO-safe reads of untrusted project files, the staged view, and one +//! flush with the same guards on both sides (a symlink or FIFO squatting a +//! path refuses; every write is atomic and keeps the file's mode). use std::collections::BTreeMap; use std::path::Path; diff --git a/crates/socket-patch-core/src/update/release.rs b/crates/socket-patch-core/src/update/release.rs index 5b2869012..aa6518bb9 100644 --- a/crates/socket-patch-core/src/update/release.rs +++ b/crates/socket-patch-core/src/update/release.rs @@ -13,8 +13,9 @@ //! for a fallback that only fires when the redirect shape drifts. //! //! All fetch sizes are capped and every request carries an explicit -//! timeout: a hung self-update is strictly worse than a hung scan, so this -//! module does not inherit the API client's no-timeout posture. +//! whole-request deadline ([`UpdateTimeouts`]), unlike the API client's +//! connect + per-read bounds (`api::retry::ApiTimeouts`): a hung +//! self-update is strictly worse than a hung scan. use std::time::Duration; diff --git a/crates/socket-patch-core/src/vex/discover/pypi_other.rs b/crates/socket-patch-core/src/vex/discover/pypi_other.rs index 2196a1882..4372d887e 100644 --- a/crates/socket-patch-core/src/vex/discover/pypi_other.rs +++ b/crates/socket-patch-core/src/vex/discover/pypi_other.rs @@ -16,10 +16,11 @@ //! a url naming another package, is diagnosed, never trusted. When the //! url does not carry the `patch/pypi/…` levels (a self-hosted //! `--patch-server-url` layout), the artifact filename alone supplies -//! the version. This is the host-allowlisted twin of -//! `vendor::pypi_pipenv::is_socket_hosted_reference`, which accepts the -//! same path shape on ANY https host because it only decides ownership -//! of a lock entry, not attestation; +//! the version. The path is parsed by the same +//! `vendor::lock_inventory::pypi::hosted_artifact_url` that +//! `hosted_pypi_reference` (hosted Pipenv rotation and the vendored +//! Pipenv guard's "is this lock entry ours" check) uses, and both apply +//! the same patch-server origin allowlist; //! * a root-anchored `.socket/vendor/pypi//` path //! ([`vendor_ref`]) → [`WiringMode::Vendored`]. The wheel must be a single //! PEP 427 filename (or a server sdist's `dist-version.tar.gz`) naming the diff --git a/npm/socket-patch/bin/socket-patch b/npm/socket-patch/bin/socket-patch index 0012586f6..1e686b8b7 100755 --- a/npm/socket-patch/bin/socket-patch +++ b/npm/socket-patch/bin/socket-patch @@ -1,6 +1,7 @@ #!/usr/bin/env node const { spawnSync } = require("child_process"); const fs = require("fs"); +const os = require("os"); const path = require("path"); const PLATFORMS = { @@ -46,6 +47,14 @@ function orderCandidates(candidates, libc) { // installed candidate is worth trying. const UNRUNNABLE = new Set(["ENOENT", "EACCES", "ENOEXEC"]); +// A binary killed by a signal has no exit status; report it the way a +// shell does (128 + the signal number), so Ctrl-C reads as 130, not 1. +function exitCode({ status, signal }) { + if (status !== null && status !== undefined) return status; + const number = signal && os.constants.signals[signal]; + return number ? 128 + number : 1; +} + function runFirstUsable(binPaths, args, { spawn = (bin, argv) => spawnSync(bin, argv, { stdio: "inherit", env: process.env }), log = (msg) => console.error(msg), @@ -53,7 +62,7 @@ function runFirstUsable(binPaths, args, { let lastError; for (const bin of binPaths) { const result = spawn(bin, args); - if (!result.error) return result.status ?? 1; + if (!result.error) return exitCode(result); lastError = { bin, error: result.error }; if (!UNRUNNABLE.has(result.error.code)) break; } diff --git a/npm/socket-patch/bin/socket-patch.test.mjs b/npm/socket-patch/bin/socket-patch.test.mjs index b53f347ef..02c69df75 100644 --- a/npm/socket-patch/bin/socket-patch.test.mjs +++ b/npm/socket-patch/bin/socket-patch.test.mjs @@ -1,7 +1,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; @@ -165,6 +165,20 @@ describe("npm wrapper libc selection (#974)", () => { assert.equal(status, 3); }); + // A binary killed by a signal has `status: null`; the wrapper must + // report it the way a shell does (128 + signal number), not as 1. + for (const [signal, code] of [["SIGINT", 130], ["SIGTERM", 143], ["SIGKILL", 137]]) { + it(`reports a ${signal} death as exit ${code}`, () => { + const logs = []; + const status = wrapper.runFirstUsable(["/gnu", "/musl"], [], { + spawn: () => ({ status: null, signal }), + log: (msg) => logs.push(msg), + }); + assert.equal(status, code); + assert.deepEqual(logs, []); + }); + } + // End to end: a node_modules tree like yarn classic leaves on Alpine, // with both platform packages installed and the gnu binary unable to // start. The wrapper must run the musl binary instead of exiting 1 @@ -204,3 +218,36 @@ describe("npm wrapper libc selection (#974)", () => { }, ); }); + +describe("npm package contents", () => { + const pkgDir = join(__dirname, ".."); + const npm = spawnSync("npm", ["--version"], { encoding: "utf8" }); + + it( + "publishes the wrapper and the compiled schema, not sources or tests", + { skip: npm.status !== 0 && "npm is not on PATH" }, + () => { + const result = spawnSync("npm", ["pack", "--dry-run", "--json", "--ignore-scripts"], { + cwd: pkgDir, + encoding: "utf8", + }); + assert.equal(result.status, 0, `stderr: ${result.stderr}`); + const files = JSON.parse(result.stdout)[0].files.map((f) => f.path); + assert.ok(files.includes("bin/socket-patch"), files.join(", ")); + assert.ok(files.includes("package.json"), files.join(", ")); + // The `./schema` export points at dist/; once it is built, both + // compiled files must ship or the export resolves to nothing. + if (existsSync(join(pkgDir, "dist", "schema", "manifest-schema.js"))) { + assert.ok(files.includes("dist/schema/manifest-schema.js"), files.join(", ")); + assert.ok(files.includes("dist/schema/manifest-schema.d.ts"), files.join(", ")); + } + for (const file of files) { + assert.doesNotMatch(file, /\.test\.|^src\/|tsconfig|tsbuildinfo/, `unexpected file in the tarball: ${file}`); + assert.ok( + file === "package.json" || file === "README.md" || file.startsWith("bin/socket-patch") || /^dist\/schema\/manifest-schema\.(js|d\.ts)$/.test(file), + `unexpected file in the tarball: ${file}`, + ); + } + }, + ); +}); diff --git a/npm/socket-patch/package.json b/npm/socket-patch/package.json index 0be711401..91514a39f 100644 --- a/npm/socket-patch/package.json +++ b/npm/socket-patch/package.json @@ -5,6 +5,12 @@ "bin": { "socket-patch": "bin/socket-patch" }, + "files": [ + "bin/socket-patch", + "bin/socket-patch-*", + "dist/schema/manifest-schema.js", + "dist/schema/manifest-schema.d.ts" + ], "exports": { "./schema": { "types": "./dist/schema/manifest-schema.d.ts", @@ -18,7 +24,7 @@ "scripts": { "build": "tsc", "prepack": "tsc", - "test": "pnpm run build && node --test dist/**/*.test.js" + "test": "npm run build && node --test dist/schema/manifest-schema.test.js" }, "keywords": [ "security", diff --git a/npm/socket-patch/src/schema/manifest-schema.test.ts b/npm/socket-patch/src/schema/manifest-schema.test.ts index d87a0f5cf..12ee44062 100644 --- a/npm/socket-patch/src/schema/manifest-schema.test.ts +++ b/npm/socket-patch/src/schema/manifest-schema.test.ts @@ -122,6 +122,30 @@ describe('PatchManifestSchema', () => { assert.ok(!result.success, 'Invalid UUID should fail') }) + it('should keep the legacy setup block on round-trip', () => { + const manifest = { + patches: {}, + setup: { exclude: ['packages/legacy'], manual: ['pypi'] }, + } + const result = PatchManifestSchema.safeParse(manifest) + assert.ok(result.success, 'A manifest with a setup block should parse') + assert.deepEqual(result.data.setup, manifest.setup) + }) + + it('should accept a setup block with either list omitted', () => { + const result = PatchManifestSchema.safeParse({ patches: {}, setup: {} }) + assert.ok(result.success, 'An empty setup block should parse') + assert.deepEqual(result.data.setup, {}) + }) + + it('should reject a malformed setup block', () => { + const result = PatchManifestSchema.safeParse({ + patches: {}, + setup: { exclude: 'packages/legacy' }, + }) + assert.ok(!result.success, 'setup.exclude must be a list') + }) + it('should reject non-object input', () => { assert.ok(!PatchManifestSchema.safeParse(null).success) assert.ok(!PatchManifestSchema.safeParse('string').success) diff --git a/npm/socket-patch/src/schema/manifest-schema.ts b/npm/socket-patch/src/schema/manifest-schema.ts index e11f29361..5572fa677 100644 --- a/npm/socket-patch/src/schema/manifest-schema.ts +++ b/npm/socket-patch/src/schema/manifest-schema.ts @@ -28,11 +28,25 @@ export const PatchRecordSchema = z.object({ export type PatchRecord = z.infer +// Legacy state written by the `setup` command that v5 removed (and by the +// pre-v5 `vex`). The CLI still parses it and keeps it on rewrite, so a +// manifest validated here keeps it too. Mirrors `SetupConfig` in +// crates/socket-patch-core/src/manifest/schema.rs. +export const SetupConfigSchema = z.object({ + // Workspace-member paths the removed `setup` skipped. + exclude: z.array(z.string()).optional(), + // Ecosystems the pre-v5 `vex` attested with no install hook wired. + manual: z.array(z.string()).optional(), +}) + +export type SetupConfig = z.infer + export const PatchManifestSchema = z.object({ patches: z.record( z.string(), // Package PURL like "pkg:npm/simplehttpserver@0.0.6" PatchRecordSchema, ), + setup: SetupConfigSchema.optional(), }) export type PatchManifest = z.infer