From a7b7019c198fa0963bfe171fb5e5963f1f8d0bf3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 11:46:12 +0000 Subject: [PATCH 01/22] Plan staged patch rollout for v5 Adds the design for rolling Socket patches out gradually: a `patches:` block in socket.yml that narrows what scan may patch (paths, ecosystems, packages, severity floor, on/off), and a severity-ordered per-run cap on new patches so each scan lands the next few most critical fixes. The plan splits the work into two parallel items with a frozen interface, lists every hard-coded filter and where it belongs, and covers the depscan autopatch follow-up. configuration.md now records that socket-patch reads socket.yml for selection policy only, with the trust boundary unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/design/configuration.md | 62 ++- docs/design/staged-rollout.md | 810 ++++++++++++++++++++++++++++++++++ docs/design/v5-plan.md | 7 + 3 files changed, 865 insertions(+), 14 deletions(-) create mode 100644 docs/design/staged-rollout.md diff --git a/docs/design/configuration.md b/docs/design/configuration.md index 4bc48634..94a79bb0 100644 --- a/docs/design/configuration.md +++ b/docs/design/configuration.md @@ -1,8 +1,9 @@ # Configuration design: env vars, the socket-cli config file, and what we deliberately don't read -Status: **implemented** (v3.5). This document records the settled design so -future configuration surface grows inside it instead of inventing new -mechanisms. +Status: **implemented** (v3.5); section 4 (`socket.yml` patch policy) is +**planned** for v5.0 (see `staged-rollout.md`). This document records the +settled design so future configuration surface grows inside it instead of +inventing new mechanisms. ## Problem @@ -69,13 +70,46 @@ UX policy and are ignored. - The python `socketsecurity` CLI already accepts `SOCKET_API_TOKEN`, so the canonical names are the cross-tool bridge; no `SOCKET_SECURITY_*` aliases were added. -- `socket.yml` stays a scanning-product surface (projectIgnorePaths / - issueRules / githubApp); socket-patch does not read it. +- `socket.yml` is shared with the scanning product (projectIgnorePaths / + triggerPaths / issueRules / githubApp). As of v5.0 socket-patch reads + exactly two of its keys, `projectIgnorePaths` and a new `patches` block, + and nothing else (section 4). - `SOCKET_PROXY_URL` (the public patch **endpoint**) must never be conflated with socket-cli's `apiProxy` (an HTTP **forward proxy**). Forward-proxy behavior comes from the standard `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY` vars, which reqwest honors. +### 4. `socket.yml` carries patch selection policy, never settings (v5.0) + +Revisits the v3.5 position that socket-patch does not read `socket.yml`. +Staged rollout needs a repo-owned, reviewable place to say which projects, +ecosystems and packages may be patched, a severity floor and a per-run cap +on new patches (`staged-rollout.md`). `socket.yml` is where Socket users +already express repo policy, it lives at the repo root, and a new +top-level `patches:` key is stripped or ignored by every existing parser. + +The trust boundary is unchanged and gains its positive half: + +- A repository file may **narrow or pace** what `scan` patches. It may + never widen it, name an endpoint or credential, choose a mode or download + format, or disable a safety interlock. The parser has no fields for any + of those; such keys are unknown keys and fail validation. +- Because the file only narrows, an invalid file fails closed (exit 1, + `socket_yml_invalid`, nothing written) instead of being treated as + absent. This is the opposite of the socket-cli `config.json` rule above + (corrupt → warn and ignore), and deliberately so: ignoring a broken + user-level login file loses a convenience; ignoring a broken repo policy + widens the rollout. +- Lookup is bounded to the repository (nearest `.git` ancestor of + `--cwd`, else `--cwd`), root files only. +- Flags and env vars still win over the file for scalars (CLI > env > + file > default) and intersect with it for list filters; + `--no-socket-yml` / `SOCKET_NO_SOCKET_YML` ignores the file. +- Only `scan` (every mode) and the in-memory engine honor it. Commands + that report, attest or undo existing state (`list`, `vex`, `rollback`, + `remove`, `repair`, `apply`, `vendor`) ignore it; `get` bypasses it with + a warning. + ## Explicitly rejected | Idea | Why not | @@ -83,21 +117,21 @@ UX policy and are ignored. | Auto-loading `.env` / `.env.local` | Trust boundary: the tool mutates installed packages while holding an API token; a file in a *cloned repo* must never redirect endpoints, disable interlocks, or spend the token. Also the wrong convention class — npm/cargo/pip/git read no `.env`; dotenv is an app-runtime convention. Users who want it have direnv/mise/dotenvx. | | A new socket-patch config file (`.socket/config.toml`, …) | Duplicates socket-cli's persisted config; one more file format to trust, document, and migrate. | | Writing to socket-cli's `config.json` | No login flow here; shared mutable state and format drift for zero benefit. | -| Honoring endpoints/credentials from repo-level files (manifest, socket.yml) | Same trust boundary as `.env`. Stated as a contract property in `CLI_CONTRACT.md`. | +| Honoring endpoints/credentials/interlock switches from repo-level files (manifest, socket.yml) | Same trust boundary as `.env`. Stated as a contract property in `CLI_CONTRACT.md`. Selection policy that only narrows is the one exception (section 4). | +| A `version: 3` socket.yml for the `patches` block | socket-cli rejects any version but 2; older ajv parsers would treat 3 as 2 anyway. The block is additive under `version: 2`. | +| Per-directory `socket.yml` files | No existing consumer supports them; one root file with `includePaths` covers monorepos. | | `SOCKET_CLI_CONFIG` (ephemeral full-JSON config override) | Imports socket-cli's whole config vocabulary as a permanent compat contract. | | Mapping `apiProxy` → anything | Forward-proxy vs patch-endpoint semantic trap; `HTTP_PROXY` et al. already work. | | `enforcedOrgs` / `skipAskToPersistDefaultOrg` | Interactive socket-cli UX policy with no socket-patch analog. | ## Deferred (designated homes, no implementation yet) -- **Project-level behavioral defaults** (`ecosystems`, `downloadMode`, - `vendorSource`): if demand materializes, they go in the manifest `setup` - block (`setup.defaults`, camelCase) — the manifest already controls what - gets patched, so behavioral defaults there grant no new capability, and - the serde struct simply has no fields for URLs/credentials/interlocks. - Requires teaching the TS zod twin - (`npm/socket-patch/src/schema/manifest-schema.ts`) to model `setup`. - Precedence would be flag > env > `setup.defaults` > default. +- **Project-level behavioral defaults** (`downloadMode`, `vendorSource`, + mode): not planned. The v3.5 idea of a manifest `setup.defaults` block is + obsolete in v5 (hosted and vendored projects have no manifest and `setup` + is removed). Selection policy (ecosystems, packages, paths, severity, + per-run cap) went to `socket.yml` `patches` instead (section 4). Anything + that is not pure narrowing stays out of repo files. - **Env cleanup sweep**: core's direct env readers (`SOCKET_OFFLINE` in `utils/env_compat.rs`, `SOCKET_TELEMETRY_DISABLED` in `telemetry.rs`) still match only `1|true`, unlike `parse_bool_flag`'s vocabulary (the CLI diff --git a/docs/design/staged-rollout.md b/docs/design/staged-rollout.md new file mode 100644 index 00000000..88b28511 --- /dev/null +++ b/docs/design/staged-rollout.md @@ -0,0 +1,810 @@ +# Staged patch rollout: `socket.yml` patch policy + `scan --max-new-patches` + +Status: **planned** (v5.0). Target branch `release/v5-prerelease`. +Two work items, built in parallel: **A** (policy file + filters) and +**B** (per-run limit + ordering + reporting). Section 9 specifies both. + +## 1. Goal + +Make it easy to roll Socket patches out gradually: + +1. **Repo policy in `socket.yml`.** Say which projects, ecosystems and + packages socket-patch may patch, and a severity floor. Defaults apply + when the file or the block is absent. The hard-coded repo-path filter + that exists today moves here as an overridable default. +2. **A per-run cap on new patches.** `scan` adds at most N patches to + packages that have none yet, most severe first. Repeated runs converge: + each run lands the next N. + +Non-goals: open-PR accounting (a CLI patching a working tree has no PR +state; that stays in depscan), schedules, release-age cooldowns (security +fixes are exempt from cooldowns in Dependabot and Renovate too), and +per-directory `socket.yml` files. + +## 2. What exists today (research summary) + +### 2.1 `socket.yml` v2 and its consumers + +| Parser | Where | Unknown top-level keys | Wrong type on a known key | +|---|---|---|---| +| P1 `@socketsecurity/config` 3.0.1 (archived; bundled in `socket` 1.x) | `socket-config-js/index.js:30-88` | stripped (ajv `removeAdditional: 'failing'`, `additionalProperties: false` at top level and under `githubApp`) | file rejected | +| P2 depscan copy (GitHub App, fix-PR) | `workspaces/lib/src/config-js/socket-yaml-schema.ts`, `parse-socket-yaml.ts` | stripped (same ajv options) | whole file rejected; PR check goes neutral with "error processing the socket.yml" (`diff-report-runner/index.ts:441-464`) | +| P3 socket-cli 2.x | `src/util/socket-yaml.mts` | ignored (hand-written picker) | that key dropped | +| P4 Coana | not available | unverified | reads `projectIgnorePaths` only | + +- Keys in the wild: `version` (integer; P1/P2 accept any integer, P3 + rejects anything but 2), `projectIgnorePaths`, `triggerPaths`, + `issueRules`, `githubApp.*`. Nothing mentions patches. +- **A new top-level `patches:` key breaks no parser** as long as the file + keeps `version: 2`. P1/P2 strip it, P3 ignores it. None of them will + ever see it; socket-patch is its only reader. +- Lookup: the GitHub App reads only the repo-root `socket.yml` / + `socket.yaml` at the scanned commit, and when both exist `socket.yaml` + wins (git tree order, `get-socket-repo-config.ts:96-120`). socket-cli + walks up from cwd and prefers `socket.yml`. The docs say `socket.yml` + wins. Nobody merges multiple files; there are no per-directory files. +- Glob semantics (backend): the `ignore` npm package, i.e. **gitignore + rules**, case-insensitive (`list-files.ts:476-507`). A leading `/` or a + middle `/` anchors to the repo root, a bare name matches at any depth, a + trailing `/` matches directories only, `!` negates, last match wins, a + child of an excluded directory cannot be re-included. +- An unquoted `**` entry is a YAML error. Case-insensitivity and the + "excluded parent" rule are the two things users trip over. + +### 2.2 socket-patch v5 today + +- Selection per package: `socket_patch_core::api::ranking` + (`ranking.rs:85`): merged patches (>= 2 advisories) newest first, then + severity, then publish date, then tier/uuid. `RankKey.severity` is forced + to 0 for merged patches, so it is **not** the patch's real severity. + `severity_order` (`ranking.rs:41`) and `max_severity_order` are. +- Per-patch data: severity (max, uppercase), advisory ids, tier, optional + `publishedAt` (batch endpoint usually lacks it). No CVSS, EPSS, KEV, + reachability or direct/transitive information anywhere. +- Scan selects patches in five disk call sites plus one in the in-memory + engine: `discover_selected` (`scan/mod.rs:553`, called from `mod.rs:1999`, + `hosted.rs:1063`, `vendor_flow.rs:459`, `mod.rs:2349`), the human + agent/vendored arm (`mod.rs:2386-2402` via `get.rs:1097`), and + `hosted_memory/discover.rs:286` (`select_top_ranked`, called at + `hosted_memory/mod.rs:537`). +- Existing filters: `--ecosystems`, `--package` (`package_spec_matches`, + `mod.rs:383`), PATH globs (`path_scope.rs`), all applied after the prune + universe is captured (`mod.rs:1480`). +- Recorded state: `merge_ledger_records_for_updates` (`discovery.rs:412`, + manifest > hosted lockfile pins > vendor ledger) and `detect_updates` + (`discovery.rs:452`) with `batch_supersedes` (`ranking.rs:157`). The + in-memory engine has **no** hosted-pin discovery. +- `docs/design/configuration.md` said socket-patch never reads + `socket.yml`. This plan reverses that (section 3); the doc is updated in + the same PR. + +### 2.3 Hard-coded filtering inventory + +socket-patch (paths relative to `crates/`): + +| # | Location | What | Verdict | +|---|---|---|---| +| H1 | `socket-patch-cli/src/hosted_memory/roots.rs:56-67` `EXCLUDED_ROOT_SEGMENTS` | the in-memory engine never detects a project root under `node_modules .git .socket .yarn vendor test tests fixtures __fixtures__ testdata` | **Move** `test tests fixtures __fixtures__ testdata` to the overridable default `ignorePaths` (section 4.3), applied on disk too. Keep `node_modules .git .socket .yarn vendor` structural. | +| H2 | `socket-patch-core/src/crawlers/npm_crawler.rs:19-27` `SKIP_DIRS` (dist build coverage tmp temp `__pycache__` vendor) | npm workspace walk looking for nested `node_modules` | Stays: crawler heuristic, not selection policy | +| H3 | `socket-patch-cli/src/hosted_memory/select.rs:46,53-70` | cargo `target/` and `cargo vendor` output skipped | Stays: correctness | +| H4 | `socket-patch-cli/src/hosted_memory/roots.rs:40-53` | maven/nuget unsupported in memory | Stays: capability | +| H5 | `socket-patch-cli/src/hosted_memory/mod.rs:274` `ecosystem_allowed` | `options.ecosystems` | Stays: the in-memory `--ecosystems`; intersects with the file | +| H6 | `crawlers/python_crawler.rs:288`, dot-dir skips in `cargo_crawler.rs:373`, `go_crawler.rs:344`, `nuget_crawler.rs:236`, `ruby_crawler.rs:576` | discovery locations | Stays: crawler heuristics | +| H7 | `ruby_crawler.rs:823` BUNDLE_PATH containment | refuse config roots outside the project | Stays: **safety** | +| H8 | `scan/mod.rs:596-603`, `get.rs:1101-1107` tier filter | paid patches for free orgs | Stays: entitlement | +| H9 | `scan/mod.rs:723-762` agent partition | vendored / not-installed skips | Stays: ownership safety | +| H10 | `scan/hosted.rs:1256-1296` non-granted references | not_found, forbidden, pending_build, build_failed, withdrawn | Stays: server truth | +| H11 | `hosted.rs:1352-1402`, `hosted.rs:1448`, core rewriter refusals, vendor revert allowlists, `vlt_lock_text.rs:311` | write safety, format gates, ledger-poisoning guards | Stays: **safety** | + +No package-name, uuid or repo denylists exist anywhere in socket-patch. + +depscan (`feat/socket-patch-cli-autopatch`, PR #26860; `workspaces/app/src/autopatch-pr/cli/` unless noted): + +| # | Location | What | Verdict | +|---|---|---|---| +| D1 | `run-job.ts:94-102,293`; `next-app/.../socket-patch-cli/enqueue.ts:104` | per-org `socketPatchCliAutopatch` flag | Server (kill switch) | +| D2 | `provider/create-patch-provider.ts:189-279` | `enablePatchesAccess`, paid entitlement | Server (entitlement) | +| D3 | `lib/src/socket-patch/autopatch-job.ts:152-169` | per-job admin `config.ecosystems` allowlist, `batchSize` (lookup batch, not a patch cap) | Server; ecosystems **intersect** with `patches.ecosystems` | +| D4 | `repo-files.ts:290-513` | tree/path/size/depth caps, unsafe path drops | Server (safety) | +| D5 | `pull-request-rules.ts:473-491` | fork/default/protected heads are check-only | Server (safety) | +| D6 | `next-app/src/lib/admin/autopatch/socket-patch-cli-branches.ts:10-31` | branch-name guards | Server | +| D7 | legacy `patch-pr-worker.ts:65-109`, `github-patch-pr.ts:262-283,1598-1740`, `github-patch-pr-hosted.ts:196-486`, `compute-full-patch-set.ts:41-273` | already-applied, not-in-SBOM, unpublished, deprecated, vlt gates | Server (correctness); not policy | + +Nothing in depscan filters by repo path, package or severity, and nothing +caps patches per PR. The only repo-path policy in the whole system is H1, +and it lives in the engine. It is the one hard-coded filter that moves. + +### 2.4 Prior art (vocabulary borrowed, complexity not) + +| Tool | Limit | Counts | Order when capped | +|---|---|---|---| +| Dependabot | `open-pull-requests-limit` (5; security updates exempt) | open PRs | undocumented; shuffled | +| Renovate | `prConcurrentLimit`, `prHourlyLimit` (security fixes bypass) | open PRs / new per hour | vulnerability, `prPriority`, update type, title | +| Snyk | 5 open upgrade PRs; backlog "one PR a day, top vulnerability" | open PRs / per day | priority score | +| GitLab auto-remediation | 10 open MRs, "three vulnerabilities at a time, highest severity first", `high` threshold | open MRs + per run | severity | +| OSV-Scanner | `--apply-top=N`, `--min-severity` | per run | fixed | + +Borrowed: gitignore paths (`projectIgnorePaths`), `include`/`ignore` +pairs, `enabled`, a severity floor spelled as a minimum (`--min-severity`, +Snyk/GitLab/OSV), a per-run new-item cap (GitLab/OSV/Snyk backlog), a +fixed total order (not Dependabot's shuffle), deny-wins. + +## 3. Trust boundary (decision) + +The rule in `CLI_CONTRACT.md` ("Repo-level files never carry endpoints, +credentials, or interlock-disablers") stays and gains its positive half: + +> A repository file may **narrow or pace** what `scan` patches. It may +> never widen it, name an endpoint or credential, pick a mode, or turn off +> a safety check. + +Every `patches:` key only removes candidates (`enabled`, `includePaths`, +`ignorePaths`, `ecosystems`, `packages`, `ignorePackages`, `minSeverity`) +or delays them (`maxNewPatches`). No key can add a package, bypass the tier +filter, the agent partition, reference grants, containment checks or any +refusal in H7-H11. The parser has no fields for URLs, tokens, org, mode, +download mode or any `--no-*` safety switch; such keys are unknown keys and +fail validation (4.4). + +Failure direction follows from that: because the file only narrows, an +unreadable or invalid policy must not mean "no policy". It fails closed. + +## 4. `socket.yml` grammar (work item A) + +### 4.1 Keys + +```yaml +version: 2 # required for socket-patch to honor `patches` +projectIgnorePaths: # existing scanner key; socket-patch honors it too + - "crates/*/tests/fixtures/**" +patches: # new; every key optional + enabled: true # bool. Default true. false = report only. + includePaths: ["/services/payments/"] # gitignore list. Absent = every project. + ignorePaths: ["/legacy/"] # gitignore list, added after the defaults. Default []. + ecosystems: [npm, pypi] # allowlist of --ecosystems names. Absent = all. + packages: ["lodash"] # allowlist of --package specs. Absent = all. + ignorePackages: ["pkg:npm/left-pad"] # denylist of --package specs. Default []. + minSeverity: high # critical|high|medium|moderate|low. Absent = no floor. + maxNewPatches: 5 # integer >= 0. Absent = unlimited. 0 = upgrades only. +``` + +- camelCase, like every existing socket.yml key. +- Ecosystem names are `Ecosystem::cli_name()`: `npm pypi cargo gem golang + maven composer nuget deno`, case-insensitive. +- Package specs use exactly the `--package` grammar and matcher + (`package_spec_matches`): a name (full or last segment, + case-insensitive) or a purl with or without a version; qualifiers + ignored. +- `moderate` is an alias of `medium`, as in `severity_order`. +- Deny wins: `ignorePackages` beats `packages`, ignore paths beat + `includePaths`. + +### 4.2 Precedence against flags and env + +| Setting | Rule | +|---|---| +| List filters (`includePaths`/`ignorePaths`/`projectIgnorePaths` vs PATH args; `ecosystems` vs `--ecosystems`; `packages`/`ignorePackages` vs `--package`) | **intersect**: flags narrow further, never widen | +| `minSeverity` | `--min-severity ` > `SOCKET_MIN_SEVERITY` > file > no floor | +| `maxNewPatches` | `--max-new-patches ` > `SOCKET_MAX_NEW_PATCHES` > file > unlimited | +| whole file | `--no-socket-yml` / `SOCKET_NO_SOCKET_YML` skips the file (built-in default path ignores still apply) | + +Scalars follow the contract's CLI > env > default order, with the file as +the layer above the default. The person running the CLI is trusted; the +file is the repo's default. (depscan adds its own server ceiling, section +7.) Every new flag has an env binding, as the contract requires. + +### 4.3 Paths + +- **Subject.** Path rules decide which *project roots* are patched: the + directory holding the lockfile/manifest, relative to the repo root, with + `/` separators, tested as a directory. The rule is the same in every + mode, including agent mode (its project is `--cwd`). +- **Semantics.** gitignore, identical to the backend's `ignore` package: + Rust `ignore::gitignore::GitignoreBuilder` with `case_insensitive(true)`, + anchored at the repo root, `matched_path_or_any_parents` so a directory + pattern covers everything under it. +- **Repo-root project.** gitignore cannot match the empty path, so in + `patches.includePaths` / `patches.ignorePaths` the literal entry `/` + (and `!/`) means "the repository-root project". It has no meaning in + `projectIgnorePaths`, which stays scanner semantics. +- **Evaluation order** (last match wins): + 1. built-in defaults: `test/ tests/ fixtures/ __fixtures__/ testdata/` + 2. `projectIgnorePaths` + 3. `patches.ignorePaths` + + A user re-includes a default with a negation, e.g. + `ignorePaths: ["!/e2e/tests/"]`. Adding an unrelated ignore never + silently re-enables fixtures. +- **Admission.** A root is admitted iff it is not ignored by the list + above AND (`includePaths` is absent OR `includePaths` matches it). +- **Defaults apply to discovered roots only.** Built-in defaults (step 1) + prune roots the tool discovers (in-memory root detection; disk PATH-glob + expansion). A directory the user names explicitly (`--cwd`, a literal + PATH) is exempt from step 1 but not from steps 2-3 or `includePaths`. +- **Structural excludes** (`node_modules .git .socket .yarn vendor`) stay + hard-coded and cannot be negated. +- **Granularity.** A workspace member that shares the root lockfile is part + of the root project; exclude it with `ignorePackages`, not paths. + Documented. +- A root excluded by the policy is reported as filtered (4.6) with the + pattern that decided it and the list it came from. + +### 4.4 Validation (fail closed) + +socket-patch validates only what it reads: `version`, `projectIgnorePaths` +and `patches`. Other top-level keys are never inspected. + +| Situation | Behavior | +|---|---| +| No file | Defaults. | +| YAML syntax error, duplicate key, top level not a mapping, file over 64 KiB, symlink resolving outside the repo root | **Error** `socket_yml_invalid` | +| `patches` present and `version` is not `2` (integer 2 or the string `"2"`, matching ajv coercion), including a missing `version` | **Error** `socket_yml_invalid` ("patches requires version: 2") | +| No `patches` and `version` is not 2 | File ignored (a v1 or future file is not ours to judge); warning `socket_yml_unsupported_version` | +| Unknown key under `patches` | **Error**, with a did-you-mean hint when one key is within edit distance 2 | +| Wrong type (no coercion: `"false"` is not a bool; YAML 1.2 so `no` is a string), unknown ecosystem or severity, `maxNewPatches` negative or non-integer, invalid glob or package spec, `projectIgnorePaths` not a list of strings | **Error** naming the key path (`patches.minSeverity`) and the file | +| Top-level key equal to `patch`/`patches` ignoring case but not exactly `patches` (`Patches:`, `PATCH:`, `patch:`) | **Error**: a misspelled block must not silently mean "no policy" | +| Both `socket.yml` and `socket.yaml` at the root | Parse both. If the parts socket-patch reads (`projectIgnorePaths`, `patches`) are equal, use them; otherwise **error** `socket_yml_ambiguous` naming both. The existing consumers disagree on which file wins, so we refuse to pick. | + +**Error behavior:** before any write, `scan` exits **1** with +`errorCode: socket_yml_invalid` (or `socket_yml_ambiguous`), a human +message naming file, key path and remedy (fix the file, or +`--no-socket-yml`), `--json` stdout still a valid envelope. Exit 1, not 2: +it is a bad input file, like an invalid manifest, not a usage error. + +**Forward compatibility.** A strict parser means an older pinned CLI fails +on a key a newer CLI understands. That is deliberate (the alternative is a +silently wider rollout); the error text says "unknown key … (a newer +socket-patch may support it; upgrade or remove it)". The contract documents +that keys are only ever added in minor releases and never change meaning. + +### 4.5 Lookup + +1. Repo root := the nearest ancestor of `--cwd` (inclusive) containing + `.git` (a directory, or a file for worktrees and submodules). With no + `.git` ancestor, repo root := `--cwd` (never the home directory or + filesystem root; socket-cli's unbounded walk could pick up an untrusted + `/tmp/socket.yml`). +2. Read `/socket.yml` and `/socket.yaml` only. + Nested `socket.yml` files are not read. One file per repo, as in the + GitHub App. +3. The in-memory engine's repo root is the tree root it was given. +4. `--global` / `--global-prefix` scans have no repo and ignore the file. + +### 4.6 Commands + +| Command | Policy | +|---|---| +| `scan` (hosted, vendored, agent; wet and `--dry-run`), `hosted-bundle`, the napi engine | honor filters and limit | +| `get` | explicit intent: ignores filters and limit; warns `policy_bypassed` when the target would have been filtered | +| `apply`, `list`, `vex`, `rollback`, `remove`, `repair`, `vendor` (eject/revert) | ignore it: they report, attest or undo existing state | + +**Narrowing never removes.** The policy runs after the prune universe is +captured (`mod.rs:1480`), so `--prune` still judges the full crawl. A +package that already has a recorded patch but is now excluded by paths, +ecosystems, packages or `enabled: false` is **retained**: not passed to the +hosted rewriters, vendor engine or agent apply; not upgraded; not taken +over; left byte-identical. It is reported under `policy.retained[]` with +`upgradeAvailable`. Removing a patch is only ever `rollback`/`remove`, or +the dependency leaving the lockfile. + +`minSeverity` filters **candidates** before per-package ranking (so a +lower-ranked patch above the floor can still win), using the patch's real +severity (`severity_order` on `BatchPatchInfo.severity`, or +`max_severity_order` over `vulnerabilities`), never `RankKey.severity`. +With a floor set, a patch with unknown severity is filtered (fail closed). +A recorded patch below the floor stays in place; it is replaced only when a +candidate above the floor supersedes it under the existing +`batch_supersedes` rule, which is an ordinary upgrade. + +`enabled: false`: discovery and the table still run; nothing is written; +every candidate is reported filtered with `policy_disabled`; warning +`patches_disabled`; exit 0. Upgrades are frozen too. + +### 4.7 JSON (`policy` block, owned by A) + +Additive top-level key on every `scan --json` result (MINOR), always +present: + +```json +"policy": { + "source": "file", // "none" | "file" | "bypassed" + "path": "socket.yml", // repo-relative; null unless source=file + "sha256": "…", // of the file bytes; null unless source=file + "enabled": true, + "minSeverity": {"value": "high", "source": "file"}, // value null = no floor; source flag|env|file|default + "counts": {"filtered": 3, "retained": 1}, + "filtered": [ + {"purl": "pkg:npm/qs@6.5.2", "uuid": "…", "project": "services/legacy", + "reason": "policy_path_excluded", "detail": "/legacy/ (patches.ignorePaths)"} + ], + "retained": [ + {"purl": "pkg:npm/lodash@4.17.20", "project": ".", "recordedUuid": "…", + "reason": "policy_package_ignored", "upgradeAvailable": true} + ] +} +``` + +- `uuid` is null when the package was filtered before any patch was looked + up (path, ecosystem, package reasons). +- Reason codes (stable): `policy_disabled`, `policy_path_excluded`, + `policy_path_not_included`, `policy_ecosystem`, + `policy_package_not_listed`, `policy_package_ignored`, `policy_severity` + (detail `unknown < high` or `medium < high`). +- A root filtered as a whole is one entry with `purl: null`. +- Human output: one line, e.g. + `Policy (socket.yml): 3 skipped by filters, 1 patched package held.` + and `--verbose` lists them. + +## 5. Per-run limit (work item B) + +### 5.1 Classification + +After filtering and per-package selection, each selected `(project root, +purl, uuid)` row is classified against that project's recorded view +(`merge_ledger_records_for_updates`: manifest > hosted lockfile pins > +vendor ledger), with `detect_updates`' qualifier-twin handling: + +| Class | Rule | Counts toward the cap | +|---|---|---| +| ALREADY | recorded uuid == selected uuid, or recorded uuid kept because the selection does not supersede it (`batch_supersedes`) | no; hosted re-confirms it idempotently as today | +| UPGRADE | recorded uuid differs and the selection supersedes it (existing `detect_updates` rule, including "recorded patch no longer offered") | no | +| NEW | no patch recorded for this base purl **in this project root** | **yes** | + +- NEW is per project root. Widening `includePaths` from a pilot directory + to more services makes piloted packages NEW in the added roots, and they + go through the cap again. A patch already in another project is not a + free pass. +- UPGRADEs are exempt (decision): rollout risk is about whether a package + runs patched code at all, and an upgrade fixes more in a package that is + already patched. Capping upgrades would leave known-superseded patches in + place. To freeze everything, use `enabled: false`; `maxNewPatches: 0` + freezes new packages only. + +### 5.2 Budget and ordering + +- **Unit:** a distinct **base purl** (ecosystem + name + version, + qualifiers stripped) among NEW rows, run-wide: across every project root + of one invocation (disk multi-directory human runs, every root of the + in-memory engine). Admitting a base purl admits all of its NEW rows in + every root. One package patched in ten roots costs 1. +- **Order** (ascending; a total order with no time-dependent keys): + 1. in-flight first (in-memory option `inFlightPatches` only, 7.2; + absent on the CLI) + 2. real severity of the selected patch (`severity_order`: critical, + high, medium, low, unknown) + 3. advisory count, descending (merged patches first within a severity) + 4. ecosystem `cli_name`, ascending + 5. canonical base purl, ascending bytewise (one shared core + normalization function, used by disk and memory) + 6. uuid, ascending + + A base purl present in several roots uses the minimum key of its rows. + `publishedAt` is deliberately **not** a key: the batch endpoint omits it, + so using it would reorder the top N between runs and between the disk + and memory engines. Per-package ranking (which patch a package gets) + still uses `publishedAt` as today; this order only decides which + packages go first. +- **Eligibility before budget.** A row consumes budget only if it can land + this run: it passed the tier filter, the agent partition (vendored / + not installed), the vendored preflight and, in hosted mode, its reference + grant came back `granted`. Rows that cannot land (withdrawn, + build_failed, pending_build, not_found, forbidden, refused) keep their + existing skip reasons and do not hold a slot, so a permanently broken + patch can never stall the rollout. Implementations may fetch references + for every NEW candidate, or in rank-ordered batches until the budget is + full; the resulting plan must be identical. +- **Write failures** after admission consume budget (the run stays bounded; + no backfill within a run). They are reported as failures, as today. +- **`maxNewPatches: 0`** admits no NEW rows; ALREADY and UPGRADE proceed. +- Everything NEW beyond the budget is **deferred**: not written, not + downloaded, not vendored, reported with its rank. + +### 5.3 Convergence and determinism + +- Same inputs, same plan, same bytes. The limit is stateless: run k lands + the top N; on run k+1 they are ALREADY and the next N land. M waiting + patches take ceil(M/N) committed runs. +- A newly published or re-scored higher-severity patch moves ahead of the + queue. That is intended ("most critical first") and is visible because + every deferred entry carries its rank and severity. +- Low-severity patches can wait indefinitely while higher ones keep + arriving. Documented; it is the point of severity ordering. +- **CI that does not commit** the scan's result never advances recorded + state, so a cap there means "only the top N, every run". Documented in + the recipes: commit the lockfile changes (or use a PR bot), or do not set + a cap in non-committing jobs. +- `pending_build` references are transient: a row can be ineligible one + run and eligible the next. The plan is still a function of the inputs. + +### 5.4 Modes + +| Mode | Recorded state | NEW/ALREADY/UPGRADE source | Deferred rows | +|---|---|---|---| +| hosted (disk) | lockfile hosted pins (`HostedPin`) | recorded view | never granted, never rewritten; mirrored into `redirect.skipped[]` with reason `rollout_deferred` | +| vendored | `.socket/vendor/state.json` | ALREADY = `already_vendored`, UPGRADE = `would_revendor` | never downloaded or vendored | +| agent | `.socket/manifest.json` | ALREADY = `skipped`, UPGRADE = `updated` | never downloaded; not in `apply.patches[]` | +| in-memory (napi, hosted-bundle) | hosted pins discovered from the in-memory lockfiles (**new**, B) | same | in `ProjectResult.deferred[]` and `skipped[]` with `rollout_deferred` | + +`--dry-run` makes exactly the same decisions and reports them the same way. +A takeover of an existing vendored or hosted entry counts as recorded, not +NEW. + +### 5.5 JSON (`rollout` block, owned by B) + +Additive top-level key on every `scan --json` result (MINOR), always +present: + +```json +"rollout": { + "maxNewPatches": {"value": 5, "source": "file"}, // value null = unlimited; source flag|env|file|default|cap + "counts": {"new": 5, "deferred": 9, "upgrade": 1, "already": 12}, + "deferred": [ + {"purl": "pkg:npm/minimist@1.2.5", "uuid": "…", "severity": "critical", + "advisoryCount": 1, "projects": ["services/api", "services/web"], "rank": 6} + ] +} +``` + +- `counts.new` is the number of NEW base purls admitted this run + (landed, or would land under `--dry-run`); `deferred` lists the rest in + rank order; `rank` is 1-based across all NEW candidates. +- Human output (hosted/vendored/agent summary, then the Next-steps + renderer): + + ``` + Rollout: 5 of 14 new patches applied (maxNewPatches=5 from socket.yml); 1 upgrade, 12 already applied. + Next steps: + 9 new patches deferred; commit these changes and run scan again to apply the next 5. + Next up: minimist@1.2.5 (critical), qs@6.5.2 (high), … + ``` +- Exit code unchanged (0) when patches are deferred or filtered. +- `jq` recipe for CI: `jq '.rollout.counts.deferred'`. + +## 6. Rollout recipes + +```yaml +# R1 Canary: one new patch per run +version: 2 +patches: + maxNewPatches: 1 +``` + +```yaml +# R2 Critical first: critical only, then widen by editing one line +version: 2 +patches: + minSeverity: critical # later: high, then remove + maxNewPatches: 5 +``` + +```yaml +# R3 One directory first (monorepo) +version: 2 +patches: + includePaths: + - "/services/payments/" + # add "/services/checkout/" next sprint + maxNewPatches: 5 +``` + +```yaml +# R4 One ecosystem, hold one package +version: 2 +patches: + ecosystems: [npm] + ignorePackages: ["pkg:npm/left-pad"] +``` + +```yaml +# R5 Weekly drip with the depscan autopatch PR +version: 2 +patches: + maxNewPatches: 5 # the PR keeps the same 5 until merged, then the next 5 +``` + +```yaml +# R6 Pause +version: 2 +patches: + enabled: false # report only; existing patches stay in place +# or keep upgrades flowing but add nothing new: +# maxNewPatches: 0 +``` + +One-off overrides from the command line: `socket-patch scan +--max-new-patches none` (drain the queue this run), `--min-severity none`, +`--no-socket-yml` (ignore the file entirely). + +## 7. depscan autopatch service + +### 7.1 Behavior with the new engine + +- `repo` jobs rebuild one commit from the base SHA each run. With + `maxNewPatches: 5`, "recorded" means pinned on the **base** branch, so + every rebuild proposes the same top 5 until the PR merges, then the next + 5. No churn, no new PR per batch. +- `pull_request` jobs honor the filters but pass `maxNewPatches: "none"`: + deferring there would leave the check permanently showing work. +- socket.yml is read from the same commit as the tree (base SHA for `repo` + jobs, head SHA for `pull_request` jobs), through the engine: the file is + one of the paths the engine asks for, so there is no second parser. +- Effective limit = min(repo value or override, server cap). The server + can tighten, never loosen. Org-level kill switches, entitlement and + safety (D1-D6) always win. + +### 7.2 Engine API changes (napi `HostedScanOptions` / result, and the `hosted-bundle` harness) + +| Owner | Change | +|---|---| +| A | `selectHostedScanPaths` returns root `socket.yml` / `socket.yaml` when present in the tree listing (one phase: the file is small and root-only; roots the policy excludes are simply not processed) | +| A | options `noSocketYml?: boolean`, `minSeverity?: "critical"\|"high"\|"medium"\|"low"\|"none"` | +| A | result: session-level `policy` block (4.7) and `policyError?: {code, detail}`; on error no project is processed and no files change; `skipped[].reason` gains the `policy_*` codes | +| B | options `maxNewPatches?: number \| "none"`, `maxNewPatchesCap?: number`, `inFlightPatches?: string[]` (uuids already in the open PR; ranked first so a reviewed patch is not displaced by a newly published one mid-review) | +| B | result: session-level `rollout` block (5.5); `ProjectResult.deferred[]`; `skipped[]` rows with `rollout_deferred` | +| B | hosted-pin discovery over the in-memory lockfiles (the memory twin of `HostedPin::all(discover_wiring(..))`), so NEW/ALREADY/UPGRADE work in memory. A finite cap must never ship in the engine without it: every merged pin would look NEW and the rollout would stall at N. | +| B | restructure the per-root loop at `hosted_memory/mod.rs:537` into collect all roots → plan once → apply, so the budget is run-wide | + +`hosted-bundle` rejects unknown fields, so each owner adds its fields there +too. + +### 7.3 depscan follow-up (after A and B merge; separate PR in depscan) + +1. Bump the socket-patch submodule and rebuild the addon. +2. Pass `inFlightPatches` (uuids in the open patch-all PR) and, for + `pull_request` jobs, `maxNewPatches: "none"`. +3. New job outcome `policy_invalid` (from `policyError`): leave the + existing PR untouched, surface the error on the admin page and in the + job's check-run text. +4. Render a "Deferred (next batch)" table and severity/rank columns in the + PR body; add `patchesDeferred` to stats. +5. Optional server cap per org (future org setting), passed as + `maxNewPatchesCap`; intersect the admin `config.ecosystems` (D3) with + the file by passing it as `ecosystems` as today. +6. Do **not** add `patches` to the ajv schema in + `socket-yaml-schema.ts` with strict types: a typo would reject the whole + file and turn PR checks neutral. If documentation value is wanted, add + it as a permissive `{type: object}`. +7. Docs repo: add a `patches` section to the socket.yml page, and fix the + two stale statements found in research (which file wins when both + exist; v1 files are rejected by the GitHub App). + +A closed/rejected rolling PR re-proposes the same patches next run; +document `ignorePackages` as the way to decline one. + +## 8. Decisions log + +| # | Decision | Why | +|---|---|---| +| 1 | Top-level `patches:` in socket.yml v2; no `version: 3` | breaks no parser (P1/P2 strip, P3 ignores); P3 rejects any version but 2 | +| 2 | socket-patch reads socket.yml (reverses configuration.md) | owner request; policy that only narrows fits the trust boundary | +| 3 | Keys `enabled includePaths ignorePaths ecosystems packages ignorePackages minSeverity maxNewPatches` | include/ignore pairs mirror existing keys; `packages` allowlist covers single-package pilots; `maxNewPatches` says it counts new patches only | +| 4 | gitignore semantics via the `ignore` crate, case-insensitive, anchored at repo root | identical to `projectIgnorePaths` in the backend | +| 5 | socket-patch also honors `projectIgnorePaths` | users expect one ignore list; every other consumer already honors it | +| 6 | Defaults `test/ tests/ fixtures/ __fixtures__/ testdata/` evaluated first, overridden by `!`; discovered roots only | moves H1; replace-on-set would re-enable fixtures when someone adds one unrelated pattern | +| 7 | Strict validation, fail closed, exit 1 `socket_yml_invalid` | a broken narrowing rule must not widen the rollout | +| 8 | Both files: error only if the parts we read differ | existing consumers disagree on precedence; repos that already have both keep working | +| 9 | Repo root = nearest `.git` ancestor, else `--cwd`; root files only | matches the GitHub App; memory engine can mirror it; never reads outside the checkout | +| 10 | Flags intersect lists; scalars CLI > env > file > default; `--no-socket-yml` with env | contract precedence and "every flag has an env var" | +| 11 | `maxNewPatches: 0` = upgrades only; absent / `none` = unlimited | literal meaning; avoids the Dependabot/Renovate 0 disagreement | +| 12 | Unknown severity is filtered when a floor is set | fail closed | +| 13 | NEW per (project root, base purl); budget per base purl run-wide | a widened pilot re-enters the cap; one package in many roots costs 1 | +| 14 | Upgrades exempt from the cap | rollout risk is per package; keeps patched packages current | +| 15 | Order: severity, advisory count, ecosystem, base purl, uuid; no `publishedAt` | total and time-independent; batch lacks the date | +| 16 | Budget after eligibility (grants, partition, preflight) | a withdrawn/broken patch never holds a slot | +| 17 | Filtered packages with recorded patches are retained, never removed or upgraded | narrowing freezes, never removes | +| 18 | `get` bypasses the policy with a warning | explicit intent | +| 19 | Separate `policy` (A) and `rollout` (B) JSON blocks | clean ownership seam; both additive | +| 20 | Everything ships in 5.0 | honoring `projectIgnorePaths`, disk default ignores and fail-closed file errors change scan's default behavior (MAJOR) | + +## 9. Work items + +Both items branch from `release/v5-prerelease` (suggested branches +`v5/rollout-policy` for A, `v5/rollout-limit` for B). **Merge order: A, +then B.** +B rebases onto A and owns the final integration (section 9.3). Neither +item depends on the other's types: the only exchanged values are plain +`Option` / `Option` and the pipeline order below. + +### 9.0 Shared contract (frozen by this plan) + +Scan pipeline, in order (disk and memory): + +1. load policy (A) — fail closed before any write +2. crawl; capture the prune universe (unchanged) +3. root filter, ecosystem/package filter, retained set (A) +4. batch API (unchanged) +5. candidate severity filter (A) +6. per-package ranking (unchanged `ranking`) +7. classify NEW/ALREADY/UPGRADE, eligibility, budget, deferral (B) +8. writers (unchanged; receive only admitted rows) + +```rust +// crates/socket-patch-core/src/policy/mod.rs — OWNER A +pub struct SelectionPolicy { /* private fields */ } +pub enum PolicySource { None, File { path: String, sha256: String }, Bypassed } +pub enum FilterReason { + Disabled, PathExcluded { pattern: String, list: &'static str }, PathNotIncluded, + Ecosystem, PackageNotListed, PackageIgnored { spec: String }, + Severity { found: Option, floor: String }, +} +impl FilterReason { pub fn code(&self) -> &'static str; pub fn detail(&self) -> String; } +pub enum PolicyError { Invalid { file: String, key: String, message: String }, Ambiguous { files: [String; 2] } } +impl PolicyError { pub fn code(&self) -> &'static str; } // socket_yml_invalid | socket_yml_ambiguous +pub trait PolicyFs { fn read_root_file(&self, name: &str, cap: usize) -> std::io::Result>>; } +pub struct PolicyOverrides { pub bypass: bool, pub min_severity: Option> } // Some(None) = "none" +impl SelectionPolicy { + pub fn unrestricted() -> Self; // defaults (built-in path ignores only) + pub fn load(fs: &dyn PolicyFs, o: &PolicyOverrides) -> Result; + pub fn source(&self) -> &PolicySource; + pub fn enabled(&self) -> bool; + pub fn admits_root(&self, rel_dir: &str, explicit: bool) -> Result<(), FilterReason>; + pub fn admits_purl(&self, purl: &str) -> Result<(), FilterReason>; // ecosystem + packages + pub fn admits_severity(&self, severity_order: u8) -> Result<(), FilterReason>; + pub fn max_new_patches(&self) -> Option; // the FILE value only; B resolves precedence +} + +// crates/socket-patch-core/src/rollout.rs — OWNER B +pub enum Recorded { None, Same, Kept { uuid: String }, Superseded { old_uuid: String } } +pub struct Candidate { + pub project: String, pub purl: String, pub base_purl: String, pub uuid: String, + pub ecosystem: &'static str, pub severity_order: u8, pub advisory_count: usize, + pub recorded: Recorded, pub eligible: bool, pub in_flight: bool, +} +pub enum MaxNewSource { Flag, Env, File, Default, Cap } +pub struct MaxNew { pub value: Option, pub source: MaxNewSource } +pub fn resolve_max_new(flag: Option>, env: Option>, + file: Option, cap: Option) -> MaxNew; +pub fn canonical_base_purl(purl: &str) -> String; +pub fn rollout_cmp(a: &Candidate, b: &Candidate) -> std::cmp::Ordering; +pub struct RolloutPlan { pub admitted: Vec, pub deferred: Vec<(Candidate, u32)>, pub counts: RolloutCounts } +pub fn plan_rollout(candidates: Vec, max_new: &MaxNew) -> RolloutPlan; // pure +``` + +Rules both items follow: +- Severity input is always the patch's real severity (`severity_order` / + `max_severity_order`), never `RankKey.severity`. +- Skip-reason strings are the stable codes in 4.7 and 5.5. +- JSON: A owns the top-level `policy` block; B owns the top-level + `rollout` block. Neither edits the other's. +- CLI args: A adds a `#[command(flatten)]` `SocketYmlArgs` (`--no-socket-yml`, + `--min-severity`) in `scan/socket_yml_args.rs`; B adds a flattened + `RolloutArgs` (`--max-new-patches`) in `scan/rollout_args.rs`. Both + derive `Default`; each adds its field to the ~18 `ScanArgs` struct + literals. The resulting adjacent-line conflicts are resolved by B on + rebase. + +### 9.1 Work item A — socket.yml loading and filtering + +Scope: +- `crates/socket-patch-core/src/policy/{mod.rs, socket_yml.rs, paths.rs}`; + `pub mod policy;` in `crates/socket-patch-core/src/lib.rs`. +- Dependencies, exact-pinned in `Cargo.toml`: a maintained YAML 1.2 serde + crate that reports duplicate keys (e.g. `serde_norway`; verify + duplicate-key rejection with a test, reject it otherwise), and `ignore` + (gitignore matcher). No other new deps. +- Loader: lookup (4.5), size and symlink confinement, both-files rule, + strict validation with key paths and did-you-mean (4.4), `PolicyFs` for + disk and for the in-memory engine. +- Filters, wired at the pipeline points in 9.0: + - disk: root filter in `project_dirs` / `run_project_dirs` + (`scan/mod.rs:1268-1320`) and the agent project; `admits_purl` next to + `--package` (`scan/mod.rs:1490-1511`); severity filter on batch + candidates after `scan/mod.rs:1801` and on by-package candidates before + `select_patches` in the human arm; retained set computed from the + recorded view and excluded from writers. + - memory: root filter in `hosted_memory/roots.rs` root detection; + `admits_purl` at `hosted_memory/mod.rs:428-432`; severity filter before + `select_top_ranked`. +- Move `test tests fixtures __fixtures__ testdata` out of + `EXCLUDED_ROOT_SEGMENTS` (`hosted_memory/roots.rs:56-67`) into the + built-in default ignores, and apply them to disk PATH-glob expansion. +- `enabled: false` report-only path; `get`'s `policy_bypassed` warning; + `--global` ignores the file. +- Flags: `--no-socket-yml`/`SOCKET_NO_SOCKET_YML`, + `--min-severity`/`SOCKET_MIN_SEVERITY` (`SocketYmlArgs`). +- napi + hosted-bundle: `selectHostedScanPaths` includes root + `socket.yml`/`socket.yaml`; options `noSocketYml`, `minSeverity`; result + `policy` and `policyError`; `npm/index.d.ts` types. +- JSON `policy` block (4.7), human policy line, error envelopes for + `socket_yml_invalid` / `socket_yml_ambiguous`, warnings + `socket_yml_unsupported_version`, `patches_disabled`, `policy_bypassed`. + +Tests: +- Unit (core, table-driven): every row of 4.4; gitignore cases (anchoring, + bare names, trailing `/`, `!` and the excluded-parent rule, case + insensitivity, the `/` root form, defaults + negation); package specs; + severity floor incl. unknown and `moderate`; both-files equal/different; + lookup with `.git` dir, `.git` file, no git. +- Parser contract: `tests/cli_parse_scan.rs` rows for the two flags and + env vars. +- E2E (wiremock, `tests/in_process_scan.rs` style): hosted, vendored, + agent and `--dry-run` with a socket.yml that filters by path, ecosystem, + package and severity; invalid file → exit 1, no bytes changed; + `--no-socket-yml` bypass; narrowing after a patch is applied leaves the + pinned package byte-identical in hosted, vendored and agent modes + (retained); `--prune` universe unchanged. +- Parity: `tests/hosted_memory_parity.rs` gains a socket.yml fixture; disk + and memory filter the same roots and packages. +- This repo's own `socket.yml` keeps working (its `projectIgnorePaths` + now also excludes the fixtures from patching). + +Docs (A): `CLI_CONTRACT.md` (new "socket.yml patch policy" section: +grammar, precedence, lookup, validation, commands; flag + env rows; error +codes; `policy` JSON block; the trust-boundary bullet gains the "narrow or +pace" sentence), README (scan section: "Roll out gradually" with recipes +R1-R4, R6), CHANGELOG `[Unreleased]` (Added: socket.yml patch policy; +Changed (BREAKING): scan honors `projectIgnorePaths`, default test/fixture +ignores on discovered roots, invalid socket.yml fails scan). + +### 9.2 Work item B — limit, ordering, reporting + +Scope: +- `crates/socket-patch-core/src/rollout.rs`; `pub mod rollout;` in + `crates/socket-patch-core/src/lib.rs`. +- Make `discover_selected` (`scan/mod.rs:553`) the single disk selection + point: route the human agent/vendored arm (`mod.rs:2386-2402`) through + it, and have it return `{selected, deferred}` so hosted + (`run_redirect_selected`, `hosted.rs:1196`), vendored and agent writers + receive only admitted rows. +- Classification from `merge_ledger_records_for_updates` / + `detect_updates` per project root; eligibility (tier, agent partition, + vendored preflight, hosted reference grants — grants fetched for NEW + candidates in rank order, identical result either way); `plan_rollout` + with one run-wide budget across `run_project_dirs`. +- In-memory engine: hosted-pin discovery over in-memory lockfiles; + collect → plan → apply restructure around `hosted_memory/mod.rs:537`; + options `maxNewPatches`, `maxNewPatchesCap`, `inFlightPatches`; result + `rollout`, `ProjectResult.deferred[]`, `rollout_deferred` skips; + `npm/index.d.ts`; hosted-bundle fields. +- Flag: `--max-new-patches `/`SOCKET_MAX_NEW_PATCHES` + (`RolloutArgs`); `resolve_max_new` precedence including the file value + from A (9.3). +- JSON `rollout` block (5.5), `redirect.skipped[]` mirror, human + "Rollout:" line and the Next-steps deferred line (hosted + `format_next_steps`, `hosted.rs:3457`, and the agent/vendored + summaries). + +Tests: +- Unit (core): `rollout_cmp` total order (property: sorting any + permutation gives the same result); `plan_rollout` caps only NEW, + counts base purls run-wide, one package across roots costs 1, 0 = no + NEW, `none` = unlimited, ineligible rows hold no slot, in-flight first; + `resolve_max_new` precedence table incl. cap; `canonical_base_purl` + twins. +- E2E (wiremock): hosted, vendored, agent, `--dry-run`: 9 candidates with + `--max-new-patches 3` apply the 3 most severe; rerun on the result + applies the next 3; a third run the last 3; a fourth run changes nothing + (convergence); upgrades land regardless of the cap; a withdrawn + top-ranked patch does not consume budget; JSON `rollout` and + `redirect.skipped[]` contents; exit 0. +- Parity: `hosted_memory_parity.rs` cap fixture — disk and memory admit + and defer the same rows; memory rerun with pins in the lockfiles lands + the next N (needs pin discovery). +- Parser contract rows for the flag and env var. + +Docs (B): `CLI_CONTRACT.md` (limit semantics: classification, unit, +order, eligibility, convergence, starvation and non-committing-CI notes; +flag + env rows; `rollout` block; `rollout_deferred`; `jq` recipe; the +"Which patch gets selected" section notes the separate cross-package +order), README (recipe R5, `--max-new-patches`), CHANGELOG `[Unreleased]` +Added. + +### 9.3 Integration (B, after rebasing on A) + +- Pass `policy.max_new_patches()` as the `file` layer of `resolve_max_new`. +- Resolve the `ScanArgs` struct-literal conflicts (both flattened fields + present). +- Combined e2e: a socket.yml with `includePaths`, `minSeverity: high` and + `maxNewPatches: 2` over a two-root fixture, disk and memory, three runs to + convergence; `--no-socket-yml` drops the file's cap but keeps a flag cap. +- If B is ready before A merges, B ships with the file layer passed as + `None` and a follow-up commit on its branch wires it once A lands. + +## 10. Open questions (decided by default, revisit with evidence) + +- A separate upgrade cap (`maxUpgrades`) if server-side republishes rotate + too many pins at once. Default: none. +- CVSS/EPSS/KEV or reachability as ordering keys once the patch API + exposes them; they would slot between severity and advisory count. +- A generated JSON Schema for the `patches` block, shared with depscan and + the docs, to keep validators from drifting. diff --git a/docs/design/v5-plan.md b/docs/design/v5-plan.md index 5d131f99..df5a7aa3 100644 --- a/docs/design/v5-plan.md +++ b/docs/design/v5-plan.md @@ -130,6 +130,13 @@ patch-UI review. exit 2 for all usage errors; scan/get JSON onto `json_envelope`. - Full item list: 22 findings from the UI review (sizes S/M/L, contract flags). +### WS9 — Staged patch rollout *(branches `v5/rollout-policy` (A), `v5/rollout-limit` (B))* +- Added 2026-09-28 at the owner's request. `socket.yml` `patches:` policy + (paths, ecosystems, packages, severity floor, enabled) read by `scan` + and the in-memory engine, plus `scan --max-new-patches` (severity-ordered + per-run cap on new patches). Full plan and the two work-item specs: + `docs/design/staged-rollout.md`. Merge order A then B. + ## Remaining small follow-ups - ci.yml `e2e_cargo`/`e2e_golang` rows select `--ignored` but have no ignored tests (vacuous legs) → give them `--include-ignored` or drop the rows. From d087a633920e81b4eb14239307e78376e1bf3fcc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 11:54:49 +0000 Subject: [PATCH 02/22] Revise rollout plan after adversarial review Three independent reviews (ambiguity, churn, trust boundary) found gaps that would have let the two implementations disagree or let a repo file widen or stall the rollout. The plan now: - matches paths against marker files with the backend's top-down gitignore rules, so projectIgnorePaths means the same everywhere - uses one data source for severity, supersession and ordering - spends the budget only on patches the planning pass proves can land, and admits nothing new when a lookup failed - uses the merged recorded view in every mode and engine - has depscan read the policy from the base commit for PR jobs - hardens file handling (regular files, aliases, size, encoding, trusted repo root) and reports what a policy hides Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/design/configuration.md | 11 +- docs/design/staged-rollout.md | 848 +++++++++++++++++++++------------- 2 files changed, 525 insertions(+), 334 deletions(-) diff --git a/docs/design/configuration.md b/docs/design/configuration.md index 94a79bb0..4c4a617c 100644 --- a/docs/design/configuration.md +++ b/docs/design/configuration.md @@ -94,14 +94,17 @@ The trust boundary is unchanged and gains its positive half: never widen it, name an endpoint or credential, choose a mode or download format, or disable a safety interlock. The parser has no fields for any of those; such keys are unknown keys and fail validation. -- Because the file only narrows, an invalid file fails closed (exit 1, - `socket_yml_invalid`, nothing written) instead of being treated as - absent. This is the opposite of the socket-cli `config.json` rule above +- Because the file only narrows, an unreadable file or an invalid + `patches` block fails closed (exit 1, `socket_yml_invalid`, nothing + written) instead of being treated as absent. (A repo with no `patches` + block and a malformed `projectIgnorePaths` gets a warning, so repos that + never opted in do not start failing.) This is the opposite of the socket-cli `config.json` rule above (corrupt → warn and ignore), and deliberately so: ignoring a broken user-level login file loses a convenience; ignoring a broken repo policy widens the rollout. - Lookup is bounded to the repository (nearest `.git` ancestor of - `--cwd`, else `--cwd`), root files only. + `--cwd` owned by the user, honoring `GIT_CEILING_DIRECTORIES`, else + `--cwd`), root files only, regular files only. - Flags and env vars still win over the file for scalars (CLI > env > file > default) and intersect with it for list filters; `--no-socket-yml` / `SOCKET_NO_SOCKET_YML` ignores the file. diff --git a/docs/design/staged-rollout.md b/docs/design/staged-rollout.md index 88b28511..eb834983 100644 --- a/docs/design/staged-rollout.md +++ b/docs/design/staged-rollout.md @@ -44,7 +44,8 @@ per-directory `socket.yml` files. walks up from cwd and prefers `socket.yml`. The docs say `socket.yml` wins. Nobody merges multiple files; there are no per-directory files. - Glob semantics (backend): the `ignore` npm package, i.e. **gitignore - rules**, case-insensitive (`list-files.ts:476-507`). A leading `/` or a + rules**, case-insensitive, tested against each manifest **file** path + (`list-files.ts:476-507`). A leading `/` or a middle `/` anchors to the repo root, a bare name matches at any depth, a trailing `/` matches directories only, `!` negates, last match wins, a child of an excluded directory cannot be re-included. @@ -129,14 +130,17 @@ pairs, `enabled`, a severity floor spelled as a minimum (`--min-severity`, Snyk/GitLab/OSV), a per-run new-item cap (GitLab/OSV/Snyk backlog), a fixed total order (not Dependabot's shuffle), deny-wins. + ## 3. Trust boundary (decision) The rule in `CLI_CONTRACT.md` ("Repo-level files never carry endpoints, credentials, or interlock-disablers") stays and gains its positive half: > A repository file may **narrow or pace** what `scan` patches. It may -> never widen it, name an endpoint or credential, pick a mode, or turn off -> a safety check. +> never name an endpoint or credential, pick a mode or download format, +> turn off a safety check, or make `scan` patch anything it would not +> patch with no file present. The one exception is negating the built-in +> test/fixture path ignores (4.3), which are repo policy by nature. Every `patches:` key only removes candidates (`enabled`, `includePaths`, `ignorePaths`, `ecosystems`, `packages`, `ignorePackages`, `minSeverity`) @@ -148,6 +152,8 @@ fail validation (4.4). Failure direction follows from that: because the file only narrows, an unreadable or invalid policy must not mean "no policy". It fails closed. +And because a policy can hide security fixes, what it hides is always +reported (4.7, 7.3), never silent. ## 4. `socket.yml` grammar (work item A) @@ -160,22 +166,28 @@ projectIgnorePaths: # existing scanner key; socket-patch honors it to patches: # new; every key optional enabled: true # bool. Default true. false = report only. includePaths: ["/services/payments/"] # gitignore list. Absent = every project. - ignorePaths: ["/legacy/"] # gitignore list, added after the defaults. Default []. + ignorePaths: ["/legacy/"] # gitignore list, evaluated after the defaults. Default []. ecosystems: [npm, pypi] # allowlist of --ecosystems names. Absent = all. - packages: ["lodash"] # allowlist of --package specs. Absent = all. + packages: ["pkg:npm/lodash"] # allowlist of --package specs. Absent = all. ignorePackages: ["pkg:npm/left-pad"] # denylist of --package specs. Default []. minSeverity: high # critical|high|medium|moderate|low. Absent = no floor. - maxNewPatches: 5 # integer >= 0. Absent = unlimited. 0 = upgrades only. + maxNewPatches: 5 # integer 0..=4294967295. Absent = unlimited. 0 = upgrades only. ``` - camelCase, like every existing socket.yml key. -- Ecosystem names are `Ecosystem::cli_name()`: `npm pypi cargo gem golang - maven composer nuget deno`, case-insensitive. +- Ecosystem names are any `Ecosystem::cli_name()` (`npm pypi cargo gem + golang maven composer nuget deno`), case-insensitive, valid whatever the + build supports; an unsupported ecosystem simply matches nothing. - Package specs use exactly the `--package` grammar and matcher - (`package_spec_matches`): a name (full or last segment, - case-insensitive) or a purl with or without a version; qualifiers - ignored. -- `moderate` is an alias of `medium`, as in `severity_order`. + (`package_spec_matches`, moved from the cli crate to core by A): a name + (full or last segment, case-insensitive) or a purl with or without a + version; qualifiers ignored. A bare name matches across ecosystems and + by last segment (`core` matches `@babel/core`), so the docs recommend + purls in `packages`/`ignorePackages`. Invalid spec: empty, or `pkg:` + without a type and name. +- `moderate` is an alias of `medium` everywhere (file, flag, env, napi). +- An empty allowlist (`includePaths: []`, `ecosystems: []`, + `packages: []`) is an error ("use `enabled: false`"), never "all". - Deny wins: `ignorePackages` beats `packages`, ignore paths beat `includePaths`. @@ -183,91 +195,142 @@ patches: # new; every key optional | Setting | Rule | |---|---| -| List filters (`includePaths`/`ignorePaths`/`projectIgnorePaths` vs PATH args; `ecosystems` vs `--ecosystems`; `packages`/`ignorePackages` vs `--package`) | **intersect**: flags narrow further, never widen | -| `minSeverity` | `--min-severity ` > `SOCKET_MIN_SEVERITY` > file > no floor | +| List filters (paths vs PATH args; `ecosystems` vs `--ecosystems`; `packages`/`ignorePackages` vs `--package`) | **intersect**: flags narrow further, never widen | +| `minSeverity` | `--min-severity ` > `SOCKET_MIN_SEVERITY` > file > no floor | | `maxNewPatches` | `--max-new-patches ` > `SOCKET_MAX_NEW_PATCHES` > file > unlimited | -| whole file | `--no-socket-yml` / `SOCKET_NO_SOCKET_YML` skips the file (built-in default path ignores still apply) | +| whole file | `--no-socket-yml` / `SOCKET_NO_SOCKET_YML` (bool, the contract's spellings) skips the file; built-in default path ignores still apply | Scalars follow the contract's CLI > env > default order, with the file as the layer above the default. The person running the CLI is trusted; the -file is the repo's default. (depscan adds its own server ceiling, section -7.) Every new flag has an env binding, as the contract requires. +file is the repo's default. Every new flag has an env binding. An empty env +value is unset (repo-wide rule); a malformed flag or env value is a usage +error (exit 2). depscan adds its own server ceiling (7.1). ### 4.3 Paths -- **Subject.** Path rules decide which *project roots* are patched: the - directory holding the lockfile/manifest, relative to the repo root, with - `/` separators, tested as a directory. The rule is the same in every - mode, including agent mode (its project is `--cwd`). -- **Semantics.** gitignore, identical to the backend's `ignore` package: - Rust `ignore::gitignore::GitignoreBuilder` with `case_insensitive(true)`, - anchored at the repo root, `matched_path_or_any_parents` so a directory - pattern covers everything under it. -- **Repo-root project.** gitignore cannot match the empty path, so in - `patches.includePaths` / `patches.ignorePaths` the literal entry `/` - (and `!/`) means "the repository-root project". It has no meaning in - `projectIgnorePaths`, which stays scanner semantics. -- **Evaluation order** (last match wins): +- **Subject: marker files.** The backend tests `projectIgnorePaths` + against manifest file paths, so socket-patch does the same for every + path list. A project root's **markers** are the lockfile/manifest files + in its directory that the engine reads for it (disk: the root's + lockfiles per the formats registry, plus its manifest; memory: + `hosted_memory/roots.rs` marker files). Paths are repo-relative with `/` + separators, e.g. `services/api/package-lock.json`, `package-lock.json` + for the repo-root project. + - A root is **ignored** iff **every** marker is ignored. + - With `includePaths` set, a root is **included** iff **any** marker + matches `includePaths`. + - Admitted iff included and not ignored. + This makes `/package-lock.json`, `**/yarn.lock`, `examples/**` and + `crates/x/fixtures/**` mean what they mean to the scanner, and needs no + special form for the repo-root project (target only it with + `includePaths: ["/*", "!/*/"]`). +- **Semantics: npm `ignore` exactly.** gitignore rules, case-insensitive, + anchored at the repo root: a leading or middle `/` anchors, a bare name + matches at any depth, a trailing `/` matches directories only, `!` + negates, last match wins. Evaluation walks **top-down**: for + `a/b/c.lock`, test `a/`, then `a/b/`, then the file; the first ignored + ancestor decides and a negation cannot re-include anything under it + (`fixtures/` + `!/a/fixtures/keep/` leaves `keep` ignored, as in the + backend). Do not use `ignore::gitignore`'s `matched_path_or_any_parents` + as-is: it walks bottom-up and would re-include. Implement the walk over + `Gitignore::matched(path, is_dir)`. `includePaths` uses the same walk + with "matched" in place of "ignored". +- A golden fixture of (patterns, path, expected) generated from the npm + `ignore` package is checked into the tests; the Rust matcher must agree + on all of it. +- **Pattern hygiene.** Reject (4.4) patterns that contain a `..` segment, a + drive letter, NUL, or exceed 1024 bytes. Backslash is gitignore's escape + character, not a separator (documented). +- **Evaluation order** of the ignore lists (one combined list, last match + wins within a path, top-down across ancestors): 1. built-in defaults: `test/ tests/ fixtures/ __fixtures__/ testdata/` 2. `projectIgnorePaths` 3. `patches.ignorePaths` - A user re-includes a default with a negation, e.g. - `ignorePaths: ["!/e2e/tests/"]`. Adding an unrelated ignore never - silently re-enables fixtures. -- **Admission.** A root is admitted iff it is not ignored by the list - above AND (`includePaths` is absent OR `includePaths` matches it). -- **Defaults apply to discovered roots only.** Built-in defaults (step 1) - prune roots the tool discovers (in-memory root detection; disk PATH-glob - expansion). A directory the user names explicitly (`--cwd`, a literal - PATH) is exempt from step 1 but not from steps 2-3 or `includePaths`. + Re-include a default with a negation: `ignorePaths: ["!/e2e/tests/"]`. + Adding an unrelated ignore never re-enables fixtures. The defaults are + now case-insensitive (`Test/` too), unlike H1. The backend's own + scanner defaults (`coverage`, `bower_components`, …) are not mirrored: + those are not dependency roots socket-patch would find. +- **Defaults apply to discovered roots only.** Step 1 never applies to a + root the user named explicitly: + + | Mode / entry point | Explicit roots | Discovered roots | + |---|---|---| + | disk hosted/vendored | `--cwd` with no PATH; a literal (non-glob) PATH | PATH-glob matches (`run_project_dirs` carries the flag per directory) | + | disk agent | `--cwd` (its only project; agent PATHs are package globs, not roots) | none | + | in-memory | roots given in `projectRoots` | roots found by detection | + + Steps 2-3 and `includePaths` apply to every root. - **Structural excludes** (`node_modules .git .socket .yarn vendor`) stay hard-coded and cannot be negated. - **Granularity.** A workspace member that shares the root lockfile is part of the root project; exclude it with `ignorePackages`, not paths. - Documented. -- A root excluded by the policy is reported as filtered (4.6) with the - pattern that decided it and the list it came from. +- **Outside the repo.** Roots are canonicalized; a PATH that resolves + outside the repo root (4.5) is a usage error (exit 2). One policy per + invocation. ### 4.4 Validation (fail closed) -socket-patch validates only what it reads: `version`, `projectIgnorePaths` -and `patches`. Other top-level keys are never inspected. +socket-patch validates `version`, `projectIgnorePaths` and `patches`, and +checks top-level key names for case variants of `patches`. It does not +validate any other key. + +Checks run in this order: file access, encoding, YAML, top-level shape, +case-variant check, version gate, keys. | Situation | Behavior | |---|---| -| No file | Defaults. | -| YAML syntax error, duplicate key, top level not a mapping, file over 64 KiB, symlink resolving outside the repo root | **Error** `socket_yml_invalid` | -| `patches` present and `version` is not `2` (integer 2 or the string `"2"`, matching ajv coercion), including a missing `version` | **Error** `socket_yml_invalid` ("patches requires version: 2") | -| No `patches` and `version` is not 2 | File ignored (a v1 or future file is not ours to judge); warning `socket_yml_unsupported_version` | -| Unknown key under `patches` | **Error**, with a did-you-mean hint when one key is within edit distance 2 | -| Wrong type (no coercion: `"false"` is not a bool; YAML 1.2 so `no` is a string), unknown ecosystem or severity, `maxNewPatches` negative or non-integer, invalid glob or package spec, `projectIgnorePaths` not a list of strings | **Error** naming the key path (`patches.minSeverity`) and the file | -| Top-level key equal to `patch`/`patches` ignoring case but not exactly `patches` (`Patches:`, `PATCH:`, `patch:`) | **Error**: a misspelled block must not silently mean "no policy" | -| Both `socket.yml` and `socket.yaml` at the root | Parse both. If the parts socket-patch reads (`projectIgnorePaths`, `patches`) are equal, use them; otherwise **error** `socket_yml_ambiguous` naming both. The existing consumers disagree on which file wins, so we refuse to pick. | - -**Error behavior:** before any write, `scan` exits **1** with -`errorCode: socket_yml_invalid` (or `socket_yml_ambiguous`), a human -message naming file, key path and remedy (fix the file, or -`--no-socket-yml`), `--json` stdout still a valid envelope. Exit 1, not 2: -it is a bad input file, like an invalid manifest, not a usage error. - -**Forward compatibility.** A strict parser means an older pinned CLI fails -on a key a newer CLI understands. That is deliberate (the alternative is a -silently wider rollout); the error text says "unknown key … (a newer -socket-patch may support it; upgrade or remove it)". The contract documents -that keys are only ever added in minor releases and never change meaning. +| No file; empty or comment-only file | no file: defaults | +| Not a regular file after resolving (directory, FIFO, device), resolves outside the repo root, larger than 64 KiB (read at most 64 KiB + 1 from the opened handle; metadata from the same handle) | **error** | +| Invalid UTF-8, UTF-16, NUL bytes (a UTF-8 BOM is stripped; CRLF is fine) | **error** | +| YAML syntax error, duplicate key, top level not a mapping, nesting deeper than 32 | **error** | +| An anchor, alias or merge key (`<<`) inside `patches` or `projectIgnorePaths` | **error** (bounds expansion; nobody needs them here) | +| Top-level key equal to `patch` or `patches` ignoring case but not exactly `patches` | **error**: a misspelled block must not mean "no policy" | +| `patches` present and `version` is not 2 (integer 2 or string `"2"`, as ajv coerces), including missing | **error** ("patches requires version: 2") | +| `patches: null` or `patches: {}` | defaults | +| Unknown key under `patches` | **error**, with a did-you-mean hint (edit distance <= 2) and "a newer socket-patch may support it; upgrade or remove it" | +| Wrong type (no coercion: `"false"` is not a bool; YAML 1.2, so `no` is a string), unknown severity, `maxNewPatches` not an integer in range, empty allowlist, invalid pattern or spec, a list over 1000 entries, an entry over 1024 bytes | **error** naming the key path (`patches.minSeverity`) | +| `projectIgnorePaths` with a `patches` block present: a string is coerced to a one-element list (as ajv does); anything else not a list of strings is an **error** | | +| `projectIgnorePaths` with **no** `patches` block: same coercion; otherwise warning `socket_yml_ignored_value` and the key is ignored | repos that never opted in do not start failing on a scanner key | +| No `patches` block, any `version` | `projectIgnorePaths` honored whatever the version, as the backend (P2) does | +| Both `socket.yml` and `socket.yaml` at the root | validate both (either invalid is an error). If their `projectIgnorePaths` and `patches` are equal as parsed values (order-sensitive), use `socket.yml`; otherwise **error** `socket_yml_ambiguous`. The existing consumers disagree on which file wins, so we refuse to pick. | +| Only a case variant exists (`Socket.yml`) | not read (the name must match a directory entry exactly, via `read_dir`, so case-insensitive disks behave like the memory tree); warning `socket_yml_name_case` | + +**Error behavior.** Before any write, `scan` fails with exit **1** and +`errorCode: socket_yml_invalid` (or `socket_yml_ambiguous`). The message +names the file, the key path and the remedy (fix the file, or +`--no-socket-yml`). Exit 1, not 2: it is a bad input file, like an invalid +manifest. Scan's JSON is still the legacy shape (not the unified envelope): +the error output is scan's existing error object `{"status": "error", +"error": ""}` plus an additive `"errorCode"`; no `policy` or +`rollout` block is emitted on error. + +Every string copied from the file into output (patterns, specs, key names) +is truncated to 200 characters with control characters stripped; depscan +additionally renders them as escaped code spans (7.3). + +Keys are only ever added in minor releases and never change meaning. An +older pinned CLI fails on a newer key by design, and the error says so. ### 4.5 Lookup -1. Repo root := the nearest ancestor of `--cwd` (inclusive) containing - `.git` (a directory, or a file for worktrees and submodules). With no - `.git` ancestor, repo root := `--cwd` (never the home directory or - filesystem root; socket-cli's unbounded walk could pick up an untrusted - `/tmp/socket.yml`). +1. Canonicalize `--cwd`. Repo root := the nearest ancestor (inclusive) + containing `.git` (a directory, or a file for worktrees and submodules), + not walking past any directory in `GIT_CEILING_DIRECTORIES`, and, on + Unix, only if `.git` is owned by the current user or root (git's + safe.directory spirit; otherwise warning `socket_yml_repo_untrusted` + and the walk stops). With no qualifying `.git`, repo root := `--cwd`. + Never the home directory unless `--cwd` is it; never above `--cwd` + without a `.git`. 2. Read `/socket.yml` and `/socket.yaml` only. - Nested `socket.yml` files are not read. One file per repo, as in the - GitHub App. -3. The in-memory engine's repo root is the tree root it was given. + Nested files are never read (one file per repo, as in the GitHub App). + A symlinked socket.yml is followed only if it resolves to a regular + file inside the repo root. +3. In memory, the repo root is the tree root; the file must arrive with + content (7.2). A socket.yml the tree lists but the engine never + receives, or receives only as present-without-content (symlink, + oversize, LFS pointer, binary), is `socket_yml_invalid`, never absent. 4. `--global` / `--global-prefix` scans have no repo and ignore the file. ### 4.6 Commands @@ -275,7 +338,7 @@ that keys are only ever added in minor releases and never change meaning. | Command | Policy | |---|---| | `scan` (hosted, vendored, agent; wet and `--dry-run`), `hosted-bundle`, the napi engine | honor filters and limit | -| `get` | explicit intent: ignores filters and limit; warns `policy_bypassed` when the target would have been filtered | +| `get` | explicit intent: ignores filters and limit; warns `policy_bypassed` when the target would have been filtered; never fails on the policy (an invalid file just skips the warning) | | `apply`, `list`, `vex`, `rollback`, `remove`, `repair`, `vendor` (eject/revert) | ignore it: they report, attest or undo existing state | **Narrowing never removes.** The policy runs after the prune universe is @@ -287,14 +350,18 @@ over; left byte-identical. It is reported under `policy.retained[]` with `upgradeAvailable`. Removing a patch is only ever `rollback`/`remove`, or the dependency leaving the lockfile. -`minSeverity` filters **candidates** before per-package ranking (so a -lower-ranked patch above the floor can still win), using the patch's real -severity (`severity_order` on `BatchPatchInfo.severity`, or -`max_severity_order` over `vulnerabilities`), never `RankKey.severity`. -With a floor set, a patch with unknown severity is filtered (fail closed). -A recorded patch below the floor stays in place; it is replaced only when a -candidate above the floor supersedes it under the existing -`batch_supersedes` rule, which is an ordinary upgrade. +**Severity floor.** One data source: the by-package records the selector +already fetches (`fetch_patch_details` on disk, the provider's by-package +lookup in memory), severity = `max_severity_order` over the patch's +`vulnerabilities`, never `RankKey.severity` (forced to 0 for merged +patches) and never the batch list. The floor restricts which candidates +may **win** per-package ranking; a lower-ranked patch above the floor can +still win. With a floor set, unknown severity is filtered (fail closed; +note `minSeverity: low` therefore drops unknown-severity patches, which the +recipes say). Supersession of a recorded patch is judged against the +**unfiltered** offer list (5.1): the floor never turns a recorded patch +into "no longer offered". A recorded package with no candidate above the +floor keeps its recorded patch (ALREADY). `enabled: false`: discovery and the table still run; nothing is written; every candidate is reported filtered with `policy_disabled`; warning @@ -302,154 +369,214 @@ every candidate is reported filtered with `policy_disabled`; warning ### 4.7 JSON (`policy` block, owned by A) -Additive top-level key on every `scan --json` result (MINOR), always -present: +Additive top-level key on every successful `scan --json` result (MINOR), +always present. Policy warnings go to scan's top-level `warnings[]`. ```json "policy": { - "source": "file", // "none" | "file" | "bypassed" - "path": "socket.yml", // repo-relative; null unless source=file - "sha256": "…", // of the file bytes; null unless source=file + "source": "file", + "path": "socket.yml", + "sha256": "…", "enabled": true, - "minSeverity": {"value": "high", "source": "file"}, // value null = no floor; source flag|env|file|default + "minSeverity": {"value": "high", "source": "file"}, "counts": {"filtered": 3, "retained": 1}, "filtered": [ - {"purl": "pkg:npm/qs@6.5.2", "uuid": "…", "project": "services/legacy", + {"purl": "pkg:npm/qs@6.5.2", "uuid": null, "project": "services/legacy", "reason": "policy_path_excluded", "detail": "/legacy/ (patches.ignorePaths)"} ], "retained": [ - {"purl": "pkg:npm/lodash@4.17.20", "project": ".", "recordedUuid": "…", + {"purl": "pkg:npm/lodash@4.17.20", "project": "", "recordedUuid": "…", "reason": "policy_package_ignored", "upgradeAvailable": true} ] } ``` -- `uuid` is null when the package was filtered before any patch was looked - up (path, ecosystem, package reasons). +| `source` | When | `path` / `sha256` | +|---|---|---| +| `none` | no file, empty file, file ignored (`--global`), or only a case variant | null | +| `file` | a root file was read (with or without a `patches` block) | the file used (`socket.yml` when both are equal) / its bytes' hash | +| `bypassed` | `--no-socket-yml` / `SOCKET_NO_SOCKET_YML` | null | + +- `project` is the repo-relative root directory; the repo root is `""` + (the memory engine's spelling) everywhere. +- `minSeverity.source` is `flag|env|file|default`; `value` null = no floor. +- `uuid` is null when the package was filtered before any patch lookup + (path, ecosystem, package reasons). A root filtered as a whole is one + entry with `purl: null`. +- `counts.filtered` counts entries of `filtered[]`; `counts.retained` + counts entries of `retained[]`. - Reason codes (stable): `policy_disabled`, `policy_path_excluded`, `policy_path_not_included`, `policy_ecosystem`, `policy_package_not_listed`, `policy_package_ignored`, `policy_severity` (detail `unknown < high` or `medium < high`). -- A root filtered as a whole is one entry with `purl: null`. -- Human output: one line, e.g. - `Policy (socket.yml): 3 skipped by filters, 1 patched package held.` - and `--verbose` lists them. +- Human output: one line, e.g. `Policy (socket.yml): 3 skipped by filters, + 1 patched package held.` Filtered critical/high candidates are always + named on the human path (a policy must not silently hide them); + `--verbose` lists everything. ## 5. Per-run limit (work item B) ### 5.1 Classification -After filtering and per-package selection, each selected `(project root, -purl, uuid)` row is classified against that project's recorded view -(`merge_ledger_records_for_updates`: manifest > hosted lockfile pins > -vendor ledger), with `detect_updates`' qualifier-twin handling: +**Recorded view.** Always the merged view, in every mode and both engines: +`merge_ledger_records_for_updates` (manifest > hosted lockfile pins > +vendor ledger), scoped to the lockfiles and state files of the project +root being written. The in-memory engine reads the same three stores from +the tree (`.socket/manifest.json`, `.socket/vendor/state.json`, hosted +pins discovered from the in-memory lockfiles; new, B). When the lockfiles +of one root pin a purl to different uuids, the recorded uuid is the +selected uuid if it is among them, else the smallest (today's rule). + +**Supersession** uses the by-package records (the same data as selection +and the severity floor), with the `batch_supersedes` rungs applied to +them: merged over unmerged, higher severity between unmerged, a real, +strictly later publish date. It is judged against the **unfiltered** offer +list. B adds the by-package twin of `batch_supersedes` in `ranking.rs`; +`detect_updates` and scan's `updates[]` switch to it so classification, +selection and reporting can never disagree. -| Class | Rule | Counts toward the cap | -|---|---|---| -| ALREADY | recorded uuid == selected uuid, or recorded uuid kept because the selection does not supersede it (`batch_supersedes`) | no; hosted re-confirms it idempotently as today | -| UPGRADE | recorded uuid differs and the selection supersedes it (existing `detect_updates` rule, including "recorded patch no longer offered") | no | -| NEW | no patch recorded for this base purl **in this project root** | **yes** | - -- NEW is per project root. Widening `includePaths` from a pilot directory - to more services makes piloted packages NEW in the added roots, and they - go through the cap again. A patch already in another project is not a - free pass. -- UPGRADEs are exempt (decision): rollout risk is about whether a package - runs patched code at all, and an upgrade fixes more in a package that is - already patched. Capping upgrades would leave known-superseded patches in - place. To freeze everything, use `enabled: false`; `maxNewPatches: 0` - freezes new packages only. +After filtering and per-package selection, each selected `(project root, +purl)` row is: -### 5.2 Budget and ordering +| Class | Rule | Counts toward the cap | Writer receives | +|---|---|---|---| +| ALREADY | recorded uuid == selected uuid, or the selection does not supersede the recorded uuid | no | the **recorded** uuid (re-confirmed idempotently) | +| UPGRADE | the selection supersedes the recorded uuid, or the recorded uuid is no longer offered at all (unfiltered) | no | the selected uuid | +| NEW | nothing recorded for this base purl in this project root | **yes** | the selected uuid, if admitted | +- NEW is per project root. Widening `includePaths` makes piloted packages + NEW in the added roots, so they go through the cap again. +- UPGRADEs are exempt (decision): rollout risk is about whether a package + runs patched code at all, and an upgrade fixes more in an already-patched + package. `enabled: false` freezes everything; `maxNewPatches: 0` freezes + new packages only. +- **Known limit: version bumps.** When a dependency moves to a new version + its hosted pin goes with the old lockfile entry, so the new version is + NEW and goes through the cap. (Hosted state cannot tell a bump from a new + package.) Documented. +- **Qualifier twins** (wheel/sdist, gem platforms) share a base purl. If + one twin lands and another was ineligible, the next run sees the base + purl as recorded and the late twin lands as ALREADY/UPGRADE, uncapped. + Documented; it is one package. + +### 5.2 Eligibility, budget and ordering + +- **Eligibility is decided by the planning pass**, the same pass + `--dry-run` runs, before any budget is spent. A NEW row is eligible only + if every check that can be decided without writing passes: + - tier filter; + - agent partition (vendored / not installed); + - vendored preflight; + - hosted reference grant `granted`, with a usable purl and url; + - vlt artifact preflight; + - symlink refusals; + - rewriter planning shows at least one lockfile edit that would pin it + (no refusal, entry found). + + Ineligible rows keep their existing skip reasons and never hold a slot, + so a patch that cannot land can never stall the rollout. +- **One fetch strategy.** References are requested for every eligible-so-far + candidate (NEW, UPGRADE and ALREADY) in the run's normal batches, before + budgeting; never lazily in rank order. A reference or lookup failure that + affects only rows that end up deferred never fails the run or the root; + it becomes warning `rollout_reference_failed`. +- **Incomplete data.** With a finite cap, if any batch, detail or reference + lookup failed for a package that could have been NEW, no NEW row is + admitted this run (all NEW rows deferred) and warning + `rollout_incomplete_lookup` is emitted. Otherwise a failure would let + lower-ranked patches take the missing ones' slots. ALREADY and UPGRADE + rows proceed as today. - **Unit:** a distinct **base purl** (ecosystem + name + version, - qualifiers stripped) among NEW rows, run-wide: across every project root - of one invocation (disk multi-directory human runs, every root of the - in-memory engine). Admitting a base purl admits all of its NEW rows in - every root. One package patched in ten roots costs 1. -- **Order** (ascending; a total order with no time-dependent keys): - 1. in-flight first (in-memory option `inFlightPatches` only, 7.2; - absent on the CLI) - 2. real severity of the selected patch (`severity_order`: critical, - high, medium, low, unknown) + qualifiers stripped, via one shared core function `canonical_base_purl`) + among eligible NEW rows. Admitting a base purl admits all of its eligible + NEW rows in every root of the invocation; it costs 1 slot. +- **Scope of the budget:** + - in-memory engine: one budget across all roots (collect, plan, apply); + - disk: one budget per invocation. `run_project_dirs` visits + directories in sorted order and passes the **remaining** budget to + each; each directory spends it in rank order. `scan --json` accepts one + directory, so a CI job per directory gets N per directory. Documented. +- **Order** (ascending; total; no time-dependent keys): + 1. in-flight first (in-memory option `inFlightPatches` only, matched by + base purl; absent on the CLI) + 2. severity of the selected patch (`max_severity_order`: critical, high, + medium, low, unknown) 3. advisory count, descending (merged patches first within a severity) 4. ecosystem `cli_name`, ascending - 5. canonical base purl, ascending bytewise (one shared core - normalization function, used by disk and memory) - 6. uuid, ascending - - A base purl present in several roots uses the minimum key of its rows. - `publishedAt` is deliberately **not** a key: the batch endpoint omits it, - so using it would reorder the top N between runs and between the disk - and memory engines. Per-package ranking (which patch a package gets) - still uses `publishedAt` as today; this order only decides which - packages go first. -- **Eligibility before budget.** A row consumes budget only if it can land - this run: it passed the tier filter, the agent partition (vendored / - not installed), the vendored preflight and, in hosted mode, its reference - grant came back `granted`. Rows that cannot land (withdrawn, - build_failed, pending_build, not_found, forbidden, refused) keep their - existing skip reasons and do not hold a slot, so a permanently broken - patch can never stall the rollout. Implementations may fetch references - for every NEW candidate, or in rank-ordered batches until the budget is - full; the resulting plan must be identical. -- **Write failures** after admission consume budget (the run stays bounded; - no backfill within a run). They are reported as failures, as today. + 5. canonical base purl, ascending bytewise + 6. smallest selected uuid across the base purl's rows, ascending + + A base purl in several roots uses the minimum key over its rows. + `publishedAt` is not a key: it would reorder the queue whenever a date is + missing. Per-package ranking (which patch a package gets) still uses + `publishedAt` as today; this order only decides which packages go first. +- **Write failures** after admission (I/O at commit time) consume budget + and are reported as failures. No backfill within a run, so `--dry-run` + predicts the wet run exactly. - **`maxNewPatches: 0`** admits no NEW rows; ALREADY and UPGRADE proceed. -- Everything NEW beyond the budget is **deferred**: not written, not - downloaded, not vendored, reported with its rank. +- Everything eligible and NEW beyond the budget is **deferred**: not + written, not downloaded, not vendored, reported with its rank. ### 5.3 Convergence and determinism - Same inputs, same plan, same bytes. The limit is stateless: run k lands the top N; on run k+1 they are ALREADY and the next N land. M waiting - patches take ceil(M/N) committed runs. + patches take **at most** ceil(M/N) committed runs, absent new or + ineligible patches. - A newly published or re-scored higher-severity patch moves ahead of the - queue. That is intended ("most critical first") and is visible because + queue. That is intended ("most critical first") and visible, because every deferred entry carries its rank and severity. - Low-severity patches can wait indefinitely while higher ones keep arriving. Documented; it is the point of severity ordering. - **CI that does not commit** the scan's result never advances recorded - state, so a cap there means "only the top N, every run". Documented in - the recipes: commit the lockfile changes (or use a PR bot), or do not set - a cap in non-committing jobs. + state, so a cap there means "only the top N, every run". The recipes + say: commit the lockfile changes (or use a PR bot), or set no cap in + non-committing jobs. - `pending_build` references are transient: a row can be ineligible one run and eligible the next. The plan is still a function of the inputs. +- `--dry-run` fetches reference grants like a wet run (it must, to decide + eligibility), so it has the same server-side effects a dry run has + today. ### 5.4 Modes -| Mode | Recorded state | NEW/ALREADY/UPGRADE source | Deferred rows | -|---|---|---|---| -| hosted (disk) | lockfile hosted pins (`HostedPin`) | recorded view | never granted, never rewritten; mirrored into `redirect.skipped[]` with reason `rollout_deferred` | -| vendored | `.socket/vendor/state.json` | ALREADY = `already_vendored`, UPGRADE = `would_revendor` | never downloaded or vendored | -| agent | `.socket/manifest.json` | ALREADY = `skipped`, UPGRADE = `updated` | never downloaded; not in `apply.patches[]` | -| in-memory (napi, hosted-bundle) | hosted pins discovered from the in-memory lockfiles (**new**, B) | same | in `ProjectResult.deferred[]` and `skipped[]` with `rollout_deferred` | +| Mode | ALREADY / UPGRADE surface | Deferred rows | +|---|---|---| +| hosted (disk) | re-confirmed / rewritten, as today | never rewritten; mirrored into `redirect.skipped[]` with reason `rollout_deferred` | +| vendored | `already_vendored` / `would_revendor` | never downloaded or vendored | +| agent | `skipped` / `updated` | never downloaded; not in `apply.patches[]` | +| in-memory (napi, hosted-bundle) | as hosted | in `ProjectResult.deferred[]` and `skipped[]` with `rollout_deferred` | -`--dry-run` makes exactly the same decisions and reports them the same way. -A takeover of an existing vendored or hosted entry counts as recorded, not -NEW. +Recorded state is the merged view (5.1) in every row. A takeover of an +existing vendored or hosted entry counts as recorded, not NEW. `--dry-run` +makes exactly the same decisions. ### 5.5 JSON (`rollout` block, owned by B) -Additive top-level key on every `scan --json` result (MINOR), always -present: +Additive top-level key on every successful `scan --json` result (MINOR), +always present: ```json "rollout": { - "maxNewPatches": {"value": 5, "source": "file"}, // value null = unlimited; source flag|env|file|default|cap + "maxNewPatches": {"value": 5, "source": "file"}, "counts": {"new": 5, "deferred": 9, "upgrade": 1, "already": 12}, "deferred": [ - {"purl": "pkg:npm/minimist@1.2.5", "uuid": "…", "severity": "critical", + {"purl": "pkg:npm/minimist@1.2.5", "uuids": ["…"], "severity": "critical", "advisoryCount": 1, "projects": ["services/api", "services/web"], "rank": 6} ] } ``` -- `counts.new` is the number of NEW base purls admitted this run - (landed, or would land under `--dry-run`); `deferred` lists the rest in - rank order; `rank` is 1-based across all NEW candidates. -- Human output (hosted/vendored/agent summary, then the Next-steps - renderer): +- `maxNewPatches.value` null = unlimited; `source` is + `flag|env|file|default|cap`. +- `counts.new` and `counts.deferred` count base purls (admitted this run, + or would be under `--dry-run`; deferred). `counts.upgrade` and + `counts.already` count `(project, purl)` rows. +- `deferred[]` is in rank order; `purl` is the base purl; `uuids` lists + the distinct selected uuids across its rows and qualifier twins; `rank` + is 1-based among **eligible** NEW base purls. Ineligible rows are not + ranked; they appear under their existing skip reasons. +- Human output (after the mode's summary, then the Next-steps renderer): ``` Rollout: 5 of 14 new patches applied (maxNewPatches=5 from socket.yml); 1 upgrade, 12 already applied. @@ -470,11 +597,11 @@ patches: ``` ```yaml -# R2 Critical first: critical only, then widen by editing one line +# R2 Critical first: widen by editing one line version: 2 patches: - minSeverity: critical # later: high, then remove - maxNewPatches: 5 + minSeverity: critical # later: high, then low, then remove the key + maxNewPatches: 5 # (low still skips patches whose severity is unknown) ``` ```yaml @@ -511,6 +638,9 @@ patches: # maxNewPatches: 0 ``` +A cap only advances when the scan's changes are committed (or merged by a +PR bot). In a CI job that scans without committing, set no cap. + One-off overrides from the command line: `socket-patch scan --max-new-patches none` (drain the queue this run), `--min-severity none`, `--no-socket-yml` (ignore the file entirely). @@ -520,29 +650,36 @@ One-off overrides from the command line: `socket-patch scan ### 7.1 Behavior with the new engine - `repo` jobs rebuild one commit from the base SHA each run. With - `maxNewPatches: 5`, "recorded" means pinned on the **base** branch, so + `maxNewPatches: 5`, "recorded" means recorded on the **base** branch, so every rebuild proposes the same top 5 until the PR merges, then the next - 5. No churn, no new PR per batch. -- `pull_request` jobs honor the filters but pass `maxNewPatches: "none"`: - deferring there would leave the check permanently showing work. -- socket.yml is read from the same commit as the tree (base SHA for `repo` - jobs, head SHA for `pull_request` jobs), through the engine: the file is - one of the paths the engine asks for, so there is no second parser. -- Effective limit = min(repo value or override, server cap). The server - can tighten, never loosen. Org-level kill switches, entitlement and - safety (D1-D6) always win. + 5. `inFlightPatches` (the base purls already in the open PR) keeps a + reviewed patch from being displaced by a newly published one mid-review. +- **Policy source.** Both job kinds read socket.yml from the **base** SHA: + the reviewed, merged policy. A pull request cannot loosen the policy + that judges its own check (for example by adding `ignorePackages` for + the vulnerable dependency it introduces). If the PR head changes + `patches` or `projectIgnorePaths`, the check run says so and lists what + the head's policy would additionally filter. +- `pull_request` jobs honor the filters and pass `maxNewPatches: "none"` + and **no** `maxNewPatchesCap`: deferring there would leave the check + permanently showing work. +- Effective limit for `repo` jobs = min(repo value, `maxNewPatchesCap`). + The server can tighten, never loosen; the cap applies to every value + including `"none"`. Org-level kill switches, entitlement and safety + (D1-D6) always win. ### 7.2 Engine API changes (napi `HostedScanOptions` / result, and the `hosted-bundle` harness) | Owner | Change | |---|---| -| A | `selectHostedScanPaths` returns root `socket.yml` / `socket.yaml` when present in the tree listing (one phase: the file is small and root-only; roots the policy excludes are simply not processed) | -| A | options `noSocketYml?: boolean`, `minSeverity?: "critical"\|"high"\|"medium"\|"low"\|"none"` | -| A | result: session-level `policy` block (4.7) and `policyError?: {code, detail}`; on error no project is processed and no files change; `skipped[].reason` gains the `policy_*` codes | -| B | options `maxNewPatches?: number \| "none"`, `maxNewPatchesCap?: number`, `inFlightPatches?: string[]` (uuids already in the open PR; ranked first so a reviewed patch is not displaced by a newly published one mid-review) | +| A | `selectHostedScanPaths` also returns root `socket.yml` / `socket.yaml` when listed, and returns the list of policy paths it selected; it applies only the **built-in** default ignores (it cannot see file contents); the session fails `socket_yml_invalid` if a selected policy path never arrives with content or arrives present-without-content | +| A | the session applies the full policy to detected roots **before** the `max_projects` check (`hosted_memory/mod.rs:377`) | +| A | options `noSocketYml?: boolean`, `minSeverity?: "critical"\|"high"\|"medium"\|"moderate"\|"low"\|"none"` | +| A | result: session-level `policy` block (4.7) and `policyError?: {code, detail}`; on error no root is processed and no files change; `skipped[].reason` gains the `policy_*` codes | +| B | options `maxNewPatches?: number \| "none"`, `maxNewPatchesCap?: number`, `inFlightPatches?: string[]` (base purls) | | B | result: session-level `rollout` block (5.5); `ProjectResult.deferred[]`; `skipped[]` rows with `rollout_deferred` | -| B | hosted-pin discovery over the in-memory lockfiles (the memory twin of `HostedPin::all(discover_wiring(..))`), so NEW/ALREADY/UPGRADE work in memory. A finite cap must never ship in the engine without it: every merged pin would look NEW and the rollout would stall at N. | -| B | restructure the per-root loop at `hosted_memory/mod.rs:537` into collect all roots → plan once → apply, so the budget is run-wide | +| B | hosted-pin discovery over the in-memory lockfiles and reading `.socket/manifest.json` / `.socket/vendor/state.json` from the tree, for the merged recorded view. A finite cap must never ship in the engine without it: every merged pin would look NEW and the rollout would stall at N. | +| B | restructure the per-root loop around `hosted_memory/mod.rs:537` into collect all roots → plan once → apply, so the budget is run-wide | `hosted-bundle` rejects unknown fields, so each owner adds its fields there too. @@ -550,26 +687,34 @@ too. ### 7.3 depscan follow-up (after A and B merge; separate PR in depscan) 1. Bump the socket-patch submodule and rebuild the addon. -2. Pass `inFlightPatches` (uuids in the open patch-all PR) and, for - `pull_request` jobs, `maxNewPatches: "none"`. -3. New job outcome `policy_invalid` (from `policyError`): leave the +2. Stream root socket.yml content from the **base** SHA for both job kinds + (for `repo` jobs that is the tree being scanned; for `pull_request` + jobs push the base-SHA blob under the policy path the engine selected). + Never let the file be dropped by the size/path caps silently: the + engine turns a missing policy blob into `policyError`. +3. Pass `inFlightPatches` (base purls in the open patch-all PR); for + `pull_request` jobs pass `maxNewPatches: "none"` and no cap. +4. New job outcome `policy_invalid` (from `policyError`): leave the existing PR untouched, surface the error on the admin page and in the - job's check-run text. -4. Render a "Deferred (next batch)" table and severity/rank columns in the - PR body; add `patchesDeferred` to stats. -5. Optional server cap per org (future org setting), passed as - `maxNewPatchesCap`; intersect the admin `config.ecosystems` (D3) with - the file by passing it as `ecosystems` as today. -6. Do **not** add `patches` to the ajv schema in - `socket-yaml-schema.ts` with strict types: a typo would reject the whole - file and turn PR checks neutral. If documentation value is wanted, add - it as a permissive `{type: object}`. -7. Docs repo: add a `patches` section to the socket.yml page, and fix the - two stale statements found in research (which file wins when both - exist; v1 files are rejected by the GitHub App). - -A closed/rejected rolling PR re-proposes the same patches next run; -document `ignorePackages` as the way to decline one. + check-run text. +5. PR body and check run: a "Deferred (next batch)" table with severity + and rank; `policy.filtered`/`retained` counts, naming every critical or + high candidate the policy suppressed; a note when the PR head changes + the policy. Render every file-derived string as an escaped code span, + truncated. +6. Stats: `patchesDeferred`, `patchesFiltered`. +7. Optional server cap per org (future setting) passed as + `maxNewPatchesCap`; keep passing the admin `config.ecosystems` (D3) as + `ecosystems`, which intersects with the file. +8. Do **not** add `patches` with strict types to the ajv schema in + `socket-yaml-schema.ts`: a typo there rejects the whole file and turns + PR checks neutral. If wanted for docs, add a permissive `{type: object}`. +9. Docs repo: a `patches` section on the socket.yml page; fix the two + stale statements found in research (which file wins when both exist; + v1 files are rejected by the GitHub App). + +A closed or rejected rolling PR re-proposes the same patches next run; +`ignorePackages` is the documented way to decline one. ## 8. Decisions log @@ -577,46 +722,48 @@ document `ignorePackages` as the way to decline one. |---|---|---| | 1 | Top-level `patches:` in socket.yml v2; no `version: 3` | breaks no parser (P1/P2 strip, P3 ignores); P3 rejects any version but 2 | | 2 | socket-patch reads socket.yml (reverses configuration.md) | owner request; policy that only narrows fits the trust boundary | -| 3 | Keys `enabled includePaths ignorePaths ecosystems packages ignorePackages minSeverity maxNewPatches` | include/ignore pairs mirror existing keys; `packages` allowlist covers single-package pilots; `maxNewPatches` says it counts new patches only | -| 4 | gitignore semantics via the `ignore` crate, case-insensitive, anchored at repo root | identical to `projectIgnorePaths` in the backend | -| 5 | socket-patch also honors `projectIgnorePaths` | users expect one ignore list; every other consumer already honors it | -| 6 | Defaults `test/ tests/ fixtures/ __fixtures__/ testdata/` evaluated first, overridden by `!`; discovered roots only | moves H1; replace-on-set would re-enable fixtures when someone adds one unrelated pattern | -| 7 | Strict validation, fail closed, exit 1 `socket_yml_invalid` | a broken narrowing rule must not widen the rollout | -| 8 | Both files: error only if the parts we read differ | existing consumers disagree on precedence; repos that already have both keep working | -| 9 | Repo root = nearest `.git` ancestor, else `--cwd`; root files only | matches the GitHub App; memory engine can mirror it; never reads outside the checkout | +| 3 | Keys `enabled includePaths ignorePaths ecosystems packages ignorePackages minSeverity maxNewPatches` | include/ignore pairs mirror existing keys; `packages` covers single-package pilots; `maxNewPatches` says it counts new patches only | +| 4 | Paths match marker **files**, npm-`ignore` semantics, top-down, case-insensitive; golden parity fixture | identical meaning to `projectIgnorePaths` in the backend; no root special form | +| 5 | socket-patch also honors `projectIgnorePaths` (leniently when there is no `patches` block) | users expect one ignore list; repos that never opted in do not start failing | +| 6 | Defaults `test/ tests/ fixtures/ __fixtures__/ testdata/` first, overridden by `!`; discovered roots only | moves H1; replace-on-set would re-enable fixtures on any unrelated edit | +| 7 | Strict validation of `patches`, fail closed, exit 1 `socket_yml_invalid` | a broken narrowing rule must not widen the rollout | +| 8 | Both files: error only if the parts we read differ | consumers disagree on precedence; repos that have both keep working | +| 9 | Repo root = nearest trusted `.git` ancestor (ceiling dirs honored), else `--cwd`; root files only; PATHs outside it are exit 2 | matches the GitHub App; memory can mirror it; never reads outside the checkout | | 10 | Flags intersect lists; scalars CLI > env > file > default; `--no-socket-yml` with env | contract precedence and "every flag has an env var" | | 11 | `maxNewPatches: 0` = upgrades only; absent / `none` = unlimited | literal meaning; avoids the Dependabot/Renovate 0 disagreement | | 12 | Unknown severity is filtered when a floor is set | fail closed | -| 13 | NEW per (project root, base purl); budget per base purl run-wide | a widened pilot re-enters the cap; one package in many roots costs 1 | -| 14 | Upgrades exempt from the cap | rollout risk is per package; keeps patched packages current | -| 15 | Order: severity, advisory count, ecosystem, base purl, uuid; no `publishedAt` | total and time-independent; batch lacks the date | -| 16 | Budget after eligibility (grants, partition, preflight) | a withdrawn/broken patch never holds a slot | -| 17 | Filtered packages with recorded patches are retained, never removed or upgraded | narrowing freezes, never removes | -| 18 | `get` bypasses the policy with a warning | explicit intent | -| 19 | Separate `policy` (A) and `rollout` (B) JSON blocks | clean ownership seam; both additive | -| 20 | Everything ships in 5.0 | honoring `projectIgnorePaths`, disk default ignores and fail-closed file errors change scan's default behavior (MAJOR) | +| 13 | One data source (by-package records) for floor, supersession, classification and order | selection, classification and reporting can never disagree | +| 14 | NEW per (project root, base purl); budget per base purl; memory run-wide, disk per invocation carried across directories | a widened pilot re-enters the cap; one package in many roots costs 1 | +| 15 | Upgrades exempt from the cap | rollout risk is per package; keeps patched packages current | +| 16 | Order: severity, advisory count, ecosystem, base purl, uuid; no `publishedAt` | total and time-independent | +| 17 | Eligibility = everything the planning pass can decide; fetch-all references; incomplete lookups admit no NEW rows | a broken patch never holds a slot; failures never reshuffle the queue | +| 18 | Filtered packages with recorded patches are retained, never removed or upgraded | narrowing freezes, never removes | +| 19 | `get` bypasses the policy with a warning | explicit intent | +| 20 | Separate `policy` (A) and `rollout` (B) JSON blocks | clean ownership seam; both additive | +| 21 | depscan reads the policy from the base SHA for PR jobs too | a PR cannot loosen the policy judging it | +| 22 | Everything ships in 5.0 | honoring `projectIgnorePaths`, disk default ignores and fail-closed file errors change scan's default behavior (MAJOR) | ## 9. Work items Both items branch from `release/v5-prerelease` (suggested branches `v5/rollout-policy` for A, `v5/rollout-limit` for B). **Merge order: A, -then B.** -B rebases onto A and owns the final integration (section 9.3). Neither -item depends on the other's types: the only exchanged values are plain -`Option` / `Option` and the pipeline order below. +then B.** B rebases onto A and owns the final integration (9.3). Neither +item needs the other's types to compile: the only exchanged values are +plain integers and the pipeline order below. ### 9.0 Shared contract (frozen by this plan) Scan pipeline, in order (disk and memory): -1. load policy (A) — fail closed before any write +1. load policy (A); fail closed before any write 2. crawl; capture the prune universe (unchanged) 3. root filter, ecosystem/package filter, retained set (A) -4. batch API (unchanged) -5. candidate severity filter (A) +4. batch API, by-package details (unchanged fetches) +5. candidate severity filter on by-package records (A) 6. per-package ranking (unchanged `ranking`) -7. classify NEW/ALREADY/UPGRADE, eligibility, budget, deferral (B) -8. writers (unchanged; receive only admitted rows) +7. classify, planning pass for eligibility, budget, deferral (B) +8. writers (receive only admitted NEW rows, ALREADY rows with the recorded + uuid, and UPGRADE rows) ```rust // crates/socket-patch-core/src/policy/mod.rs — OWNER A @@ -628,20 +775,26 @@ pub enum FilterReason { Severity { found: Option, floor: String }, } impl FilterReason { pub fn code(&self) -> &'static str; pub fn detail(&self) -> String; } -pub enum PolicyError { Invalid { file: String, key: String, message: String }, Ambiguous { files: [String; 2] } } +pub enum PolicyError { + Invalid { file: String, key: String, message: String }, + Ambiguous { files: [String; 2] }, +} impl PolicyError { pub fn code(&self) -> &'static str; } // socket_yml_invalid | socket_yml_ambiguous -pub trait PolicyFs { fn read_root_file(&self, name: &str, cap: usize) -> std::io::Result>>; } +pub enum RootFile { Absent, Present(Vec), PresentWithoutContent } +pub trait PolicyFs { fn read_root_file(&self, name: &str, cap: usize) -> std::io::Result; } pub struct PolicyOverrides { pub bypass: bool, pub min_severity: Option> } // Some(None) = "none" +pub struct Root<'a> { pub rel_dir: &'a str, pub markers: &'a [String], pub explicit: bool } impl SelectionPolicy { - pub fn unrestricted() -> Self; // defaults (built-in path ignores only) - pub fn load(fs: &dyn PolicyFs, o: &PolicyOverrides) -> Result; + pub fn unrestricted() -> Self; // built-in default ignores only + pub fn load(fs: &dyn PolicyFs, o: &PolicyOverrides) -> Result<(Self, Vec), PolicyError>; pub fn source(&self) -> &PolicySource; pub fn enabled(&self) -> bool; - pub fn admits_root(&self, rel_dir: &str, explicit: bool) -> Result<(), FilterReason>; - pub fn admits_purl(&self, purl: &str) -> Result<(), FilterReason>; // ecosystem + packages + pub fn admits_root(&self, root: &Root) -> Result<(), FilterReason>; + pub fn admits_purl(&self, purl: &str) -> Result<(), FilterReason>; // ecosystem + packages pub fn admits_severity(&self, severity_order: u8) -> Result<(), FilterReason>; - pub fn max_new_patches(&self) -> Option; // the FILE value only; B resolves precedence + pub fn max_new_patches(&self) -> Option; // the FILE value only; B resolves precedence } +pub fn package_spec_matches(spec: &str, purl: &str) -> bool; // moved from cli scan/mod.rs:383 // crates/socket-patch-core/src/rollout.rs — OWNER B pub enum Recorded { None, Same, Kept { uuid: String }, Superseded { old_uuid: String } } @@ -656,149 +809,182 @@ pub fn resolve_max_new(flag: Option>, env: Option>, file: Option, cap: Option) -> MaxNew; pub fn canonical_base_purl(purl: &str) -> String; pub fn rollout_cmp(a: &Candidate, b: &Candidate) -> std::cmp::Ordering; -pub struct RolloutPlan { pub admitted: Vec, pub deferred: Vec<(Candidate, u32)>, pub counts: RolloutCounts } -pub fn plan_rollout(candidates: Vec, max_new: &MaxNew) -> RolloutPlan; // pure +pub struct RolloutCounts { pub new: u32, pub deferred: u32, pub upgrade: u32, pub already: u32 } +pub struct RolloutPlan { + pub admitted: Vec, pub deferred: Vec<(Candidate, u32)>, + pub counts: RolloutCounts, pub remaining: Option, // carried to the next directory +} +pub fn plan_rollout(candidates: Vec, max_new: &MaxNew, incomplete: bool) -> RolloutPlan; // pure + +// crates/socket-patch-core/src/api/ranking.rs — OWNER B (addition) +pub fn search_result_supersedes(candidate: &PatchSearchResult, recorded: &PatchSearchResult) -> bool; ``` Rules both items follow: -- Severity input is always the patch's real severity (`severity_order` / - `max_severity_order`), never `RankKey.severity`. -- Skip-reason strings are the stable codes in 4.7 and 5.5. +- Severity input is always `max_severity_order` over the by-package + record's `vulnerabilities`, never `RankKey.severity`, never the batch + list. +- Skip-reason strings are the stable codes in 4.7 and 5.5; warnings go to + scan's top-level `warnings[]`. - JSON: A owns the top-level `policy` block; B owns the top-level `rollout` block. Neither edits the other's. -- CLI args: A adds a `#[command(flatten)]` `SocketYmlArgs` (`--no-socket-yml`, - `--min-severity`) in `scan/socket_yml_args.rs`; B adds a flattened - `RolloutArgs` (`--max-new-patches`) in `scan/rollout_args.rs`. Both - derive `Default`; each adds its field to the ~18 `ScanArgs` struct - literals. The resulting adjacent-line conflicts are resolved by B on - rebase. +- CLI args: A adds a `#[command(flatten)]` `SocketYmlArgs` + (`--no-socket-yml`, `--min-severity`) in `scan/socket_yml_args.rs`; B + adds a flattened `RolloutArgs` (`--max-new-patches`) in + `scan/rollout_args.rs`. Both derive `Default`; each adds its field to + the ~18 `ScanArgs` struct literals. B resolves the adjacent-line + conflicts on rebase. +- `run_project_dirs` changes: A adds the per-directory `explicit` flag; + B adds the carried remaining budget. B resolves the overlap on rebase. ### 9.1 Work item A — socket.yml loading and filtering Scope: - `crates/socket-patch-core/src/policy/{mod.rs, socket_yml.rs, paths.rs}`; - `pub mod policy;` in `crates/socket-patch-core/src/lib.rs`. + `pub mod policy;` in `crates/socket-patch-core/src/lib.rs`; move + `package_spec_matches` to core (the cli re-uses it). - Dependencies, exact-pinned in `Cargo.toml`: a maintained YAML 1.2 serde - crate that reports duplicate keys (e.g. `serde_norway`; verify - duplicate-key rejection with a test, reject it otherwise), and `ignore` - (gitignore matcher). No other new deps. -- Loader: lookup (4.5), size and symlink confinement, both-files rule, - strict validation with key paths and did-you-mean (4.4), `PolicyFs` for - disk and for the in-memory engine. -- Filters, wired at the pipeline points in 9.0: + crate that reports duplicate keys and can refuse aliases and bound depth + (e.g. `serde_norway`; prove each property with a test, pick another + crate otherwise), and `ignore` (for `Gitignore::matched`; the top-down + walk is ours). No other new deps. +- Loader: lookup (4.5, incl. ceiling dirs and ownership), regular-file, + size and symlink confinement on the opened handle, exact-name match, + encoding, both-files rule, strict validation with key paths and + did-you-mean (4.4), `PolicyFs` for disk and memory. +- Path matcher (4.3): marker-file subject, npm-`ignore` top-down + semantics, defaults + lists in order, `includePaths`, pattern hygiene; + golden fixture generated from npm `ignore` (commit the generator script + under `scripts/` and the fixture under `crates/socket-patch-core/tests/`). +- Filters at the pipeline points in 9.0: - disk: root filter in `project_dirs` / `run_project_dirs` - (`scan/mod.rs:1268-1320`) and the agent project; `admits_purl` next to - `--package` (`scan/mod.rs:1490-1511`); severity filter on batch - candidates after `scan/mod.rs:1801` and on by-package candidates before - `select_patches` in the human arm; retained set computed from the - recorded view and excluded from writers. - - memory: root filter in `hosted_memory/roots.rs` root detection; - `admits_purl` at `hosted_memory/mod.rs:428-432`; severity filter before + (`scan/mod.rs:1268-1320`, carrying `explicit`) and the agent project; + `admits_purl` next to `--package` (`scan/mod.rs:1490-1511`); severity + filter on by-package candidates before `select_patches` + (`discover_selected`, `scan/mod.rs:553`, and the human arm, + `mod.rs:2386-2402`); retained set computed from the recorded view and + excluded from writers. + - memory: built-in defaults in `selectHostedScanPaths` + (`hosted_memory/select.rs`); full root filter in the session before + `max_projects` (`hosted_memory/mod.rs:377`); `admits_purl` at + `hosted_memory/mod.rs:428-432`; severity filter before `select_top_ranked`. - Move `test tests fixtures __fixtures__ testdata` out of `EXCLUDED_ROOT_SEGMENTS` (`hosted_memory/roots.rs:56-67`) into the built-in default ignores, and apply them to disk PATH-glob expansion. - `enabled: false` report-only path; `get`'s `policy_bypassed` warning; - `--global` ignores the file. + `--global` ignores the file; PATHs outside the repo root → exit 2. - Flags: `--no-socket-yml`/`SOCKET_NO_SOCKET_YML`, `--min-severity`/`SOCKET_MIN_SEVERITY` (`SocketYmlArgs`). -- napi + hosted-bundle: `selectHostedScanPaths` includes root - `socket.yml`/`socket.yaml`; options `noSocketYml`, `minSeverity`; result - `policy` and `policyError`; `npm/index.d.ts` types. -- JSON `policy` block (4.7), human policy line, error envelopes for - `socket_yml_invalid` / `socket_yml_ambiguous`, warnings - `socket_yml_unsupported_version`, `patches_disabled`, `policy_bypassed`. +- napi + hosted-bundle (7.2, A rows); `npm/index.d.ts` types. +- JSON `policy` block (4.7), human policy line (naming suppressed + critical/high), error output with `errorCode`, warnings + `socket_yml_ignored_value`, `socket_yml_name_case`, + `socket_yml_repo_untrusted`, `patches_disabled`, `policy_bypassed`; + output string hygiene. Tests: -- Unit (core, table-driven): every row of 4.4; gitignore cases (anchoring, - bare names, trailing `/`, `!` and the excluded-parent rule, case - insensitivity, the `/` root form, defaults + negation); package specs; - severity floor incl. unknown and `moderate`; both-files equal/different; - lookup with `.git` dir, `.git` file, no git. -- Parser contract: `tests/cli_parse_scan.rs` rows for the two flags and - env vars. +- Unit (core, table-driven): every row of 4.4 in order; the npm-`ignore` + golden fixture (anchoring, bare names, trailing `/`, `!`, excluded + parents, case); marker rule (all markers ignored / any included); + defaults + negation; explicit vs discovered; package specs incl. + invalid ones; severity floor incl. unknown and `moderate`; both-files + equal / different / one invalid; lookup with `.git` dir, `.git` file, + none, `GIT_CEILING_DIRECTORIES`, foreign-owned `.git`; symlink inside + and outside, directory, FIFO; alias bomb; oversize; BOM, CRLF, UTF-16. +- Parser contract: `tests/cli_parse_scan.rs` rows for both flags and env + vars (empty = unset, malformed = exit 2). - E2E (wiremock, `tests/in_process_scan.rs` style): hosted, vendored, - agent and `--dry-run` with a socket.yml that filters by path, ecosystem, - package and severity; invalid file → exit 1, no bytes changed; - `--no-socket-yml` bypass; narrowing after a patch is applied leaves the - pinned package byte-identical in hosted, vendored and agent modes - (retained); `--prune` universe unchanged. -- Parity: `tests/hosted_memory_parity.rs` gains a socket.yml fixture; disk - and memory filter the same roots and packages. + agent and `--dry-run` with a socket.yml filtering by path, ecosystem, + package and severity; invalid file → exit 1, `errorCode`, no bytes + changed; `--no-socket-yml`; narrowing after a patch is applied leaves the + pinned package byte-identical in all three modes (retained); a recorded + merged patch below a new floor is kept, not replaced; `--prune` universe + unchanged; PATH outside the repo → exit 2. +- Parity: `tests/hosted_memory_parity.rs` gains a socket.yml fixture + (single-lockfile roots) where disk and memory filter the same roots and + packages; a memory test where the tree lists socket.yml but its content + is withheld → `policyError`. - This repo's own `socket.yml` keeps working (its `projectIgnorePaths` now also excludes the fixtures from patching). Docs (A): `CLI_CONTRACT.md` (new "socket.yml patch policy" section: -grammar, precedence, lookup, validation, commands; flag + env rows; error -codes; `policy` JSON block; the trust-boundary bullet gains the "narrow or -pace" sentence), README (scan section: "Roll out gradually" with recipes -R1-R4, R6), CHANGELOG `[Unreleased]` (Added: socket.yml patch policy; -Changed (BREAKING): scan honors `projectIgnorePaths`, default test/fixture -ignores on discovered roots, invalid socket.yml fails scan). +grammar, precedence, paths, lookup, validation, commands; flag + env rows; +error codes; `policy` JSON block; the trust-boundary bullet gains the +"narrow or pace" sentence), README (scan section: "Roll out gradually" +with recipes R1-R4, R6), CHANGELOG `[Unreleased]` (Added: socket.yml +patch policy; Changed (BREAKING): scan honors `projectIgnorePaths`, +default test/fixture ignores on discovered roots, invalid socket.yml with +a `patches` block fails scan). ### 9.2 Work item B — limit, ordering, reporting Scope: - `crates/socket-patch-core/src/rollout.rs`; `pub mod rollout;` in - `crates/socket-patch-core/src/lib.rs`. + `crates/socket-patch-core/src/lib.rs`; `search_result_supersedes` in + `ranking.rs`, and `detect_updates` / `updates[]` switched to by-package + supersession. - Make `discover_selected` (`scan/mod.rs:553`) the single disk selection point: route the human agent/vendored arm (`mod.rs:2386-2402`) through - it, and have it return `{selected, deferred}` so hosted + it, and have it return `{admitted, deferred}` so hosted (`run_redirect_selected`, `hosted.rs:1196`), vendored and agent writers - receive only admitted rows. -- Classification from `merge_ledger_records_for_updates` / - `detect_updates` per project root; eligibility (tier, agent partition, - vendored preflight, hosted reference grants — grants fetched for NEW - candidates in rank order, identical result either way); `plan_rollout` - with one run-wide budget across `run_project_dirs`. -- In-memory engine: hosted-pin discovery over in-memory lockfiles; - collect → plan → apply restructure around `hosted_memory/mod.rs:537`; - options `maxNewPatches`, `maxNewPatchesCap`, `inFlightPatches`; result - `rollout`, `ProjectResult.deferred[]`, `rollout_deferred` skips; - `npm/index.d.ts`; hosted-bundle fields. + receive only the rows 9.0 step 8 allows (ALREADY with the recorded + uuid). +- Classification from the merged recorded view (5.1); the planning pass + for eligibility (hosted: grants, purl/url, vlt preflight, symlink + refusals, rewriter planning; vendored: preflight; agent: partition); + fetch-all references; `rollout_reference_failed` and + `rollout_incomplete_lookup`; `plan_rollout`; the remaining budget + carried through `run_project_dirs` in sorted directory order. +- In-memory engine (7.2, B rows): pin discovery and state-file reads, + collect → plan → apply, options and result fields, `npm/index.d.ts`, + hosted-bundle fields. - Flag: `--max-new-patches `/`SOCKET_MAX_NEW_PATCHES` - (`RolloutArgs`); `resolve_max_new` precedence including the file value - from A (9.3). + (`RolloutArgs`); `resolve_max_new` including the file value from A + (9.3). - JSON `rollout` block (5.5), `redirect.skipped[]` mirror, human "Rollout:" line and the Next-steps deferred line (hosted `format_next_steps`, `hosted.rs:3457`, and the agent/vendored summaries). Tests: -- Unit (core): `rollout_cmp` total order (property: sorting any - permutation gives the same result); `plan_rollout` caps only NEW, - counts base purls run-wide, one package across roots costs 1, 0 = no - NEW, `none` = unlimited, ineligible rows hold no slot, in-flight first; - `resolve_max_new` precedence table incl. cap; `canonical_base_purl` - twins. +- Unit (core): `rollout_cmp` total order (property test: every + permutation sorts the same); `plan_rollout` caps only eligible NEW, + counts base purls, one package across roots costs 1, 0 = no NEW, `none` + = unlimited, ineligible rows hold no slot, `incomplete` admits nothing + NEW, in-flight first, remaining budget; `resolve_max_new` precedence + table incl. cap on `none`; `canonical_base_purl` twins; + `search_result_supersedes` rungs. - E2E (wiremock): hosted, vendored, agent, `--dry-run`: 9 candidates with - `--max-new-patches 3` apply the 3 most severe; rerun on the result - applies the next 3; a third run the last 3; a fourth run changes nothing - (convergence); upgrades land regardless of the cap; a withdrawn - top-ranked patch does not consume budget; JSON `rollout` and - `redirect.skipped[]` contents; exit 0. -- Parity: `hosted_memory_parity.rs` cap fixture — disk and memory admit - and defer the same rows; memory rerun with pins in the lockfiles lands - the next N (needs pin discovery). + `--max-new-patches 3` apply the 3 most severe; a rerun on the result + applies the next 3; a third run the last 3; a fourth changes nothing; + dry-run output equals the wet run's decisions; upgrades land regardless + of the cap; a withdrawn, a `bad_purl` and a vlt-withheld top-ranked + patch hold no slot; a failed detail lookup with a cap admits nothing + NEW; two PATH directories share one budget in sorted order; JSON + `rollout` and `redirect.skipped[]`; exit 0. +- Parity: `hosted_memory_parity.rs` cap fixture: disk and memory admit and + defer the same rows; a memory rerun with pins (and with a committed + manifest / vendor state) lands the next N. - Parser contract rows for the flag and env var. -Docs (B): `CLI_CONTRACT.md` (limit semantics: classification, unit, -order, eligibility, convergence, starvation and non-committing-CI notes; -flag + env rows; `rollout` block; `rollout_deferred`; `jq` recipe; the -"Which patch gets selected" section notes the separate cross-package -order), README (recipe R5, `--max-new-patches`), CHANGELOG `[Unreleased]` -Added. +Docs (B): `CLI_CONTRACT.md` (limit semantics: classification, +eligibility, unit, budget scope, order, convergence, version-bump and +twin notes, starvation and non-committing CI; flag + env rows; `rollout` +block; `rollout_deferred`; warnings; `jq` recipe; "Which patch gets +selected" gains the cross-package order and the by-package supersession +change), README (recipe R5, `--max-new-patches`), CHANGELOG +`[Unreleased]` Added (and Changed: `updates[]` uses by-package data). ### 9.3 Integration (B, after rebasing on A) - Pass `policy.max_new_patches()` as the `file` layer of `resolve_max_new`. -- Resolve the `ScanArgs` struct-literal conflicts (both flattened fields - present). +- Resolve the `ScanArgs` struct-literal and `run_project_dirs` conflicts. - Combined e2e: a socket.yml with `includePaths`, `minSeverity: high` and `maxNewPatches: 2` over a two-root fixture, disk and memory, three runs to convergence; `--no-socket-yml` drops the file's cap but keeps a flag cap. - If B is ready before A merges, B ships with the file layer passed as - `None` and a follow-up commit on its branch wires it once A lands. + `None` and wires it in a follow-up commit on its branch once A lands. ## 10. Open questions (decided by default, revisit with evidence) @@ -808,3 +994,5 @@ Added. exposes them; they would slot between severity and advisory count. - A generated JSON Schema for the `patches` block, shared with depscan and the docs, to keep validators from drifting. +- Recognizing a dependency version bump of an already-patched package as + exempt from the cap (needs state hosted mode does not keep). From bdbe73596d9646b3554291670c90711fe3352acc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 11:57:12 +0000 Subject: [PATCH 03/22] Close interface gaps in the rollout plan A final consistency pass found places where the two work items would have produced incompatible code: base purls admitted in one directory being charged again in the next, no defined hand-off of the unfiltered offers from the severity filter to classification, no shared repo-relative path helper, and override sources the JSON must report but the interface could not carry. The shared contract now defines each of these, and the parity tests match each engine's budget scope. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/design/staged-rollout.md | 54 ++++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/docs/design/staged-rollout.md b/docs/design/staged-rollout.md index eb834983..c48967d8 100644 --- a/docs/design/staged-rollout.md +++ b/docs/design/staged-rollout.md @@ -443,6 +443,7 @@ purl)` row is: | ALREADY | recorded uuid == selected uuid, or the selection does not supersede the recorded uuid | no | the **recorded** uuid (re-confirmed idempotently) | | UPGRADE | the selection supersedes the recorded uuid, or the recorded uuid is no longer offered at all (unfiltered) | no | the selected uuid | | NEW | nothing recorded for this base purl in this project root | **yes** | the selected uuid, if admitted | +| ALREADY (kept) | recorded, offers exist in `Offers.unfiltered` but none survive the floor | no | the recorded uuid (counted in `counts.already`) | - NEW is per project root. Widening `includePaths` makes piloted packages NEW in the added roots, so they go through the cap again. @@ -494,7 +495,9 @@ purl)` row is: - in-memory engine: one budget across all roots (collect, plan, apply); - disk: one budget per invocation. `run_project_dirs` visits directories in sorted order and passes the **remaining** budget to - each; each directory spends it in rank order. `scan --json` accepts one + each, together with the set of base purls already admitted (a base + purl admitted in an earlier directory is admitted free in later ones); + each directory spends the budget in rank order. `scan --json` accepts one directory, so a CI job per directory gets N per directory. Documented. - **Order** (ascending; total; no time-dependent keys): 1. in-flight first (in-memory option `inFlightPatches` only, matched by @@ -747,9 +750,10 @@ A closed or rejected rolling PR re-proposes the same patches next run; Both items branch from `release/v5-prerelease` (suggested branches `v5/rollout-policy` for A, `v5/rollout-limit` for B). **Merge order: A, -then B.** B rebases onto A and owns the final integration (9.3). Neither -item needs the other's types to compile: the only exchanged values are -plain integers and the pipeline order below. +then B.** B rebases onto A and owns the final integration (9.3). The +seams are small and listed in 9.0: the step 5 → step 7 `Offers` struct +(A), the repo-relative path helpers (A), the file's `maxNewPatches` value +(A → B), and the pipeline order. ### 9.0 Shared contract (frozen by this plan) @@ -759,7 +763,9 @@ Scan pipeline, in order (disk and memory): 2. crawl; capture the prune universe (unchanged) 3. root filter, ecosystem/package filter, retained set (A) 4. batch API, by-package details (unchanged fetches) -5. candidate severity filter on by-package records (A) +5. candidate severity filter on by-package records (A); `discover_selected` + returns `Offers` (below) so B sees both the unfiltered and the + floor-filtered candidates 6. per-package ranking (unchanged `ranking`) 7. classify, planning pass for eligibility, budget, deferral (B) 8. writers (receive only admitted NEW rows, ALREADY rows with the recorded @@ -782,7 +788,9 @@ pub enum PolicyError { impl PolicyError { pub fn code(&self) -> &'static str; } // socket_yml_invalid | socket_yml_ambiguous pub enum RootFile { Absent, Present(Vec), PresentWithoutContent } pub trait PolicyFs { fn read_root_file(&self, name: &str, cap: usize) -> std::io::Result; } -pub struct PolicyOverrides { pub bypass: bool, pub min_severity: Option> } // Some(None) = "none" +pub enum OverrideSource { Flag, Env } +pub struct PolicyOverrides { pub bypass: bool, pub min_severity: Option<(Option, OverrideSource)> } // (None, _) = "none" +pub struct PolicyWarning { pub code: &'static str, pub detail: String } pub struct Root<'a> { pub rel_dir: &'a str, pub markers: &'a [String], pub explicit: bool } impl SelectionPolicy { pub fn unrestricted() -> Self; // built-in default ignores only @@ -792,9 +800,17 @@ impl SelectionPolicy { pub fn admits_root(&self, root: &Root) -> Result<(), FilterReason>; pub fn admits_purl(&self, purl: &str) -> Result<(), FilterReason>; // ecosystem + packages pub fn admits_severity(&self, severity_order: u8) -> Result<(), FilterReason>; - pub fn max_new_patches(&self) -> Option; // the FILE value only; B resolves precedence + pub fn max_new_patches(&self) -> Option; // the file's value; None when source() is None or Bypassed, or the key is absent } pub fn package_spec_matches(spec: &str, purl: &str) -> bool; // moved from cli scan/mod.rs:383 +pub fn find_repo_root(cwd: &Path) -> PathBuf; // 4.5 +pub fn repo_relative(repo_root: &Path, dir: &Path) -> String; // "" for the repo root, `/` separators + +// crates/socket-patch-core/src/policy/mod.rs — OWNER A (the step 5 → 7 seam) +pub struct Offers { + pub unfiltered: BTreeMap>, // purl → every offer (after tier) + pub selected: BTreeMap, // purl → winner among floor-admitted offers +} // crates/socket-patch-core/src/rollout.rs — OWNER B pub enum Recorded { None, Same, Kept { uuid: String }, Superseded { old_uuid: String } } @@ -812,9 +828,13 @@ pub fn rollout_cmp(a: &Candidate, b: &Candidate) -> std::cmp::Ordering; pub struct RolloutCounts { pub new: u32, pub deferred: u32, pub upgrade: u32, pub already: u32 } pub struct RolloutPlan { pub admitted: Vec, pub deferred: Vec<(Candidate, u32)>, - pub counts: RolloutCounts, pub remaining: Option, // carried to the next directory + pub counts: RolloutCounts, + pub remaining: Option, // carried to the next directory + pub admitted_base_purls: BTreeSet, // carried too } -pub fn plan_rollout(candidates: Vec, max_new: &MaxNew, incomplete: bool) -> RolloutPlan; // pure +// Rows whose base_purl is in `already_admitted` are admitted without spending budget. +pub fn plan_rollout(candidates: Vec, max_new: &MaxNew, incomplete: bool, + already_admitted: &BTreeSet) -> RolloutPlan; // pure // crates/socket-patch-core/src/api/ranking.rs — OWNER B (addition) pub fn search_result_supersedes(candidate: &PatchSearchResult, recorded: &PatchSearchResult) -> bool; @@ -923,7 +943,9 @@ Scope: - `crates/socket-patch-core/src/rollout.rs`; `pub mod rollout;` in `crates/socket-patch-core/src/lib.rs`; `search_result_supersedes` in `ranking.rs`, and `detect_updates` / `updates[]` switched to by-package - supersession. + supersession; move the `detect_updates` call (today `scan/mod.rs:1912`, + on batch data) after `discover_selected` so it receives the by-package + offers. - Make `discover_selected` (`scan/mod.rs:553`) the single disk selection point: route the human agent/vendored arm (`mod.rs:2386-2402`) through it, and have it return `{admitted, deferred}` so hosted @@ -963,8 +985,10 @@ Tests: patch hold no slot; a failed detail lookup with a cap admits nothing NEW; two PATH directories share one budget in sorted order; JSON `rollout` and `redirect.skipped[]`; exit 0. -- Parity: `hosted_memory_parity.rs` cap fixture: disk and memory admit and - defer the same rows; a memory rerun with pins (and with a committed +- Parity: `hosted_memory_parity.rs` single-root cap fixture: disk and + memory admit and defer the same rows. Two-root fixture: assert memory's + run-wide order and disk's per-directory order separately (they differ by + design, 5.2). A memory rerun with pins (and with a committed manifest / vendor state) lands the next N. - Parser contract rows for the flag and env var. @@ -982,7 +1006,11 @@ change), README (recipe R5, `--max-new-patches`), CHANGELOG - Resolve the `ScanArgs` struct-literal and `run_project_dirs` conflicts. - Combined e2e: a socket.yml with `includePaths`, `minSeverity: high` and `maxNewPatches: 2` over a two-root fixture, disk and memory, three runs to - convergence; `--no-socket-yml` drops the file's cap but keeps a flag cap. + convergence, asserting each engine's own budget scope (5.2); + `--no-socket-yml` drops the file's cap but keeps a flag cap. +- Switch `Candidate.project` to A's `repo_relative` and consume A's + `Offers` (before A lands, B uses canonical `--cwd` as the repo root and + treats the selected offers as the unfiltered list). - If B is ready before A merges, B ships with the file layer passed as `None` and wires it in a follow-up commit on its branch once A lands. From ff7a30ad7387871b676bffc39c3a0d2ddc26b5aa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 11:58:03 +0000 Subject: [PATCH 04/22] Clarify who shapes scan's selection output The plan said both that the selector returns the shared offers struct (work item A) and that it returns admitted/deferred rows (work item B). The selector now returns the offers, and B adds the rollout stage after it. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/design/staged-rollout.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/design/staged-rollout.md b/docs/design/staged-rollout.md index c48967d8..dcaf65d5 100644 --- a/docs/design/staged-rollout.md +++ b/docs/design/staged-rollout.md @@ -948,10 +948,11 @@ Scope: offers. - Make `discover_selected` (`scan/mod.rs:553`) the single disk selection point: route the human agent/vendored arm (`mod.rs:2386-2402`) through - it, and have it return `{admitted, deferred}` so hosted + it, and add the step-7 stage after it (classify its `Offers`, planning + pass, `plan_rollout`) yielding `{admitted, deferred}`, so hosted (`run_redirect_selected`, `hosted.rs:1196`), vendored and agent writers receive only the rows 9.0 step 8 allows (ALREADY with the recorded - uuid). + uuid). `discover_selected`'s return type is A's `Offers`. - Classification from the merged recorded view (5.1); the planning pass for eligibility (hosted: grants, purl/url, vlt preflight, symlink refusals, rewriter planning; vendored: preflight; agent: partition); From 3b6b95ee585e92bc8d76937594175b219f5375b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 13:42:08 +0000 Subject: [PATCH 05/22] Add the rollout planner for capped scans A new core module plans a capped scan: rows that already carry a patch always go through, NEW packages are admitted most critical first until the budget is spent, and the rest are deferred with their rank. The order is total and uses no dates, so repeated scans on an unchanged repo land the same patches and converge. ranking gains search_result_supersedes, the by-package twin of batch_supersedes, so ALREADY vs UPGRADE is judged on the same records that pick the patch. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-core/src/api/ranking.rs | 57 +- crates/socket-patch-core/src/lib.rs | 1 + crates/socket-patch-core/src/rollout.rs | 682 ++++++++++++++++++++ 3 files changed, 739 insertions(+), 1 deletion(-) create mode 100644 crates/socket-patch-core/src/rollout.rs diff --git a/crates/socket-patch-core/src/api/ranking.rs b/crates/socket-patch-core/src/api/ranking.rs index 395f790d..4aca7fac 100644 --- a/crates/socket-patch-core/src/api/ranking.rs +++ b/crates/socket-patch-core/src/api/ranking.rs @@ -25,6 +25,11 @@ //! //! `tier` is an access filter, not a ranking signal: callers drop the paid //! patches a free user cannot download before ranking. +//! +//! This order picks one patch per package. Which *packages* a capped scan +//! patches first is a different order, [`crate::rollout::rollout_cmp`]: +//! it reads the same severity ladder and advisory count, but never the +//! publish date, so a missing date cannot reshuffle the rollout queue. use std::cmp::{Ordering, Reverse}; @@ -159,7 +164,18 @@ pub fn cmp_search_results(a: &PatchSearchResult, b: &PatchSearchResult) -> Order /// never count, and neither does a missing date (the batch endpoint omits /// `publishedAt`), so an equal sibling is never reported as an update. pub fn batch_supersedes(candidate: &BatchPatchInfo, applied: &BatchPatchInfo) -> bool { - let (c, a) = (rank_batch_info(candidate), rank_batch_info(applied)); + key_supersedes(&rank_batch_info(candidate), &rank_batch_info(applied)) +} + +/// [`batch_supersedes`] over the by-package shape: the rule scan uses to +/// classify a recorded patch (ALREADY vs UPGRADE) and to report +/// `updates[]`, on the same records that pick the patch, so selection, +/// classification and reporting cannot disagree. +pub fn search_result_supersedes(candidate: &PatchSearchResult, recorded: &PatchSearchResult) -> bool { + key_supersedes(&rank_search_result(candidate), &rank_search_result(recorded)) +} + +fn key_supersedes(c: &RankKey<'_>, a: &RankKey<'_>) -> bool { if c.not_merged != a.not_merged { return !c.not_merged; } @@ -807,6 +823,45 @@ mod tests { ); } + // ── search_result_supersedes ───────────────────────────────────── + + #[test] + fn search_supersedes_on_merged_state_first() { + let merged = search_multi("z", "free", "2020-01-01T00:00:00Z", &["low", "low"]); + let single = search("a", "free", "2026-01-01T00:00:00Z", "critical"); + assert!(search_result_supersedes(&merged, &single)); + assert!(!search_result_supersedes(&single, &merged)); + } + + #[test] + fn search_supersedes_on_severity_between_unmerged() { + let crit = search("z", "free", "2020-01-01T00:00:00Z", "critical"); + let high = search("a", "free", "2026-01-01T00:00:00Z", "high"); + assert!(search_result_supersedes(&crit, &high)); + assert!(!search_result_supersedes(&high, &crit)); + } + + #[test] + fn search_supersedes_on_a_real_later_date_only() { + let newer = search("z", "free", "2026-01-01T00:00:00Z", "high"); + let older = search("a", "free", "2024-01-01T00:00:00Z", "high"); + assert!(search_result_supersedes(&newer, &older)); + assert!(!search_result_supersedes(&older, &newer)); + let undated = search("z", "free", "", "high"); + assert!(!search_result_supersedes(&undated, &older)); + assert!(!search_result_supersedes(&newer, &undated)); + } + + #[test] + fn search_supersedes_ignores_tier_and_uuid_tiebreaks() { + let paid = search("a", "paid", "2026-01-01T00:00:00Z", "high"); + let free = search("z", "free", "2026-01-01T00:00:00Z", "high"); + assert_eq!(cmp_search_results(&paid, &free), Ordering::Less); + assert!(!search_result_supersedes(&paid, &free)); + assert!(!search_result_supersedes(&free, &paid)); + assert!(!search_result_supersedes(&paid, &paid)); + } + #[test] fn batch_without_published_at_still_ranks_by_severity() { // The batch endpoint historically omits `publishedAt`; losing the diff --git a/crates/socket-patch-core/src/lib.rs b/crates/socket-patch-core/src/lib.rs index 04a2a6f9..0b727f3e 100644 --- a/crates/socket-patch-core/src/lib.rs +++ b/crates/socket-patch-core/src/lib.rs @@ -5,6 +5,7 @@ pub mod hash; pub mod manifest; pub mod package_json; pub mod patch; +pub mod rollout; pub mod setup; pub mod telemetry; pub mod update; diff --git a/crates/socket-patch-core/src/rollout.rs b/crates/socket-patch-core/src/rollout.rs new file mode 100644 index 00000000..89e2e11b --- /dev/null +++ b/crates/socket-patch-core/src/rollout.rs @@ -0,0 +1,682 @@ +//! Staged rollout: the per-run cap on NEW patches (`scan +//! --max-new-patches`, `patches.maxNewPatches` in socket.yml). +//! +//! Pure planning only. Callers classify each selected `(project, purl)` row +//! against the recorded state ([`Recorded`]), decide eligibility with their +//! planning pass, and hand the rows to [`plan_rollout`], which admits the +//! most critical NEW packages up to the budget and defers the rest. Rows +//! that already carry a patch (ALREADY, UPGRADE) never count toward the cap. +//! +//! The order ([`rollout_cmp`]) is total and time-independent, so the same +//! inputs always give the same plan and repeated runs converge: run k lands +//! the top N, run k+1 sees them as recorded and lands the next N. + +use std::cmp::{Ordering, Reverse}; +use std::collections::{BTreeMap, BTreeSet}; + +use crate::utils::purl::canonical_purl; + +/// What the recorded state (manifest > hosted pins > vendor ledger) says +/// about one selected row. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Recorded { + /// No patch recorded for this base purl in this project: NEW. + None, + /// The recorded uuid is the selected one: ALREADY. + Same, + /// A different uuid is recorded and the selection does not supersede + /// it: ALREADY, and the writer keeps `uuid`. + Kept { uuid: String }, + /// The selection supersedes the recorded uuid, or the recorded uuid is + /// no longer offered: UPGRADE. + Superseded { old_uuid: String }, +} + +impl Recorded { + pub fn is_new(&self) -> bool { + matches!(self, Recorded::None) + } +} + +/// One selected `(project, purl)` row. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Candidate { + /// Repo-relative project root; `""` is the repo root. + pub project: String, + pub purl: String, + /// [`canonical_base_purl`] of `purl`: the budget unit. + pub base_purl: String, + /// The selected uuid. + pub uuid: String, + /// `Ecosystem::cli_name`. + pub ecosystem: &'static str, + /// `ranking::max_severity_order` of the selected patch (0 = critical). + pub severity_order: u8, + pub advisory_count: usize, + pub recorded: Recorded, + /// Every check the planning pass can decide without writing passed. + /// Only read for NEW rows. + pub eligible: bool, + /// Already proposed in an open rollout PR (in-memory engine option). + pub in_flight: bool, +} + +/// Where the effective cap came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MaxNewSource { + Flag, + Env, + File, + Default, + /// A server ceiling (`maxNewPatchesCap`) tightened the value. + Cap, +} + +impl MaxNewSource { + pub fn as_str(self) -> &'static str { + match self { + MaxNewSource::Flag => "flag", + MaxNewSource::Env => "env", + MaxNewSource::File => "file", + MaxNewSource::Default => "default", + MaxNewSource::Cap => "cap", + } + } +} + +/// The effective cap. `value: None` is unlimited. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MaxNew { + pub value: Option, + pub source: MaxNewSource, +} + +impl MaxNew { + pub const UNLIMITED: MaxNew = MaxNew { + value: None, + source: MaxNewSource::Default, + }; +} + +/// Resolve the cap: flag > env > file > unlimited, then a server `cap` +/// tightens it (never loosens; it applies to `none` too). For `flag` and +/// `env`, `Some(None)` is an explicit `none`. +pub fn resolve_max_new( + flag: Option>, + env: Option>, + file: Option, + cap: Option, +) -> MaxNew { + let chosen = if let Some(value) = flag { + MaxNew { + value, + source: MaxNewSource::Flag, + } + } else if let Some(value) = env { + MaxNew { + value, + source: MaxNewSource::Env, + } + } else if let Some(value) = file { + MaxNew { + value: Some(value), + source: MaxNewSource::File, + } + } else { + MaxNew::UNLIMITED + }; + match cap { + Some(cap) if chosen.value.is_none_or(|v| v > cap) => MaxNew { + value: Some(cap), + source: MaxNewSource::Cap, + }, + _ => chosen, + } +} + +/// The budget unit: ecosystem + name + version, qualifiers stripped and +/// percent-decoded, so qualifier twins (a wheel and its sdist, gem +/// platforms) and the API's encoded spelling are one package. +pub fn canonical_base_purl(purl: &str) -> String { + canonical_purl(purl) +} + +/// Rollout order, most urgent first: in-flight, severity, advisory count +/// (descending), ecosystem, base purl, uuid. Total, and free of +/// time-dependent keys. +pub fn rollout_cmp(a: &Candidate, b: &Candidate) -> Ordering { + rollout_key(a).cmp(&rollout_key(b)) +} + +type RolloutKey<'a> = (bool, u8, Reverse, &'a str, &'a str, &'a str); + +fn rollout_key(c: &Candidate) -> RolloutKey<'_> { + ( + !c.in_flight, + c.severity_order, + Reverse(c.advisory_count), + c.ecosystem, + c.base_purl.as_str(), + c.uuid.as_str(), + ) +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct RolloutCounts { + /// Distinct base purls admitted as NEW. + pub new: u32, + /// Distinct base purls deferred. + pub deferred: u32, + /// UPGRADE rows. + pub upgrade: u32, + /// ALREADY rows (same or kept). + pub already: u32, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RolloutPlan { + /// Rows the writers receive: admitted NEW rows plus every ALREADY and + /// UPGRADE row, in input order. Ineligible NEW rows are in neither + /// list; they keep their own skip reasons. + pub admitted: Vec, + /// Eligible NEW rows over the budget, in rank order, each with the + /// 1-based rank of its base purl among eligible NEW base purls. + pub deferred: Vec<(Candidate, u32)>, + pub counts: RolloutCounts, + /// Budget left for the next project directory; `None` is unlimited. + pub remaining: Option, + /// Base purls admitted so far in this invocation (input set included). + pub admitted_base_purls: BTreeSet, +} + +/// Admit eligible NEW base purls in [`rollout_cmp`] order until the budget +/// is spent. A base purl already in `already_admitted` (an earlier +/// directory of the same invocation) is admitted without spending budget. +/// With a finite cap and `incomplete` set (a lookup failed for a package +/// that could have been NEW), no NEW row is admitted: a missing package +/// must not let lower-ranked ones take its slot. +pub fn plan_rollout( + candidates: Vec, + max_new: &MaxNew, + incomplete: bool, + already_admitted: &BTreeSet, +) -> RolloutPlan { + let mut counts = RolloutCounts::default(); + let mut remaining = max_new.value; + let mut admitted_base_purls = already_admitted.clone(); + + let mut new_rows: Vec<&Candidate> = candidates + .iter() + .filter(|c| c.recorded.is_new() && c.eligible) + .collect(); + new_rows.sort_by(|a, b| rollout_cmp(a, b)); + // Base purl → (rank, admitted), in rank order. + let mut decisions: BTreeMap<&str, (u32, bool)> = BTreeMap::new(); + let mut rank = 0u32; + for row in &new_rows { + if decisions.contains_key(row.base_purl.as_str()) { + continue; + } + rank += 1; + let admit = if incomplete && max_new.value.is_some() { + false + } else if already_admitted.contains(&row.base_purl) { + true + } else { + match remaining.as_mut() { + None => true, + Some(0) => false, + Some(left) => { + *left -= 1; + true + } + } + }; + if admit { + counts.new += 1; + admitted_base_purls.insert(row.base_purl.clone()); + } else { + counts.deferred += 1; + } + decisions.insert(row.base_purl.as_str(), (rank, admit)); + } + + let deferred: Vec<(Candidate, u32)> = new_rows + .iter() + .filter_map(|row| match decisions[row.base_purl.as_str()] { + (rank, false) => Some(((*row).clone(), rank)), + _ => None, + }) + .collect(); + let mut admitted = Vec::new(); + for row in &candidates { + match &row.recorded { + Recorded::None => { + if row.eligible && decisions[row.base_purl.as_str()].1 { + admitted.push(row.clone()); + } + } + Recorded::Superseded { .. } => { + counts.upgrade += 1; + admitted.push(row.clone()); + } + Recorded::Same | Recorded::Kept { .. } => { + counts.already += 1; + admitted.push(row.clone()); + } + } + } + RolloutPlan { + admitted, + deferred, + counts, + remaining, + admitted_base_purls, + } +} + +/// The severity label for a `ranking::severity_order` value. +pub fn severity_label(order: u8) -> &'static str { + match order { + 0 => "critical", + 1 => "high", + 2 => "medium", + 3 => "low", + _ => "unknown", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn row(project: &str, purl: &str, uuid: &str, severity: u8, advisories: usize) -> Candidate { + Candidate { + project: project.to_string(), + purl: purl.to_string(), + base_purl: canonical_base_purl(purl), + uuid: uuid.to_string(), + ecosystem: purl + .strip_prefix("pkg:") + .and_then(|r| r.split('/').next()) + .map(|e| match e { + "npm" => "npm", + "pypi" => "pypi", + "cargo" => "cargo", + "gem" => "gem", + _ => "golang", + }) + .unwrap_or("npm"), + severity_order: severity, + advisory_count: advisories, + recorded: Recorded::None, + eligible: true, + in_flight: false, + } + } + + fn cap(n: u32) -> MaxNew { + MaxNew { + value: Some(n), + source: MaxNewSource::Flag, + } + } + + fn admitted_purls(plan: &RolloutPlan) -> Vec<&str> { + plan.admitted.iter().map(|c| c.purl.as_str()).collect() + } + + fn deferred_purls(plan: &RolloutPlan) -> Vec<(&str, u32)> { + plan.deferred + .iter() + .map(|(c, r)| (c.purl.as_str(), *r)) + .collect() + } + + fn nine() -> Vec { + vec![ + row("", "pkg:npm/a@1", "u1", 3, 1), + row("", "pkg:npm/b@1", "u2", 0, 1), + row("", "pkg:npm/c@1", "u3", 1, 1), + row("", "pkg:npm/d@1", "u4", 2, 1), + row("", "pkg:npm/e@1", "u5", 0, 2), + row("", "pkg:npm/f@1", "u6", 4, 1), + row("", "pkg:npm/g@1", "u7", 1, 3), + row("", "pkg:npm/h@1", "u8", 2, 1), + row("", "pkg:npm/i@1", "u9", 3, 1), + ] + } + + fn permutations(items: &[T]) -> Vec> { + if items.len() <= 1 { + return vec![items.to_vec()]; + } + let mut out = Vec::new(); + for i in 0..items.len() { + let mut rest = items.to_vec(); + let head = rest.remove(i); + for mut tail in permutations(&rest) { + tail.insert(0, head.clone()); + out.push(tail); + } + } + out + } + + #[test] + fn rollout_cmp_is_a_total_order_every_permutation_sorts_the_same() { + let mut in_flight = row("", "pkg:pypi/z@1", "u9", 4, 0); + in_flight.in_flight = true; + let items = vec![ + row("", "pkg:npm/b@1", "u2", 1, 1), + row("", "pkg:npm/a@1", "u3", 1, 1), + row("", "pkg:cargo/a@1", "u1", 1, 1), + row("", "pkg:npm/m@1", "u0", 1, 2), + row("", "pkg:npm/a@1", "u1", 1, 1), + in_flight, + ]; + let expected: Vec<(String, String)> = vec![ + ("pkg:pypi/z@1".into(), "u9".into()), + ("pkg:npm/m@1".into(), "u0".into()), + ("pkg:cargo/a@1".into(), "u1".into()), + ("pkg:npm/a@1".into(), "u1".into()), + ("pkg:npm/a@1".into(), "u3".into()), + ("pkg:npm/b@1".into(), "u2".into()), + ]; + for mut perm in permutations(&items) { + perm.sort_by(rollout_cmp); + let got: Vec<(String, String)> = perm + .iter() + .map(|c| (c.purl.clone(), c.uuid.clone())) + .collect(); + assert_eq!(got, expected); + } + } + + #[test] + fn severity_then_advisory_count_decide_before_names() { + let mut rows = [ + row("", "pkg:npm/a@1", "u", 2, 5), + row("", "pkg:npm/b@1", "u", 0, 1), + row("", "pkg:npm/c@1", "u", 0, 3), + row("", "pkg:npm/d@1", "u", 4, 9), + ]; + rows.sort_by(rollout_cmp); + let order: Vec<&str> = rows.iter().map(|c| c.purl.as_str()).collect(); + assert_eq!( + order, + ["pkg:npm/c@1", "pkg:npm/b@1", "pkg:npm/a@1", "pkg:npm/d@1"] + ); + } + + #[test] + fn a_cap_admits_the_most_critical_and_defers_the_rest_with_ranks() { + let plan = plan_rollout(nine(), &cap(3), false, &BTreeSet::new()); + assert_eq!( + admitted_purls(&plan), + ["pkg:npm/b@1", "pkg:npm/e@1", "pkg:npm/g@1"] + ); + assert_eq!( + deferred_purls(&plan), + [ + ("pkg:npm/c@1", 4), + ("pkg:npm/d@1", 5), + ("pkg:npm/h@1", 6), + ("pkg:npm/a@1", 7), + ("pkg:npm/i@1", 8), + ("pkg:npm/f@1", 9), + ] + ); + assert_eq!( + plan.counts, + RolloutCounts { + new: 3, + deferred: 6, + upgrade: 0, + already: 0 + } + ); + assert_eq!(plan.remaining, Some(0)); + } + + #[test] + fn three_runs_roll_nine_packages_forward_and_a_fourth_changes_nothing() { + let mut state = nine(); + let mut landed: Vec> = Vec::new(); + for _ in 0..4 { + let plan = plan_rollout(state.clone(), &cap(3), false, &BTreeSet::new()); + let new: Vec = plan + .admitted + .iter() + .filter(|c| c.recorded.is_new()) + .map(|c| c.purl.clone()) + .collect(); + for c in &mut state { + if new.contains(&c.purl) { + c.recorded = Recorded::Same; + } + } + landed.push(new); + } + assert_eq!(landed[0], ["pkg:npm/b@1", "pkg:npm/e@1", "pkg:npm/g@1"]); + assert_eq!(landed[1], ["pkg:npm/c@1", "pkg:npm/d@1", "pkg:npm/h@1"]); + assert_eq!(landed[2], ["pkg:npm/a@1", "pkg:npm/f@1", "pkg:npm/i@1"]); + assert!(landed[3].is_empty()); + } + + #[test] + fn zero_admits_no_new_rows_but_keeps_upgrades_and_already() { + let mut rows = nine(); + rows[0].recorded = Recorded::Superseded { + old_uuid: "old".into(), + }; + rows[1].recorded = Recorded::Same; + rows[2].recorded = Recorded::Kept { uuid: "k".into() }; + let plan = plan_rollout(rows, &cap(0), false, &BTreeSet::new()); + assert_eq!( + admitted_purls(&plan), + ["pkg:npm/a@1", "pkg:npm/b@1", "pkg:npm/c@1"] + ); + assert_eq!(plan.counts.new, 0); + assert_eq!(plan.counts.deferred, 6); + assert_eq!(plan.counts.upgrade, 1); + assert_eq!(plan.counts.already, 2); + assert_eq!(plan.deferred[0].1, 1, "ranks start at 1 among NEW rows"); + } + + #[test] + fn a_cap_of_one_admits_exactly_the_top_package() { + let plan = plan_rollout(nine(), &cap(1), false, &BTreeSet::new()); + assert_eq!(admitted_purls(&plan), ["pkg:npm/e@1"]); + assert_eq!(plan.counts.deferred, 8); + } + + #[test] + fn unlimited_and_a_cap_above_the_supply_admit_everything() { + for max in [MaxNew::UNLIMITED, cap(50)] { + let plan = plan_rollout(nine(), &max, false, &BTreeSet::new()); + assert_eq!(plan.admitted.len(), 9); + assert!(plan.deferred.is_empty()); + assert_eq!(plan.counts.new, 9); + } + let plan = plan_rollout(nine(), &cap(50), false, &BTreeSet::new()); + assert_eq!(plan.remaining, Some(41)); + let plan = plan_rollout(nine(), &MaxNew::UNLIMITED, false, &BTreeSet::new()); + assert_eq!(plan.remaining, None); + } + + #[test] + fn ineligible_rows_hold_no_slot_and_are_not_reported_as_deferred() { + let mut rows = nine(); + // The top two by rank cannot land. + rows[4].eligible = false; // e + rows[1].eligible = false; // b + let plan = plan_rollout(rows, &cap(2), false, &BTreeSet::new()); + assert_eq!(admitted_purls(&plan), ["pkg:npm/c@1", "pkg:npm/g@1"]); + assert!(plan + .deferred + .iter() + .all(|(c, _)| c.purl != "pkg:npm/b@1" && c.purl != "pkg:npm/e@1")); + assert_eq!(plan.counts.deferred, 5); + assert_eq!(plan.deferred[0].1, 3); + } + + #[test] + fn incomplete_lookups_admit_nothing_new_under_a_cap() { + let mut rows = nine(); + rows[0].recorded = Recorded::Superseded { + old_uuid: "old".into(), + }; + let plan = plan_rollout(rows.clone(), &cap(3), true, &BTreeSet::new()); + assert_eq!(admitted_purls(&plan), ["pkg:npm/a@1"]); + assert_eq!(plan.counts.deferred, 8); + assert_eq!(plan.remaining, Some(3), "nothing was spent"); + // Without a cap there is no slot to steal. + let plan = plan_rollout(rows, &MaxNew::UNLIMITED, true, &BTreeSet::new()); + assert_eq!(plan.admitted.len(), 9); + } + + #[test] + fn one_package_across_roots_costs_one_slot() { + let rows = vec![ + row("services/api", "pkg:npm/qs@6.5.2", "u1", 1, 1), + row("services/web", "pkg:npm/qs@6.5.2", "u1", 1, 1), + row("", "pkg:npm/minimist@1.2.5", "u2", 0, 1), + row("", "pkg:npm/zzz@1.0.0", "u3", 3, 1), + ]; + let plan = plan_rollout(rows, &cap(2), false, &BTreeSet::new()); + assert_eq!( + admitted_purls(&plan), + [ + "pkg:npm/qs@6.5.2", + "pkg:npm/qs@6.5.2", + "pkg:npm/minimist@1.2.5" + ] + ); + assert_eq!(plan.counts.new, 2); + assert_eq!(deferred_purls(&plan), [("pkg:npm/zzz@1.0.0", 3)]); + } + + #[test] + fn qualifier_twins_share_a_base_purl_and_a_rank() { + assert_eq!( + canonical_base_purl("pkg:pypi/foo@1.0?artifact_id=abc"), + canonical_base_purl("pkg:pypi/foo@1.0?artifact_id=def") + ); + assert_eq!( + canonical_base_purl("pkg:npm/%40scope/x@1.0.0"), + "pkg:npm/@scope/x@1.0.0" + ); + let rows = vec![ + row("", "pkg:pypi/foo@1.0?artifact_id=whl", "u2", 1, 1), + row("", "pkg:pypi/foo@1.0?artifact_id=sdist", "u1", 1, 1), + row("", "pkg:pypi/bar@1.0", "u3", 0, 1), + ]; + let plan = plan_rollout(rows, &cap(1), false, &BTreeSet::new()); + assert_eq!(admitted_purls(&plan), ["pkg:pypi/bar@1.0"]); + assert_eq!( + deferred_purls(&plan), + [ + ("pkg:pypi/foo@1.0?artifact_id=sdist", 2), + ("pkg:pypi/foo@1.0?artifact_id=whl", 2) + ] + ); + assert_eq!(plan.counts.deferred, 1); + } + + #[test] + fn ties_across_ecosystems_break_by_ecosystem_name() { + let rows = vec![ + row("", "pkg:npm/x@1", "u1", 1, 1), + row("", "pkg:cargo/x@1", "u2", 1, 1), + row("", "pkg:pypi/x@1", "u3", 1, 1), + row("", "pkg:gem/x@1", "u4", 1, 1), + ]; + let plan = plan_rollout(rows, &cap(2), false, &BTreeSet::new()); + assert_eq!(admitted_purls(&plan), ["pkg:cargo/x@1", "pkg:gem/x@1"]); + assert_eq!( + deferred_purls(&plan), + [("pkg:npm/x@1", 3), ("pkg:pypi/x@1", 4)] + ); + } + + #[test] + fn in_flight_rows_go_first_whatever_their_severity() { + let mut rows = nine(); + rows[5].in_flight = true; // f, unknown severity + let plan = plan_rollout(rows, &cap(1), false, &BTreeSet::new()); + assert_eq!(admitted_purls(&plan), ["pkg:npm/f@1"]); + } + + #[test] + fn remaining_budget_carries_across_directories() { + let first = vec![ + row("a", "pkg:npm/x@1", "u1", 0, 1), + row("a", "pkg:npm/y@1", "u2", 1, 1), + ]; + let plan_a = plan_rollout(first, &cap(3), false, &BTreeSet::new()); + assert_eq!(plan_a.remaining, Some(1)); + let carried = MaxNew { + value: plan_a.remaining, + source: MaxNewSource::Flag, + }; + let second = vec![ + row("b", "pkg:npm/x@1", "u1", 0, 1), + row("b", "pkg:npm/z@1", "u3", 2, 1), + row("b", "pkg:npm/w@1", "u4", 3, 1), + ]; + let plan_b = plan_rollout(second, &carried, false, &plan_a.admitted_base_purls); + assert_eq!(admitted_purls(&plan_b), ["pkg:npm/x@1", "pkg:npm/z@1"]); + assert_eq!(plan_b.remaining, Some(0)); + assert_eq!(deferred_purls(&plan_b), [("pkg:npm/w@1", 3)]); + assert_eq!(plan_b.admitted_base_purls.len(), 3); + } + + #[test] + fn an_empty_plan_is_empty() { + let plan = plan_rollout(Vec::new(), &cap(3), true, &BTreeSet::new()); + assert!(plan.admitted.is_empty() && plan.deferred.is_empty()); + assert_eq!(plan.counts, RolloutCounts::default()); + assert_eq!(plan.remaining, Some(3)); + } + + #[test] + fn resolve_max_new_precedence_table() { + use MaxNewSource::*; + let cases: [( + Option>, + Option>, + Option, + Option, + Option, + MaxNewSource, + ); 10] = [ + (None, None, None, None, None, Default), + (None, None, Some(5), None, Some(5), File), + (None, Some(Some(2)), Some(5), None, Some(2), Env), + (Some(Some(1)), Some(Some(2)), Some(5), None, Some(1), Flag), + (Some(None), None, Some(5), None, None, Flag), + (None, Some(None), Some(5), None, None, Env), + (Some(None), None, None, Some(4), Some(4), Cap), + (None, None, None, Some(4), Some(4), Cap), + (Some(Some(9)), None, None, Some(4), Some(4), Cap), + (Some(Some(2)), None, None, Some(4), Some(2), Flag), + ]; + for (flag, env, file, cap, value, source) in cases { + assert_eq!( + resolve_max_new(flag, env, file, cap), + MaxNew { value, source }, + "flag={flag:?} env={env:?} file={file:?} cap={cap:?}" + ); + } + assert_eq!(resolve_max_new(None, None, Some(4), Some(4)).source, File); + } + + #[test] + fn severity_labels_follow_the_ladder() { + assert_eq!( + (0..=5).map(severity_label).collect::>(), + ["critical", "high", "medium", "low", "unknown", "unknown"] + ); + } +} From 4fe9c6c5deb165a0cf54985b1e453a432520155d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 13:58:04 +0000 Subject: [PATCH 06/22] Add the socket.yml selection policy to core New socket_patch_core::policy module: the SelectionPolicy, Offers and repo-root helpers that the staged-rollout plan freezes as the shared contract between the socket.yml work and the --max-new-patches work. It reads the repo root's socket.yml/socket.yaml strictly and fails closed: bad YAML, a misspelled `patches` block, unknown keys (with a did-you-mean hint), wrong types, empty allowlists, bad globs and anchors or aliases inside the keys we read are all errors that name the key path. Path lists match marker files with npm `ignore` semantics, walked top-down; a golden fixture generated from the npm package pins that. The built-in test/fixture ignores become overridable defaults for discovered roots. --package matching moves to core so scan and the policy share it. Claude-Session: https://claude.ai/code/session_01BKsyzefGhAnPkYmXCwq3H3 Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 127 +- Cargo.toml | 2 + .../socket-patch-cli/src/commands/scan/mod.rs | 38 +- crates/socket-patch-core/Cargo.toml | 2 + crates/socket-patch-core/src/lib.rs | 1 + crates/socket-patch-core/src/policy/mod.rs | 848 ++++++++++ crates/socket-patch-core/src/policy/paths.rs | 250 +++ .../src/policy/socket_yml.rs | 1195 ++++++++++++++ crates/socket-patch-core/src/policy/tests.rs | 587 +++++++ .../tests/fixtures/ignore_golden.json | 1379 +++++++++++++++++ scripts/gen-ignore-golden.mjs | 113 ++ 11 files changed, 4503 insertions(+), 39 deletions(-) create mode 100644 crates/socket-patch-core/src/policy/mod.rs create mode 100644 crates/socket-patch-core/src/policy/paths.rs create mode 100644 crates/socket-patch-core/src/policy/socket_yml.rs create mode 100644 crates/socket-patch-core/src/policy/tests.rs create mode 100644 crates/socket-patch-core/tests/fixtures/ignore_golden.json create mode 100644 scripts/gen-ignore-golden.mjs diff --git a/Cargo.lock b/Cargo.lock index 7b687168..eff61ddc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,17 @@ dependencies = [ "memchr", ] +[[package]] +name = "annotate-snippets" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f211a51805bc641f3ad5b7664c77d2547af685cc33b4cd8d31964027a46f13f1" +dependencies = [ + "anstyle", + "memchr", + "unicode-width", +] + [[package]] name = "anstream" version = "0.6.21" @@ -73,6 +84,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -122,6 +139,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -266,6 +293,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -404,6 +437,29 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "encoding_rs_io" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fba3fe847045ecff794b9c138293a80db914678c453ad63fbf0c6a9eb6e00b22" +dependencies = [ + "encoding_rs", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -628,6 +684,29 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +[[package]] +name = "globset" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "granit-parser" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e20f99e46474f56bd905c56e817ebddcf377a611f94c53ac4649e4d3fa3c0cd0" +dependencies = [ + "arraydeque", + "smallvec", +] + [[package]] name = "h2" version = "0.4.14" @@ -896,6 +975,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -1040,6 +1135,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "napi" version = "3.13.0" @@ -1390,9 +1491,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -1589,6 +1690,20 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-saphyr" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8050abb251097357e24aff63ba2c52a6309ecb7d23a5474023df960a02694d8" +dependencies = [ + "annotate-snippets", + "encoding_rs_io", + "granit-parser", + "num-traits", + "serde_core", + "smallvec", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -1742,6 +1857,12 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.12" @@ -1798,6 +1919,7 @@ dependencies = [ "fs2", "futures-util", "hex", + "ignore", "libc", "once_cell", "qbsdiff", @@ -1808,6 +1930,7 @@ dependencies = [ "self-replace", "semver", "serde", + "serde-saphyr", "serde_json", "serial_test", "sha1", diff --git a/Cargo.toml b/Cargo.toml index 6a95c787..2d241db0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,8 @@ serial_test = "=3.4.0" napi = { version = "=3.13.0", features = ["napi8", "tokio_rt"] } napi-derive = "=3.6.9" napi-build = "=2.5.0" +serde-saphyr = { version = "=1.3.0", default-features = false, features = ["deserialize"] } +ignore = "=0.4.33" [profile.release] strip = true diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index adaa869c..3bc5211b 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -374,43 +374,7 @@ pub struct ScanArgs { pub vex: VexEmbedArgs, } -/// Whether a `--package` spec names the package at `purl`: a purl spec -/// matches the same purl, or any version of it when it carries none; a -/// bare spec matches the package's full name (`@scope/pkg`, `group/name`) -/// or its last segment. Qualifiers are ignored and names compare -/// case-insensitively (PyPI, NuGet and Composer names are case-insensitive; -/// npm forbids uppercase). -pub(crate) fn package_spec_matches(spec: &str, purl: &str) -> bool { - let decoded = normalize_purl(strip_purl_qualifiers(purl)).to_lowercase(); - let spec = spec.trim().to_lowercase(); - if spec.is_empty() { - return false; - } - let Some(rest) = decoded.strip_prefix("pkg:") else { - return false; - }; - let Some((_eco, name_version)) = rest.split_once('/') else { - return false; - }; - let name = match name_version.rfind('@').filter(|&i| i > 0) { - Some(at) => &name_version[..at], - None => name_version, - }; - if let Some(spec_rest) = spec.strip_prefix("pkg:") { - let spec_purl = normalize_purl(strip_purl_qualifiers(&format!("pkg:{spec_rest}"))).to_lowercase(); - let spec_rest = &spec_purl[4..]; - let has_version = spec_rest - .split_once('/') - .is_some_and(|(_, nv)| nv.rfind('@').is_some_and(|i| i > 0)); - return if has_version { - decoded == spec_purl - } else { - decoded.strip_prefix(&spec_purl).is_some_and(|tail| tail.starts_with('@')) - }; - } - let spec = spec.replace(':', "/"); - name == spec || name.rsplit('/').next() == Some(spec.as_str()) -} +pub(crate) use socket_patch_core::policy::package_spec_matches; /// Embedded-VEX side-effect for `scan`'s JSON terminal returns. When /// `--vex` was requested and `base_code` is 0, generate the OpenVEX diff --git a/crates/socket-patch-core/Cargo.toml b/crates/socket-patch-core/Cargo.toml index 3add95f0..ab6ed3af 100644 --- a/crates/socket-patch-core/Cargo.toml +++ b/crates/socket-patch-core/Cargo.toml @@ -42,6 +42,8 @@ tempfile = { workspace = true } zip = { workspace = true } base64 = { workspace = true } semver = { workspace = true } +serde-saphyr = { workspace = true } +ignore = { workspace = true } [target.'cfg(unix)'.dependencies] libc = { workspace = true } diff --git a/crates/socket-patch-core/src/lib.rs b/crates/socket-patch-core/src/lib.rs index 04a2a6f9..ec1248d6 100644 --- a/crates/socket-patch-core/src/lib.rs +++ b/crates/socket-patch-core/src/lib.rs @@ -5,6 +5,7 @@ pub mod hash; pub mod manifest; pub mod package_json; pub mod patch; +pub mod policy; pub mod setup; pub mod telemetry; pub mod update; diff --git a/crates/socket-patch-core/src/policy/mod.rs b/crates/socket-patch-core/src/policy/mod.rs new file mode 100644 index 00000000..466d7f1c --- /dev/null +++ b/crates/socket-patch-core/src/policy/mod.rs @@ -0,0 +1,848 @@ +//! The repository's patch policy: the `patches` block and +//! `projectIgnorePaths` of the root `socket.yml`, plus the built-in default +//! path ignores. See `docs/design/staged-rollout.md` §3-§4. +//! +//! A policy only ever **narrows** what `scan` patches (trust boundary, +//! CLI_CONTRACT.md): nothing here names an endpoint, a credential, a mode +//! or a safety switch. Because it only narrows, an unreadable or invalid +//! file fails closed ([`PolicyError`]) instead of meaning "no policy". + +pub mod paths; +pub mod socket_yml; + +use std::collections::BTreeMap; +use std::io::Read; +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +use crate::api::ranking::max_severity_order; +use crate::api::types::PatchSearchResult; +use crate::crawlers::Ecosystem; +use crate::utils::purl::{normalize_purl, strip_purl_qualifiers}; + +use self::paths::{PathHit, PathMatcher}; +use self::socket_yml::{parse_file, ParsedFile, PatchesBlock}; + +pub use self::socket_yml::MAX_FILE_BYTES; + +/// Root file names, in the order they are read. +pub const POLICY_FILE_NAMES: [&str; 2] = ["socket.yml", "socket.yaml"]; + +/// Built-in ignores for discovered project roots (overridable with `!`). +pub const DEFAULT_IGNORE_PATHS: [&str; 5] = + ["test/", "tests/", "fixtures/", "__fixtures__/", "testdata/"]; + +/// List label of [`DEFAULT_IGNORE_PATHS`] in filter details. +pub const DEFAULT_IGNORE_LIST: &str = "built-in default"; + +pub const SOCKET_YML_INVALID: &str = "socket_yml_invalid"; +pub const SOCKET_YML_AMBIGUOUS: &str = "socket_yml_ambiguous"; +pub const SOCKET_YML_IGNORED_VALUE: &str = "socket_yml_ignored_value"; +pub const SOCKET_YML_NAME_CASE: &str = "socket_yml_name_case"; +pub const SOCKET_YML_REPO_UNTRUSTED: &str = "socket_yml_repo_untrusted"; +pub const PATCHES_DISABLED: &str = "patches_disabled"; +pub const POLICY_BYPASSED: &str = "policy_bypassed"; + +/// Longest file-derived string copied into output. +const MAX_OUTPUT_CHARS: usize = 200; + +/// Make a file-derived string safe to print: control characters dropped, +/// at most 200 characters. +pub fn sanitize(s: &str) -> String { + s.chars() + .filter(|c| !c.is_control()) + .take(MAX_OUTPUT_CHARS) + .collect() +} + +/// Where the policy came from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PolicySource { + /// No file, an empty file, a file-less scan (`--global`), or only a + /// case variant of the name. + None, + /// A root file was read; `path` is its name relative to the repo root. + File { path: String, sha256: String }, + /// `--no-socket-yml` / `SOCKET_NO_SOCKET_YML`. + Bypassed, +} + +impl PolicySource { + pub fn as_str(&self) -> &'static str { + match self { + PolicySource::None => "none", + PolicySource::File { .. } => "file", + PolicySource::Bypassed => "bypassed", + } + } +} + +/// Why a root, package or patch was filtered. Codes are stable. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FilterReason { + Disabled, + PathExcluded { + pattern: String, + list: &'static str, + }, + PathNotIncluded, + Ecosystem, + PackageNotListed, + PackageIgnored { + spec: String, + }, + Severity { + found: Option, + floor: String, + }, +} + +impl FilterReason { + pub fn code(&self) -> &'static str { + match self { + FilterReason::Disabled => "policy_disabled", + FilterReason::PathExcluded { .. } => "policy_path_excluded", + FilterReason::PathNotIncluded => "policy_path_not_included", + FilterReason::Ecosystem => "policy_ecosystem", + FilterReason::PackageNotListed => "policy_package_not_listed", + FilterReason::PackageIgnored { .. } => "policy_package_ignored", + FilterReason::Severity { .. } => "policy_severity", + } + } + + pub fn detail(&self) -> String { + match self { + FilterReason::Disabled => "patches.enabled is false".to_string(), + FilterReason::PathExcluded { pattern, list } => { + format!("{} ({list})", sanitize(pattern)) + } + FilterReason::PathNotIncluded => "not matched by patches.includePaths".to_string(), + FilterReason::Ecosystem => "ecosystem not in patches.ecosystems".to_string(), + FilterReason::PackageNotListed => "not in patches.packages".to_string(), + FilterReason::PackageIgnored { spec } => { + format!("{} (patches.ignorePackages)", sanitize(spec)) + } + FilterReason::Severity { found, floor } => { + format!("{} < {floor}", found.as_deref().unwrap_or("unknown")) + } + } + } +} + +/// A policy file that cannot be honored. Scan fails closed on it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PolicyError { + Invalid { + file: String, + key: String, + message: String, + }, + Ambiguous { + files: [String; 2], + }, +} + +impl PolicyError { + pub fn code(&self) -> &'static str { + match self { + PolicyError::Invalid { .. } => SOCKET_YML_INVALID, + PolicyError::Ambiguous { .. } => SOCKET_YML_AMBIGUOUS, + } + } + + /// The message without the remedy. + pub fn detail(&self) -> String { + match self { + PolicyError::Invalid { file, key, message } if key.is_empty() => format!("{file}: {message}"), + PolicyError::Invalid { file, key, message } => format!("{file}: {key}: {message}"), + PolicyError::Ambiguous { files } => format!( + "{} and {} both exist and their `patches`/`projectIgnorePaths` differ; keep one file", + files[0], files[1] + ), + } + } +} + +impl std::fmt::Display for PolicyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{} (fix the file, or pass --no-socket-yml to ignore it)", + self.detail() + ) + } +} + +impl std::error::Error for PolicyError {} + +/// A root file as the policy source sees it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RootFile { + Absent, + Present(Vec), + /// Listed, but its bytes were withheld (symlink, oversize, LFS pointer, + /// binary): never "absent", because the file may narrow the scan. + PresentWithoutContent, +} + +/// Read access to the repo root's policy files. +pub trait PolicyFs { + /// The root file named exactly `name`, read up to `cap + 1` bytes. + fn read_root_file(&self, name: &str, cap: usize) -> std::io::Result; + + /// Root entries whose names equal a policy file name only ignoring case. + fn case_variants(&self) -> Vec { + Vec::new() + } +} + +/// [`PolicyFs`] over a directory on disk (the repo root). +pub struct DiskPolicyFs { + root: PathBuf, +} + +impl DiskPolicyFs { + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + fn entry_names(&self) -> Vec { + std::fs::read_dir(&self.root) + .map(|entries| { + entries + .filter_map(|e| e.ok()) + .filter_map(|e| e.file_name().into_string().ok()) + .collect() + }) + .unwrap_or_default() + } +} + +#[cfg(unix)] +fn open_nonblocking(path: &Path) -> std::io::Result { + use std::os::unix::fs::OpenOptionsExt; + // O_NONBLOCK: opening a FIFO must not wait for a writer; the handle's + // metadata then refuses it. + std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NONBLOCK) + .open(path) +} + +#[cfg(not(unix))] +fn open_nonblocking(path: &Path) -> std::io::Result { + std::fs::File::open(path) +} + +fn io_other(message: &str) -> std::io::Error { + std::io::Error::other(message.to_string()) +} + +impl PolicyFs for DiskPolicyFs { + fn read_root_file(&self, name: &str, cap: usize) -> std::io::Result { + if !self.entry_names().iter().any(|n| n == name) { + return Ok(RootFile::Absent); + } + let path = self.root.join(name); + let link_meta = std::fs::symlink_metadata(&path)?; + if link_meta.file_type().is_symlink() { + let target = std::fs::canonicalize(&path)?; + let root = std::fs::canonicalize(&self.root)?; + if !target.starts_with(&root) { + return Err(io_other("symlink resolves outside the repository root")); + } + } + let file = open_nonblocking(&path)?; + let meta = file.metadata()?; + if !meta.is_file() { + return Err(io_other("not a regular file")); + } + let mut bytes = Vec::new(); + file.take(cap as u64 + 1).read_to_end(&mut bytes)?; + if bytes.len() > cap { + return Err(io_other(&format!("larger than {} KiB", cap / 1024))); + } + Ok(RootFile::Present(bytes)) + } + + fn case_variants(&self) -> Vec { + let mut out: Vec = self + .entry_names() + .into_iter() + .filter(|n| { + POLICY_FILE_NAMES + .iter() + .any(|p| n.eq_ignore_ascii_case(p) && n != p) + }) + .collect(); + out.sort(); + out + } +} + +/// [`PolicyFs`] over an in-memory tree root (the hosted engine). +#[derive(Debug, Clone, Default)] +pub struct MemoryPolicyFs { + pub files: BTreeMap, + /// Every root entry name the tree lists, for the case-variant warning. + pub root_names: Vec, +} + +impl PolicyFs for MemoryPolicyFs { + fn read_root_file(&self, name: &str, cap: usize) -> std::io::Result { + match self.files.get(name) { + None => Ok(RootFile::Absent), + Some(RootFile::Present(bytes)) if bytes.len() > cap => { + Err(io_other(&format!("larger than {} KiB", cap / 1024))) + } + Some(file) => Ok(file.clone()), + } + } + + fn case_variants(&self) -> Vec { + let mut out: Vec = self + .root_names + .iter() + .filter(|n| { + POLICY_FILE_NAMES + .iter() + .any(|p| n.eq_ignore_ascii_case(p) && n != p) + }) + .cloned() + .collect(); + out.sort(); + out.dedup(); + out + } +} + +/// Which layer set a scalar override. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OverrideSource { + Flag, + Env, +} + +/// The invoking user's overrides (trusted; they beat the file). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PolicyOverrides { + /// Skip the file entirely (built-in default ignores still apply). + pub bypass: bool, + /// `(None, _)` is `none`: no floor, even when the file sets one. + pub min_severity: Option<(Option, OverrideSource)>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PolicyWarning { + pub code: &'static str, + pub detail: String, +} + +/// Where the effective severity floor came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SeveritySource { + Flag, + Env, + File, + Default, +} + +impl SeveritySource { + pub fn as_str(&self) -> &'static str { + match self { + SeveritySource::Flag => "flag", + SeveritySource::Env => "env", + SeveritySource::File => "file", + SeveritySource::Default => "default", + } + } +} + +/// A project root, as the path filters see it. +#[derive(Debug, Clone, Copy)] +pub struct Root<'a> { + /// Repo-relative directory, `/` separators, `""` for the repo root. + pub rel_dir: &'a str, + /// The lockfile/manifest files the engine reads for this root, + /// relative to `rel_dir`. + pub markers: &'a [String], + /// Named by the user (never subject to the built-in default ignores). + pub explicit: bool, +} + +/// The effective selection policy of one invocation. +#[derive(Debug, Clone)] +pub struct SelectionPolicy { + source: PolicySource, + enabled: bool, + ignore_discovered: PathMatcher, + ignore_explicit: PathMatcher, + include: Option, + ecosystems: Option>, + packages: Option>, + ignore_packages: Vec, + min_severity: Option, + min_severity_source: SeveritySource, + max_new_patches: Option, +} + +/// Canonical severity name of an order (`moderate` reads as `medium`). +pub fn severity_name(order: u8) -> Option<&'static str> { + match order { + 0 => Some("critical"), + 1 => Some("high"), + 2 => Some("medium"), + 3 => Some("low"), + _ => None, + } +} + +/// Parse a `--min-severity` / `SOCKET_MIN_SEVERITY` value: a severity +/// name, or `none` for no floor. +pub fn parse_min_severity(value: &str) -> Result, String> { + if value.trim().eq_ignore_ascii_case("none") { + return Ok(None); + } + socket_yml::parse_severity_name(value) + .map(Some) + .ok_or_else(|| { + format!( + "invalid severity `{}`: expected critical, high, medium, moderate, low or none", + sanitize(value) + ) + }) +} + +/// The severity the floor judges a patch by: the worst severity across +/// the advisories it fixes (never `RankKey.severity`). +pub fn patch_severity_order(patch: &PatchSearchResult) -> u8 { + max_severity_order(patch.vulnerabilities.values().map(|v| v.severity.as_str())) +} + +fn defaults_list() -> Vec { + DEFAULT_IGNORE_PATHS.iter().map(|s| s.to_string()).collect() +} + +fn compile(lists: &[(&'static str, &[String])]) -> PathMatcher { + // Every list was compiled once during validation, so this cannot fail; + // an empty matcher would only ever admit more, which the validation + // already ruled out. + PathMatcher::new(lists) + .unwrap_or_else(|_| PathMatcher::new(&[]).expect("an empty pattern list always compiles")) +} + +impl SelectionPolicy { + /// No file: only the built-in default ignores. + pub fn unrestricted() -> Self { + Self::from_parts(PolicySource::None, &[], &PatchesBlock::default()) + } + + fn from_parts( + source: PolicySource, + project_ignore_paths: &[String], + block: &PatchesBlock, + ) -> Self { + let defaults = defaults_list(); + let ignore_discovered = compile(&[ + (DEFAULT_IGNORE_LIST, &defaults), + ("projectIgnorePaths", project_ignore_paths), + ("patches.ignorePaths", &block.ignore_paths), + ]); + let ignore_explicit = compile(&[ + ("projectIgnorePaths", project_ignore_paths), + ("patches.ignorePaths", &block.ignore_paths), + ]); + let include = block + .include_paths + .as_ref() + .map(|list| compile(&[("patches.includePaths", list)])); + Self { + source, + enabled: block.enabled.unwrap_or(true), + ignore_discovered, + ignore_explicit, + include, + ecosystems: block.ecosystems.clone(), + packages: block.packages.clone(), + ignore_packages: block.ignore_packages.clone(), + min_severity: block.min_severity, + min_severity_source: if block.min_severity.is_some() { + SeveritySource::File + } else { + SeveritySource::Default + }, + max_new_patches: block.max_new_patches, + } + } + + fn apply_overrides(&mut self, overrides: &PolicyOverrides) { + if let Some((value, source)) = overrides.min_severity { + self.min_severity = value; + self.min_severity_source = match source { + OverrideSource::Flag => SeveritySource::Flag, + OverrideSource::Env => SeveritySource::Env, + }; + } + } + + /// Read, validate and combine the repo root's policy files (4.4-4.5). + pub fn load( + fs: &dyn PolicyFs, + overrides: &PolicyOverrides, + ) -> Result<(Self, Vec), PolicyError> { + let mut warnings = Vec::new(); + if overrides.bypass { + let mut policy = + Self::from_parts(PolicySource::Bypassed, &[], &PatchesBlock::default()); + policy.apply_overrides(overrides); + return Ok((policy, warnings)); + } + for variant in fs.case_variants() { + warnings.push(PolicyWarning { + code: SOCKET_YML_NAME_CASE, + detail: format!( + "`{}` is not read: the policy file must be named exactly socket.yml or socket.yaml", + sanitize(&variant) + ), + }); + } + let mut files: Vec<(&'static str, Vec, ParsedFile)> = Vec::new(); + for name in POLICY_FILE_NAMES { + let invalid = |message: String| PolicyError::Invalid { + file: name.to_string(), + key: String::new(), + message, + }; + match fs.read_root_file(name, MAX_FILE_BYTES) { + Ok(RootFile::Absent) => {} + Ok(RootFile::PresentWithoutContent) => { + return Err(invalid( + "the file is listed but its content was not provided (symlink, oversize, LFS pointer or binary)" + .to_string(), + )) + } + Ok(RootFile::Present(bytes)) => { + if bytes.len() > MAX_FILE_BYTES { + return Err(invalid(format!("cannot read the file: larger than {} KiB", MAX_FILE_BYTES / 1024))); + } + let parsed = parse_file(name, &bytes, &mut warnings)?; + files.push((name, bytes, parsed)); + } + Err(e) => return Err(invalid(format!("cannot read the file: {e}"))), + } + } + if let [(first, _, a), (second, _, b)] = files.as_slice() { + if !a.same_policy(b) { + return Err(PolicyError::Ambiguous { + files: [first.to_string(), second.to_string()], + }); + } + } + let mut policy = match files.into_iter().next() { + Some((name, bytes, parsed)) if !parsed.empty => { + let source = PolicySource::File { + path: name.to_string(), + sha256: hex::encode(Sha256::digest(&bytes)), + }; + let block = parsed.patches.clone().unwrap_or_default(); + Self::from_parts(source, &parsed.project_ignore_paths, &block) + } + _ => Self::unrestricted(), + }; + policy.apply_overrides(overrides); + Ok((policy, warnings)) + } + + pub fn source(&self) -> &PolicySource { + &self.source + } + + /// `patches.enabled`. When false nothing is written (report only). + pub fn enabled(&self) -> bool { + self.enabled + } + + /// The effective severity floor and its source. + pub fn min_severity(&self) -> (Option, SeveritySource) { + (self.min_severity, self.min_severity_source) + } + + /// The file's `maxNewPatches`; `None` when there is no file, it was + /// bypassed, or the key is absent. + pub fn max_new_patches(&self) -> Option { + match self.source { + PolicySource::File { .. } => self.max_new_patches, + _ => None, + } + } + + /// Path filters for one project root (4.3): ignored iff every marker + /// is ignored; with `includePaths`, included iff any marker matches. + pub fn admits_root(&self, root: &Root) -> Result<(), FilterReason> { + let rel_dir = root.rel_dir.trim_matches('/'); + let subjects: Vec<(String, bool)> = if root.markers.is_empty() { + vec![(rel_dir.to_string(), true)] + } else { + root.markers + .iter() + .map(|m| { + let m = m.trim_start_matches('/'); + if rel_dir.is_empty() { + (m.to_string(), false) + } else { + (format!("{rel_dir}/{m}"), false) + } + }) + .collect() + }; + let ignore = if root.explicit { + &self.ignore_explicit + } else { + &self.ignore_discovered + }; + let mut first_hit: Option = None; + let mut all_ignored = true; + for (path, is_dir) in &subjects { + match ignore.check(path, *is_dir) { + Some(hit) => { + first_hit.get_or_insert(hit); + } + None => { + all_ignored = false; + break; + } + } + } + if all_ignored { + if let Some(hit) = first_hit { + return Err(FilterReason::PathExcluded { + pattern: sanitize(&hit.pattern), + list: hit.list, + }); + } + } + if let Some(include) = &self.include { + if !subjects + .iter() + .any(|(path, is_dir)| include.check(path, *is_dir).is_some()) + { + return Err(FilterReason::PathNotIncluded); + } + } + Ok(()) + } + + /// Ecosystem and package filters for one package (deny wins). + pub fn admits_purl(&self, purl: &str) -> Result<(), FilterReason> { + if let Some(ecosystems) = &self.ecosystems { + let eco = Ecosystem::from_purl(purl).map(|e| e.cli_name()); + if !eco.is_some_and(|e| ecosystems.iter().any(|x| x == e)) { + return Err(FilterReason::Ecosystem); + } + } + if let Some(spec) = self + .ignore_packages + .iter() + .find(|s| package_spec_matches(s, purl)) + { + return Err(FilterReason::PackageIgnored { + spec: sanitize(spec), + }); + } + if let Some(packages) = &self.packages { + if !packages.iter().any(|s| package_spec_matches(s, purl)) { + return Err(FilterReason::PackageNotListed); + } + } + Ok(()) + } + + /// The severity floor for one patch (`severity_order` ranks, unknown + /// = 4 is filtered whenever a floor is set). + pub fn admits_severity(&self, severity_order: u8) -> Result<(), FilterReason> { + let Some(floor) = self.min_severity else { + return Ok(()); + }; + if severity_order <= floor && severity_name(severity_order).is_some() { + return Ok(()); + } + Err(FilterReason::Severity { + found: severity_name(severity_order).map(str::to_string), + floor: severity_name(floor).unwrap_or("unknown").to_string(), + }) + } + + /// Split offers by the severity floor, keeping order. + pub fn floor_filter( + &self, + offers: Vec, + ) -> ( + Vec, + Vec<(PatchSearchResult, FilterReason)>, + ) { + let mut admitted = Vec::with_capacity(offers.len()); + let mut filtered = Vec::new(); + for offer in offers { + match self.admits_severity(patch_severity_order(&offer)) { + Ok(()) => admitted.push(offer), + Err(reason) => filtered.push((offer, reason)), + } + } + (admitted, filtered) + } +} + +/// The step 5 → 7 seam: every offer per purl (after the tier filter) and +/// the winner among the floor-admitted ones. +#[derive(Debug, Clone, Default)] +pub struct Offers { + pub unfiltered: BTreeMap>, + pub selected: BTreeMap, +} + +/// Whether a `--package` spec names the package at `purl`: a purl spec +/// matches the same purl, or any version of it when it carries none; a +/// bare spec matches the package's full name (`@scope/pkg`, `group/name`) +/// or its last segment. Qualifiers are ignored and names compare +/// case-insensitively (PyPI, NuGet and Composer names are case-insensitive; +/// npm forbids uppercase). +pub fn package_spec_matches(spec: &str, purl: &str) -> bool { + let decoded = normalize_purl(strip_purl_qualifiers(purl)).to_lowercase(); + let spec = spec.trim().to_lowercase(); + if spec.is_empty() { + return false; + } + let Some(rest) = decoded.strip_prefix("pkg:") else { + return false; + }; + let Some((_eco, name_version)) = rest.split_once('/') else { + return false; + }; + let name = match name_version.rfind('@').filter(|&i| i > 0) { + Some(at) => &name_version[..at], + None => name_version, + }; + if let Some(spec_rest) = spec.strip_prefix("pkg:") { + let spec_purl = + normalize_purl(strip_purl_qualifiers(&format!("pkg:{spec_rest}"))).to_lowercase(); + let spec_rest = &spec_purl[4..]; + let has_version = spec_rest + .split_once('/') + .is_some_and(|(_, nv)| nv.rfind('@').is_some_and(|i| i > 0)); + return if has_version { + decoded == spec_purl + } else { + decoded + .strip_prefix(&spec_purl) + .is_some_and(|tail| tail.starts_with('@')) + }; + } + let spec = spec.replace(':', "/"); + name == spec || name.rsplit('/').next() == Some(spec.as_str()) +} + +fn home_dir() -> Option { + let var = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; + std::env::var_os(var) + .filter(|v| !v.is_empty()) + .map(PathBuf::from) + .map(|p| std::fs::canonicalize(&p).unwrap_or(p)) +} + +fn ceiling_dirs() -> Vec { + std::env::var_os("GIT_CEILING_DIRECTORIES") + .map(|v| { + std::env::split_paths(&v) + .filter(|p| !p.as_os_str().is_empty()) + .map(|p| std::fs::canonicalize(&p).unwrap_or(p)) + .collect() + }) + .unwrap_or_default() +} + +#[cfg(unix)] +fn trusted_owner(meta: &std::fs::Metadata) -> bool { + use std::os::unix::fs::MetadataExt; + // SAFETY: geteuid has no preconditions and cannot fail. + owner_trusted(meta.uid(), unsafe { libc::geteuid() }) +} + +#[cfg(unix)] +fn owner_trusted(owner: u32, euid: u32) -> bool { + owner == euid || owner == 0 +} + +#[cfg(not(unix))] +fn trusted_owner(_meta: &std::fs::Metadata) -> bool { + true +} + +/// The repo root for `cwd` (4.5) with the lookup's warnings: the nearest +/// ancestor (inclusive) holding a `.git` directory or file, not walking +/// past `GIT_CEILING_DIRECTORIES` or into the home directory, and (Unix) +/// only when `.git` belongs to the current user or root. Otherwise `cwd`. +pub fn find_repo_root_with_warnings(cwd: &Path) -> (PathBuf, Vec) { + let cwd = std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf()); + let ceilings = ceiling_dirs(); + let home = home_dir(); + let mut warnings = Vec::new(); + let mut dir: &Path = &cwd; + loop { + if dir != cwd && home.as_deref() == Some(dir) { + break; + } + if let Ok(meta) = std::fs::symlink_metadata(dir.join(".git")) { + if meta.is_dir() || meta.is_file() { + if trusted_owner(&meta) { + return (dir.to_path_buf(), warnings); + } + warnings.push(PolicyWarning { + code: SOCKET_YML_REPO_UNTRUSTED, + detail: format!( + "{} is owned by another user; using {} as the repository root", + dir.join(".git").display(), + cwd.display() + ), + }); + break; + } + } + let Some(parent) = dir.parent() else { break }; + if ceilings.iter().any(|c| c == parent) { + break; + } + dir = parent; + } + (cwd.clone(), warnings) +} + +/// [`find_repo_root_with_warnings`] without the warnings. +pub fn find_repo_root(cwd: &Path) -> PathBuf { + find_repo_root_with_warnings(cwd).0 +} + +/// `dir` relative to `repo_root` with `/` separators (`""` for the root +/// itself); `None` when `dir` is not inside it. +pub fn repo_relative_checked(repo_root: &Path, dir: &Path) -> Option { + let rel = dir.strip_prefix(repo_root).ok()?; + let mut parts = Vec::new(); + for component in rel.components() { + match component { + std::path::Component::Normal(part) => parts.push(part.to_string_lossy().into_owned()), + std::path::Component::CurDir => {} + _ => return None, + } + } + Some(parts.join("/")) +} + +/// [`repo_relative_checked`], falling back to `dir` itself (with `/` +/// separators) when it is outside the root. +pub fn repo_relative(repo_root: &Path, dir: &Path) -> String { + repo_relative_checked(repo_root, dir) + .unwrap_or_else(|| dir.to_string_lossy().replace('\\', "/")) +} + +#[cfg(test)] +mod tests; diff --git a/crates/socket-patch-core/src/policy/paths.rs b/crates/socket-patch-core/src/policy/paths.rs new file mode 100644 index 00000000..78219ae7 --- /dev/null +++ b/crates/socket-patch-core/src/policy/paths.rs @@ -0,0 +1,250 @@ +//! Path lists of the `socket.yml` patch policy: gitignore patterns matched +//! the way the npm `ignore` package (the backend's `projectIgnorePaths` +//! matcher) matches them. +//! +//! Evaluation walks top-down: for `a/b/c.lock` the matcher tests `a/`, +//! then `a/b/`, then the file, and the first ignored ancestor decides. A +//! negation can therefore never re-include anything under an ignored +//! directory, as in git and npm `ignore`. `ignore::gitignore`'s +//! `matched_path_or_any_parents` walks bottom-up and would re-include, so +//! it is not used. + +use std::path::PathBuf; + +use ignore::gitignore::{Gitignore, GitignoreBuilder}; +use ignore::Match; + +/// Longest accepted pattern, in bytes. +pub(crate) const MAX_PATTERN_BYTES: usize = 1024; + +/// Why a pattern is refused before it reaches the glob compiler. +pub(crate) fn pattern_hygiene_error(pattern: &str) -> Option { + if pattern.len() > MAX_PATTERN_BYTES { + return Some(format!("pattern is longer than {MAX_PATTERN_BYTES} bytes")); + } + if pattern.contains('\0') { + return Some("pattern contains a NUL byte".to_string()); + } + if pattern.trim().is_empty() { + return Some("pattern is empty".to_string()); + } + let body = pattern.strip_prefix('!').unwrap_or(pattern); + let body_no_slash = body.strip_prefix('/').unwrap_or(body); + let bytes = body_no_slash.as_bytes(); + if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' { + return Some("pattern starts with a drive letter; paths are repo-relative".to_string()); + } + if body.split('/').any(|segment| segment == "..") { + return Some("pattern contains a `..` segment; paths are repo-relative".to_string()); + } + None +} + +/// One compiled, ordered pattern list (several named source lists joined; +/// the last matching pattern wins within a path). +#[derive(Clone, Debug)] +pub(crate) struct PathMatcher { + gitignore: Gitignore, + list_names: Vec<&'static str>, +} + +/// The pattern that decided a path, and the list it came from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PathHit { + pub pattern: String, + pub list: &'static str, +} + +impl PathMatcher { + /// Compile `lists` in order. The error names the list and the pattern. + pub(crate) fn new( + lists: &[(&'static str, &[String])], + ) -> Result { + let mut builder = GitignoreBuilder::new(""); + // Never fails in ignore 0.4 (the Result is historical). + let _ = builder.case_insensitive(true); + builder.allow_unclosed_class(false); + let mut list_names = Vec::with_capacity(lists.len()); + for (name, patterns) in lists { + list_names.push(*name); + for pattern in patterns.iter() { + if let Some(message) = pattern_hygiene_error(pattern) { + return Err((name, pattern.clone(), message)); + } + if let Err(e) = builder.add_line(Some(PathBuf::from(*name)), pattern) { + return Err((name, pattern.clone(), format!("invalid pattern: {e}"))); + } + } + } + let gitignore = builder.build().map_err(|e| { + ( + lists.last().map_or("", |l| l.0), + String::new(), + e.to_string(), + ) + })?; + Ok(Self { + gitignore, + list_names, + }) + } + + fn hit(&self, glob: &ignore::gitignore::Glob) -> PathHit { + let list = glob + .from() + .and_then(|from| { + self.list_names + .iter() + .copied() + .find(|name| from == std::path::Path::new(name)) + }) + .unwrap_or(""); + PathHit { + pattern: glob.original().to_string(), + list, + } + } + + /// The pattern that matches the repo-relative `path` (`/` separators, + /// no leading `/`), walking its ancestors top-down; `None` when nothing + /// matches or a negation has the last word. + pub(crate) fn check(&self, path: &str, is_dir: bool) -> Option { + let path = path.trim_matches('/'); + if path.is_empty() || self.gitignore.is_empty() { + return None; + } + let segments: Vec<&str> = path.split('/').collect(); + for end in 1..segments.len() { + let ancestor = segments[..end].join("/"); + if let Match::Ignore(glob) = self.gitignore.matched(&ancestor, true) { + return Some(self.hit(glob)); + } + } + match self.gitignore.matched(path, is_dir) { + Match::Ignore(glob) => Some(self.hit(glob)), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn matcher(patterns: &[&str]) -> PathMatcher { + let owned: Vec = patterns.iter().map(|p| p.to_string()).collect(); + PathMatcher::new(&[("test", &owned)]).expect("patterns compile") + } + + #[derive(serde::Deserialize)] + struct GoldenCase { + patterns: Vec, + path: String, + ignored: bool, + } + + #[derive(serde::Deserialize)] + struct Golden { + generator: String, + cases: Vec, + } + + #[test] + fn agrees_with_npm_ignore_golden_fixture() { + let text = include_str!("../../tests/fixtures/ignore_golden.json"); + let golden: Golden = serde_json::from_str(text).expect("golden fixture parses"); + assert!(golden.generator.starts_with("ignore@")); + assert!(golden.cases.len() > 1000); + let mut mismatches = Vec::new(); + for case in &golden.cases { + let m = PathMatcher::new(&[("test", &case.patterns)]).expect("golden patterns compile"); + let got = m.check(&case.path, false).is_some(); + if got != case.ignored { + mismatches.push(format!( + "{:?} on {:?}: npm ignore says {}, we say {}", + case.patterns, case.path, case.ignored, got + )); + } + } + assert!( + mismatches.is_empty(), + "{} mismatches:\n{}", + mismatches.len(), + mismatches.join("\n") + ); + } + + #[test] + fn excluded_parent_cannot_be_reincluded() { + let m = matcher(&["fixtures/", "!/a/fixtures/keep/"]); + let hit = m + .check("a/fixtures/keep/yarn.lock", false) + .expect("still ignored"); + assert_eq!(hit.pattern, "fixtures/"); + assert_eq!(hit.list, "test"); + } + + #[test] + fn negation_of_the_directory_itself_reincludes() { + let m = matcher(&["tests/", "!/e2e/tests/"]); + assert!(m.check("e2e/tests/package-lock.json", false).is_none()); + assert!(m.check("x/tests/package-lock.json", false).is_some()); + } + + #[test] + fn case_insensitive_and_anchoring() { + let m = matcher(&["/legacy/"]); + assert!(m.check("Legacy/requirements.txt", false).is_some()); + assert!(m.check("x/legacy/requirements.txt", false).is_none()); + let bare = matcher(&["legacy/"]); + assert!(bare.check("x/legacy/requirements.txt", false).is_some()); + } + + #[test] + fn trailing_slash_only_matches_directories() { + let m = matcher(&["package-lock.json/"]); + assert!(m.check("package-lock.json", false).is_none()); + assert!(m.check("package-lock.json/x", false).is_some()); + } + + #[test] + fn reports_the_list_of_the_deciding_pattern() { + let a = vec!["tests/".to_string()]; + let b = vec!["/legacy/".to_string()]; + let m = PathMatcher::new(&[("defaults", &a), ("patches.ignorePaths", &b)]).unwrap(); + assert_eq!( + m.check("legacy/x.lock", false).unwrap().list, + "patches.ignorePaths" + ); + assert_eq!(m.check("a/tests/x.lock", false).unwrap().list, "defaults"); + } + + #[test] + fn hygiene_rejects_unsafe_patterns() { + for bad in [ + "../x", "a/../b", "!../x", "C:/x", "/c:/x", "a\0b", "", " ", + ] { + assert!( + pattern_hygiene_error(bad).is_some(), + "{bad:?} must be rejected" + ); + } + let long = "a".repeat(MAX_PATTERN_BYTES + 1); + assert!(pattern_hygiene_error(&long).is_some()); + for good in ["/a/", "..a/", "a..b/", "**/x", "!/e2e/tests/", "ab:c"] { + assert!( + pattern_hygiene_error(good).is_none(), + "{good:?} must be accepted" + ); + } + } + + #[test] + fn bad_glob_is_an_error_naming_the_pattern() { + let bad = vec!["a/[b".to_string()]; + let err = PathMatcher::new(&[("patches.ignorePaths", &bad)]) + .expect_err("unclosed class rejected"); + assert_eq!(err.0, "patches.ignorePaths"); + assert_eq!(err.1, "a/[b"); + } +} diff --git a/crates/socket-patch-core/src/policy/socket_yml.rs b/crates/socket-patch-core/src/policy/socket_yml.rs new file mode 100644 index 00000000..b434240d --- /dev/null +++ b/crates/socket-patch-core/src/policy/socket_yml.rs @@ -0,0 +1,1195 @@ +//! Parser and strict validator for the parts of `socket.yml` socket-patch +//! reads: `version`, `projectIgnorePaths` and the `patches` block. +//! +//! The file is read as a YAML 1.2 event stream (serde-saphyr's parser) +//! into a small node tree. Aliases are never expanded: an alias inside the +//! keys we read is refused, and one anywhere else is left alone, so an +//! alias bomb cannot cost anything. Plain scalars resolve with the YAML 1.2 +//! core schema (`no` is a string, `"false"` is not a bool). + +use std::collections::HashSet; + +use serde_saphyr::granit_parser::{Event, Options, Parser, ScalarStyle, Tag}; + +use super::paths::{PathMatcher, MAX_PATTERN_BYTES}; +use super::{sanitize, PolicyError, PolicyWarning}; +use crate::api::ranking::severity_order; +use crate::crawlers::Ecosystem; + +/// Largest accepted file, in bytes. +pub const MAX_FILE_BYTES: usize = 64 * 1024; +const MAX_DEPTH: usize = 32; +const MAX_LIST_ENTRIES: usize = 1000; + +pub(crate) const PATCHES_KEYS: [&str; 8] = [ + "enabled", + "includePaths", + "ignorePaths", + "ecosystems", + "packages", + "ignorePackages", + "minSeverity", + "maxNewPatches", +]; + +#[derive(Debug, Clone, PartialEq)] +enum Scalar { + Null, + Bool(bool), + Int(i128), + /// A float, or an integer too large for `i128`. + Number, + Str(String), +} + +#[derive(Debug)] +enum Kind { + Scalar { + value: Scalar, + raw: String, + plain: bool, + }, + Seq(Vec), + Map(Vec<(Node, Node)>), + Alias, +} + +#[derive(Debug)] +struct Node { + kind: Kind, + anchored: bool, + custom_tag: bool, +} + +impl Node { + fn as_str(&self) -> Option<&str> { + match &self.kind { + Kind::Scalar { + value: Scalar::Str(s), + .. + } => Some(s), + _ => None, + } + } + + fn is_merge_key(&self) -> bool { + matches!(&self.kind, Kind::Scalar { raw, plain: true, .. } if raw == "<<") + } + + fn describe(&self) -> &'static str { + match &self.kind { + Kind::Scalar { value, .. } => match value { + Scalar::Null => "null", + Scalar::Bool(_) => "a boolean", + Scalar::Int(_) | Scalar::Number => "a number", + Scalar::Str(_) => "a string", + }, + Kind::Seq(_) => "a list", + Kind::Map(_) => "a mapping", + Kind::Alias => "an alias", + } + } +} + +fn is_int_body(s: &str) -> bool { + !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) +} + +fn is_float(s: &str) -> bool { + let lower = s.to_ascii_lowercase(); + let unsigned = lower.trim_start_matches(['-', '+']); + if matches!(unsigned, ".inf") || lower == ".nan" { + return true; + } + let (mantissa, exponent) = match unsigned.split_once('e') { + Some((m, e)) => (m, Some(e)), + None => (unsigned, None), + }; + let mantissa_ok = match mantissa.split_once('.') { + Some((whole, frac)) => { + (whole.is_empty() || is_int_body(whole)) + && (frac.is_empty() || is_int_body(frac)) + && !(whole.is_empty() && frac.is_empty()) + } + None => is_int_body(mantissa), + }; + let exponent_ok = exponent.is_none_or(|e| is_int_body(e.trim_start_matches(['-', '+']))); + mantissa_ok && exponent_ok && (mantissa.contains('.') || exponent.is_some()) +} + +/// YAML 1.2 core-schema resolution of a plain scalar. +fn resolve_plain(raw: &str) -> Scalar { + match raw { + "" | "~" | "null" | "Null" | "NULL" => return Scalar::Null, + "true" | "True" | "TRUE" => return Scalar::Bool(true), + "false" | "False" | "FALSE" => return Scalar::Bool(false), + _ => {} + } + let (negative, unsigned) = match raw.as_bytes().first() { + Some(b'-') => (true, &raw[1..]), + Some(b'+') => (false, &raw[1..]), + _ => (false, raw), + }; + if is_int_body(unsigned) { + return match unsigned.parse::() { + Ok(n) => Scalar::Int(if negative { -n } else { n }), + Err(_) => Scalar::Number, + }; + } + if let Some(hex) = raw.strip_prefix("0x") { + if !hex.is_empty() && hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return i128::from_str_radix(hex, 16).map_or(Scalar::Number, Scalar::Int); + } + } + if let Some(oct) = raw.strip_prefix("0o") { + if !oct.is_empty() && oct.bytes().all(|b| (b'0'..=b'7').contains(&b)) { + return i128::from_str_radix(oct, 8).map_or(Scalar::Number, Scalar::Int); + } + } + if is_float(raw) { + return Scalar::Number; + } + Scalar::Str(raw.to_string()) +} + +/// Resolve a scalar with its tag. `Err` is an explicit core tag the value +/// does not fit (`!!int abc`). +fn resolve_scalar( + raw: &str, + style: ScalarStyle, + tag: Option<&Tag>, +) -> Result<(Scalar, bool), String> { + let core = tag.and_then(|t| t.core_suffix().map(str::to_string)); + let custom = tag.is_some() && core.is_none(); + let plain = style == ScalarStyle::Plain; + let value = match core.as_deref() { + Some("str") => Scalar::Str(raw.to_string()), + Some(kind @ ("int" | "bool" | "null" | "float")) => { + let resolved = resolve_plain(raw); + let fits = matches!( + (kind, &resolved), + ("int", Scalar::Int(_)) + | ("bool", Scalar::Bool(_)) + | ("null", Scalar::Null) + | ("float", Scalar::Number | Scalar::Int(_)) + ); + if !fits { + return Err(format!("`{}` is not a valid !!{kind}", sanitize(raw))); + } + resolved + } + _ if plain && tag.is_none() => resolve_plain(raw), + _ => Scalar::Str(raw.to_string()), + }; + Ok((value, custom)) +} + +enum Frame { + Seq(Node, Vec), + Map(Node, Vec<(Node, Node)>, Option, HashSet), +} + +fn key_identity(key: &Node) -> Option { + match &key.kind { + Kind::Scalar { value, .. } => Some(format!("{value:?}")), + _ => None, + } +} + +/// Parse `text` into one document's root node; `None` for an empty or +/// comment-only stream. +fn build_tree(text: &str) -> Result, String> { + let mut options = Options::default(); + options.emit_comments = false; + let parser = Parser::new_from_str_with_options(text, options); + let mut stack: Vec = Vec::new(); + let mut root: Option = None; + let mut documents = 0usize; + + fn attach(stack: &mut [Frame], root: &mut Option, node: Node) -> Result<(), String> { + match stack.last_mut() { + None => { + *root = Some(node); + Ok(()) + } + Some(Frame::Seq(_, items)) => { + items.push(node); + Ok(()) + } + Some(Frame::Map(_, pairs, pending, seen)) => match pending.take() { + None => { + *pending = Some(node); + Ok(()) + } + Some(key) => { + if let Some(id) = key_identity(&key) { + if !seen.insert(id) { + let name = match &key.kind { + Kind::Scalar { raw, .. } => sanitize(raw), + _ => String::new(), + }; + return Err(format!("duplicate key `{name}`")); + } + } + pairs.push((key, node)); + Ok(()) + } + }, + } + } + + for event in parser { + let (event, span) = event.map_err(|e| e.to_string())?; + let line = span.start.line(); + let header = |anchor: usize, tag: Option<&Tag>| Node { + kind: Kind::Alias, + anchored: anchor != 0, + custom_tag: tag.is_some_and(|t| t.core_suffix().is_none()), + }; + match event { + Event::StreamStart | Event::StreamEnd | Event::DocumentEnd | Event::Comment(..) => {} + Event::DocumentStart(..) => { + documents += 1; + if documents > 1 { + return Err(format!("more than one YAML document (line {line})")); + } + } + Event::Alias(_) => { + let node = Node { + kind: Kind::Alias, + anchored: false, + custom_tag: false, + }; + attach(&mut stack, &mut root, node)?; + } + Event::Scalar(raw, style, anchor, tag) => { + let (value, custom_tag) = resolve_scalar(&raw, style, tag.as_deref()) + .map_err(|m| format!("{m} (line {line})"))?; + let node = Node { + kind: Kind::Scalar { + value, + raw: raw.into_owned(), + plain: style == ScalarStyle::Plain, + }, + anchored: anchor != 0, + custom_tag, + }; + attach(&mut stack, &mut root, node)?; + } + Event::SequenceStart(_, anchor, tag) => { + if stack.len() >= MAX_DEPTH { + return Err(format!( + "nesting is deeper than {MAX_DEPTH} levels (line {line})" + )); + } + stack.push(Frame::Seq(header(anchor, tag.as_deref()), Vec::new())); + } + Event::MappingStart(_, anchor, tag) => { + if stack.len() >= MAX_DEPTH { + return Err(format!( + "nesting is deeper than {MAX_DEPTH} levels (line {line})" + )); + } + stack.push(Frame::Map( + header(anchor, tag.as_deref()), + Vec::new(), + None, + HashSet::new(), + )); + } + Event::SequenceEnd => { + let Some(Frame::Seq(mut node, items)) = stack.pop() else { + return Err("unbalanced sequence".to_string()); + }; + node.kind = Kind::Seq(items); + attach(&mut stack, &mut root, node)?; + } + Event::MappingEnd => { + let Some(Frame::Map(mut node, pairs, _, _)) = stack.pop() else { + return Err("unbalanced mapping".to_string()); + }; + node.kind = Kind::Map(pairs); + attach(&mut stack, &mut root, node)?; + } + _ => return Err(format!("unsupported YAML construct (line {line})")), + } + } + Ok(root) +} + +/// The validated `patches` block. `None` list fields mean "absent". +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub(crate) struct PatchesBlock { + pub enabled: Option, + pub include_paths: Option>, + pub ignore_paths: Vec, + pub ecosystems: Option>, + pub packages: Option>, + pub ignore_packages: Vec, + pub min_severity: Option, + pub max_new_patches: Option, +} + +/// What one root file contributes. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub(crate) struct ParsedFile { + /// Empty, comment-only or null document: the file counts as absent. + pub empty: bool, + pub patches: Option, + pub project_ignore_paths: Vec, +} + +impl ParsedFile { + /// The parts two files must agree on when both exist. + pub(crate) fn same_policy(&self, other: &ParsedFile) -> bool { + self.patches == other.patches && self.project_ignore_paths == other.project_ignore_paths + } +} + +/// Levenshtein distance, for did-you-mean hints. +fn edit_distance(a: &str, b: &str) -> usize { + let b: Vec = b.chars().collect(); + let mut row: Vec = (0..=b.len()).collect(); + for (i, ca) in a.chars().enumerate() { + let mut prev = row[0]; + row[0] = i + 1; + for (j, cb) in b.iter().enumerate() { + let cur = row[j + 1]; + row[j + 1] = if ca == *cb { + prev + } else { + 1 + prev.min(cur).min(row[j]) + }; + prev = cur; + } + } + row[b.len()] +} + +fn did_you_mean<'a>(input: &str, known: impl IntoIterator) -> Option<&'a str> { + let lower = input.to_lowercase(); + known + .into_iter() + .map(|k| (edit_distance(&lower, &k.to_lowercase()), k)) + .filter(|(d, _)| *d <= 2) + .min_by_key(|(d, _)| *d) + .map(|(_, k)| k) +} + +struct Ctx<'a> { + file: &'a str, +} + +impl Ctx<'_> { + fn err(&self, key: impl Into, message: impl Into) -> PolicyError { + PolicyError::Invalid { + file: self.file.to_string(), + key: key.into(), + message: message.into(), + } + } +} + +/// Refuse anchors, aliases, merge keys and custom tags anywhere in `node`. +fn check_plain_subtree(node: &Node, path: &str) -> Result<(), (String, String)> { + if node.anchored { + return Err(( + path.to_string(), + "YAML anchors are not allowed here".to_string(), + )); + } + if node.custom_tag { + return Err(( + path.to_string(), + "custom YAML tags are not allowed here".to_string(), + )); + } + match &node.kind { + Kind::Alias => Err(( + path.to_string(), + "YAML aliases are not allowed here".to_string(), + )), + Kind::Scalar { .. } => Ok(()), + Kind::Seq(items) => { + for (i, item) in items.iter().enumerate() { + check_plain_subtree(item, &format!("{path}[{i}]"))?; + } + Ok(()) + } + Kind::Map(pairs) => { + for (key, value) in pairs { + if key.is_merge_key() { + return Err(( + path.to_string(), + "YAML merge keys (`<<`) are not allowed here".to_string(), + )); + } + let name = key.as_str().map(sanitize).unwrap_or_default(); + let child = if path.is_empty() { + name + } else { + format!("{path}.{name}") + }; + check_plain_subtree(key, &child)?; + check_plain_subtree(value, &child)?; + } + Ok(()) + } + } +} + +/// A list of strings, with the size limits every list key shares. +fn string_list(node: &Node, key: &str) -> Result, (String, String)> { + let Kind::Seq(items) = &node.kind else { + return Err(( + key.to_string(), + format!("must be a list of strings, found {}", node.describe()), + )); + }; + if items.len() > MAX_LIST_ENTRIES { + return Err(( + key.to_string(), + format!("has more than {MAX_LIST_ENTRIES} entries"), + )); + } + let mut out = Vec::with_capacity(items.len()); + for (i, item) in items.iter().enumerate() { + let path = format!("{key}[{i}]"); + let Some(s) = item.as_str() else { + return Err(( + path, + format!("must be a string, found {} (quote it)", item.describe()), + )); + }; + if s.len() > MAX_PATTERN_BYTES { + return Err((path, format!("is longer than {MAX_PATTERN_BYTES} bytes"))); + } + out.push(s.to_string()); + } + Ok(out) +} + +/// Compile a pattern list so a bad glob fails here, with its key path. +fn check_patterns(patterns: &[String], key: &'static str) -> Result<(), (String, String)> { + PathMatcher::new(&[(key, patterns)]) + .map(|_| ()) + .map_err(|(_, pattern, message)| { + let index = patterns.iter().position(|p| *p == pattern); + let path = index.map_or_else(|| key.to_string(), |i| format!("{key}[{i}]")); + (path, format!("{message}: `{}`", sanitize(&pattern))) + }) +} + +/// Why a `--package`-grammar spec is invalid, if it is. +pub(crate) fn package_spec_error(spec: &str) -> Option<&'static str> { + let spec = spec.trim(); + if spec.is_empty() { + return Some("package spec is empty"); + } + if spec.len() >= 4 && spec[..4].eq_ignore_ascii_case("pkg:") { + let rest = &spec[4..]; + let valid = rest.split_once('/').is_some_and(|(ty, name)| { + !ty.is_empty() && !name.trim_matches('/').is_empty() && !name.starts_with('@') + }); + if !valid { + return Some("purl spec needs a type and a name (`pkg:npm/lodash`)"); + } + } + None +} + +fn project_ignore_paths(node: &Node) -> Result, (String, String)> { + const KEY: &str = "projectIgnorePaths"; + check_plain_subtree(node, KEY)?; + let list = match &node.kind { + Kind::Scalar { + value: Scalar::Null, + .. + } => Vec::new(), + Kind::Scalar { + value: Scalar::Str(s), + .. + } => vec![s.clone()], + _ => string_list(node, KEY)?, + }; + if list.len() == 1 && list[0].len() > MAX_PATTERN_BYTES { + return Err(( + KEY.to_string(), + format!("is longer than {MAX_PATTERN_BYTES} bytes"), + )); + } + check_patterns(&list, KEY)?; + Ok(list) +} + +fn patches_block(node: &Node) -> Result { + let mut block = PatchesBlock::default(); + let pairs = match &node.kind { + Kind::Scalar { + value: Scalar::Null, + .. + } if !node.anchored && !node.custom_tag => return Ok(block), + Kind::Map(pairs) => pairs, + _ => { + check_plain_subtree(node, "patches")?; + return Err(( + "patches".to_string(), + format!("must be a mapping, found {}", node.describe()), + )); + } + }; + check_plain_subtree(node, "patches")?; + for (key, value) in pairs { + let Some(name) = key.as_str() else { + return Err(( + "patches".to_string(), + format!("keys must be strings, found {}", key.describe()), + )); + }; + let path = format!("patches.{}", sanitize(name)); + if !PATCHES_KEYS.contains(&name) { + let hint = did_you_mean(name, PATCHES_KEYS) + .map(|k| format!(" (did you mean `{k}`?)")) + .unwrap_or_default(); + return Err(( + path, + format!( + "unknown key{hint}; a newer socket-patch may support it: upgrade socket-patch or remove the key" + ), + )); + } + if matches!( + &value.kind, + Kind::Scalar { + value: Scalar::Null, + .. + } + ) { + return Err(( + path, + "has no value; remove the key to use the default".to_string(), + )); + } + match name { + "enabled" => match &value.kind { + Kind::Scalar { + value: Scalar::Bool(b), + .. + } => block.enabled = Some(*b), + _ => { + return Err(( + path, + format!("must be true or false, found {}", value.describe()), + )) + } + }, + "includePaths" | "ignorePaths" => { + let list = string_list(value, &path)?; + let key: &'static str = if name == "includePaths" { + "patches.includePaths" + } else { + "patches.ignorePaths" + }; + check_patterns(&list, key)?; + if name == "includePaths" { + if list.is_empty() { + return Err((path, "is empty and would match nothing; use `enabled: false` to pause patching".to_string())); + } + block.include_paths = Some(list); + } else { + block.ignore_paths = list; + } + } + "ecosystems" => { + let list = string_list(value, &path)?; + if list.is_empty() { + return Err(( + path, + "is empty and would match nothing; use `enabled: false` to pause patching" + .to_string(), + )); + } + let known: Vec<&str> = Ecosystem::all().iter().map(|e| e.cli_name()).collect(); + let mut out = Vec::with_capacity(list.len()); + for (i, entry) in list.iter().enumerate() { + let lower = entry.trim().to_lowercase(); + if !known.contains(&lower.as_str()) { + let hint = did_you_mean(&lower, known.iter().copied()) + .map(|k| format!(" (did you mean `{k}`?)")) + .unwrap_or_default(); + return Err(( + format!("{path}[{i}]"), + format!( + "unknown ecosystem `{}`{hint}; expected one of {}", + sanitize(entry), + known.join(", ") + ), + )); + } + out.push(lower); + } + block.ecosystems = Some(out); + } + "packages" | "ignorePackages" => { + let list = string_list(value, &path)?; + if name == "packages" && list.is_empty() { + return Err(( + path, + "is empty and would match nothing; use `enabled: false` to pause patching" + .to_string(), + )); + } + for (i, spec) in list.iter().enumerate() { + if let Some(message) = package_spec_error(spec) { + return Err(( + format!("{path}[{i}]"), + format!("{message}: `{}`", sanitize(spec)), + )); + } + } + if name == "packages" { + block.packages = Some(list); + } else { + block.ignore_packages = list; + } + } + "minSeverity" => { + let Some(s) = value.as_str() else { + return Err(( + path, + format!("must be a string, found {}", value.describe()), + )); + }; + match parse_severity_name(s) { + Some(order) => block.min_severity = Some(order), + None => { + return Err(( + path, + format!("unknown severity `{}`; expected critical, high, medium, moderate or low", sanitize(s)), + )) + } + } + } + "maxNewPatches" => match &value.kind { + Kind::Scalar { + value: Scalar::Int(n), + .. + } if (0..=i128::from(u32::MAX)).contains(n) => { + block.max_new_patches = Some(u32::try_from(*n).unwrap_or(u32::MAX)); + } + _ => { + return Err(( + path, + format!( + "must be an integer from 0 to {}, found {}", + u32::MAX, + value.describe() + ), + )) + } + }, + _ => unreachable!("PATCHES_KEYS is exhaustive"), + } + } + Ok(block) +} + +/// `critical|high|medium|moderate|low` (any case) to a severity order. +pub(crate) fn parse_severity_name(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + name @ ("critical" | "high" | "medium" | "moderate" | "low") => { + Some(severity_order(Some(name))) + } + _ => None, + } +} + +fn decode(ctx: &Ctx<'_>, bytes: &[u8]) -> Result { + if bytes.starts_with(&[0xFE, 0xFF]) || bytes.starts_with(&[0xFF, 0xFE]) { + return Err(ctx.err("", "file is UTF-16; save it as UTF-8")); + } + let bytes = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes); + if bytes.contains(&0) { + return Err(ctx.err("", "file contains NUL bytes; save it as UTF-8 text")); + } + String::from_utf8(bytes.to_vec()).map_err(|_| ctx.err("", "file is not valid UTF-8")) +} + +/// Parse and validate one root policy file (4.4). +pub(crate) fn parse_file( + file: &str, + bytes: &[u8], + warnings: &mut Vec, +) -> Result { + let ctx = Ctx { file }; + let text = decode(&ctx, bytes)?; + let root = build_tree(&text).map_err(|m| ctx.err("", format!("invalid YAML: {m}")))?; + let Some(root) = root else { + return Ok(ParsedFile { + empty: true, + ..ParsedFile::default() + }); + }; + let pairs = match &root.kind { + Kind::Map(pairs) => pairs, + Kind::Scalar { + value: Scalar::Null, + .. + } => { + return Ok(ParsedFile { + empty: true, + ..ParsedFile::default() + }) + } + _ => { + return Err(ctx.err( + "", + format!("the top level must be a mapping, found {}", root.describe()), + )); + } + }; + + let mut version: Option<&Node> = None; + let mut patches: Option<&Node> = None; + let mut ignore_paths: Option<&Node> = None; + for (key, value) in pairs { + let Some(name) = key.as_str() else { continue }; + let lower = name.to_ascii_lowercase(); + if (lower == "patch" || lower == "patches") && name != "patches" { + return Err(ctx.err( + sanitize(name), + "looks like a misspelled `patches` block; the key must be exactly `patches`", + )); + } + match name { + "version" => version = Some(value), + "patches" => patches = Some(value), + "projectIgnorePaths" => ignore_paths = Some(value), + _ => {} + } + } + + let Some(patches) = patches else { + let project_ignore_paths = match ignore_paths.map(project_ignore_paths) { + None => Vec::new(), + Some(Ok(list)) => list, + Some(Err((key, message))) => { + warnings.push(PolicyWarning { + code: super::SOCKET_YML_IGNORED_VALUE, + detail: format!("{file}: {key} {message}; the key is ignored"), + }); + Vec::new() + } + }; + return Ok(ParsedFile { + empty: false, + patches: None, + project_ignore_paths, + }); + }; + + let version_ok = version.is_some_and(|v| { + matches!( + &v.kind, + Kind::Scalar { + value: Scalar::Int(2), + .. + } + ) || matches!(&v.kind, Kind::Scalar { value: Scalar::Str(s), .. } if s == "2") + }); + if !version_ok { + return Err(ctx.err("version", "a `patches` block requires `version: 2`")); + } + let project_ignore_paths = match ignore_paths { + None => Vec::new(), + Some(node) => project_ignore_paths(node).map_err(|(key, message)| ctx.err(key, message))?, + }; + let block = patches_block(patches).map_err(|(key, message)| ctx.err(key, message))?; + Ok(ParsedFile { + empty: false, + patches: Some(block), + project_ignore_paths, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(text: &str) -> Result { + let mut warnings = Vec::new(); + parse_file("socket.yml", text.as_bytes(), &mut warnings) + } + + fn parse_warn(text: &str) -> (ParsedFile, Vec) { + let mut warnings = Vec::new(); + let parsed = parse_file("socket.yml", text.as_bytes(), &mut warnings).expect("parses"); + (parsed, warnings) + } + + fn err_key(text: &str) -> (String, String) { + match parse(text) { + Err(PolicyError::Invalid { key, message, .. }) => (key, message), + other => panic!("expected an invalid-file error for {text:?}, got {other:?}"), + } + } + + fn block(text: &str) -> PatchesBlock { + parse(text).expect("valid").patches.expect("patches block") + } + + #[test] + fn every_key_parses() { + let b = block( + "version: 2\npatches:\n enabled: false\n includePaths: [\"/services/\"]\n ignorePaths: [\"/legacy/\"]\n ecosystems: [NPM, pypi]\n packages: [\"pkg:npm/lodash\"]\n ignorePackages: [\"pkg:npm/left-pad\", \"core\"]\n minSeverity: High\n maxNewPatches: 5\n", + ); + assert_eq!(b.enabled, Some(false)); + assert_eq!(b.include_paths, Some(vec!["/services/".to_string()])); + assert_eq!(b.ignore_paths, vec!["/legacy/".to_string()]); + assert_eq!( + b.ecosystems, + Some(vec!["npm".to_string(), "pypi".to_string()]) + ); + assert_eq!(b.packages, Some(vec!["pkg:npm/lodash".to_string()])); + assert_eq!(b.ignore_packages.len(), 2); + assert_eq!(b.min_severity, Some(1)); + assert_eq!(b.max_new_patches, Some(5)); + } + + #[test] + fn defaults_when_absent() { + for text in [ + "version: 2\npatches:\n", + "version: 2\npatches: {}\n", + "version: 2\npatches: null\n", + ] { + assert_eq!(block(text), PatchesBlock::default(), "{text:?}"); + } + let parsed = parse("version: 2\n").unwrap(); + assert!(parsed.patches.is_none()); + assert!(!parsed.empty); + } + + #[test] + fn empty_and_comment_only_files_are_absent() { + for text in ["", "\n\n", "# just a comment\n", "---\n", "~\n"] { + assert!(parse(text).unwrap().empty, "{text:?}"); + } + } + + #[test] + fn bom_and_crlf_are_fine() { + let mut bytes = vec![0xEF, 0xBB, 0xBF]; + bytes.extend_from_slice(b"version: 2\r\npatches:\r\n maxNewPatches: 3\r\n"); + let parsed = parse_file("socket.yml", &bytes, &mut Vec::new()).unwrap(); + assert_eq!(parsed.patches.unwrap().max_new_patches, Some(3)); + } + + #[test] + fn encoding_errors() { + for bytes in [ + &b"\xFF\xFEv\0e\0r\0"[..], + &b"\xFE\xFF\0v\0e"[..], + &b"version: 2\0\n"[..], + &b"version: \xC3\x28\n"[..], + ] { + assert!( + parse_file("socket.yml", bytes, &mut Vec::new()).is_err(), + "{bytes:?}" + ); + } + } + + #[test] + fn yaml_errors() { + for text in [ + "version: 2\npatches: [\n", + "a: 1\na: 2\n", + "version: 2\npatches:\n enabled: true\n enabled: false\n", + "- a\n- b\n", + "just a string\n", + "a: 1\n---\nb: 2\n", + "projectIgnorePaths:\n - **\n", + ] { + assert!(parse(text).is_err(), "{text:?} must fail"); + } + } + + #[test] + fn nesting_limit() { + let deep = format!("a: {}{}\n", "[".repeat(40), "]".repeat(40)); + let (key, message) = err_key(&deep); + assert_eq!(key, ""); + assert!(message.contains("deeper than 32"), "{message}"); + let ok = format!("a: {}{}\n", "[".repeat(30), "]".repeat(30)); + assert!(parse(&ok).is_ok()); + } + + #[test] + fn alias_bomb_elsewhere_is_never_expanded() { + let mut text = String::from( + "a: &a [\"lol\",\"lol\",\"lol\",\"lol\",\"lol\",\"lol\",\"lol\",\"lol\",\"lol\"]\n", + ); + let names = ["b", "c", "d", "e", "f", "g", "h", "i", "j"]; + let mut prev = "a"; + for name in names { + text.push_str(&format!("{name}: &{name} [*{prev},*{prev},*{prev},*{prev},*{prev},*{prev},*{prev},*{prev},*{prev}]\n")); + prev = name; + } + text.push_str("version: 2\npatches:\n maxNewPatches: 1\n"); + let started = std::time::Instant::now(); + assert_eq!(block(&text).max_new_patches, Some(1)); + assert!(started.elapsed() < std::time::Duration::from_secs(2)); + } + + #[test] + fn anchors_aliases_and_merge_keys_are_refused_in_our_keys() { + for text in [ + "version: 2\nx: &x [\"/a/\"]\npatches:\n ignorePaths: *x\n", + "version: 2\npatches:\n ignorePaths: &y [\"/a/\"]\n", + "version: 2\nbase: &b {maxNewPatches: 1}\npatches:\n <<: *b\n", + "version: 2\npatches:\n <<: {maxNewPatches: 1}\n", + "version: 2\nx: &x \"/a/\"\npatches: {}\nprojectIgnorePaths: [*x]\n", + "version: 2\npatches: &p {}\n", + "version: 2\npatches:\n minSeverity: !custom high\n", + ] { + let (_, message) = err_key(text); + assert!(message.contains("not allowed"), "{text:?}: {message}"); + } + // Outside the keys we read, anchors and aliases are someone else's business. + let ok = "version: 2\nissueRules: &r {a: 1}\nother: *r\npatches:\n maxNewPatches: 2\n"; + assert_eq!(block(ok).max_new_patches, Some(2)); + } + + #[test] + fn case_variant_of_patches_is_an_error() { + for text in [ + "version: 2\nPatches: {}\n", + "version: 2\npatch:\n enabled: false\n", + "PATCHES: {}\n", + ] { + let (_, message) = err_key(text); + assert!(message.contains("misspelled"), "{text:?}: {message}"); + } + } + + #[test] + fn version_gate() { + for text in [ + "patches: {}\n", + "version: 1\npatches: {}\n", + "version: 3\npatches: {}\n", + "version: 2.0\npatches: {}\n", + ] { + assert_eq!(err_key(text).0, "version", "{text:?}"); + } + assert!(parse("version: \"2\"\npatches: {}\n").is_ok()); + // No patches block: any version, projectIgnorePaths honored. + let parsed = parse("version: 1\nprojectIgnorePaths: [\"/a/\"]\n").unwrap(); + assert_eq!(parsed.project_ignore_paths, vec!["/a/".to_string()]); + } + + #[test] + fn unknown_keys_with_hint() { + let (key, message) = err_key("version: 2\npatches:\n minSeverty: high\n"); + assert_eq!(key, "patches.minSeverty"); + assert!(message.contains("did you mean `minSeverity`"), "{message}"); + assert!(message.contains("newer socket-patch"), "{message}"); + let (_, message) = err_key("version: 2\npatches:\n apiToken: x\n"); + assert!(!message.contains("did you mean"), "{message}"); + let (key, message) = err_key("version: 2\npatches:\n maxnewpatches: 1\n"); + assert_eq!(key, "patches.maxnewpatches"); + assert!( + message.contains("did you mean `maxNewPatches`"), + "{message}" + ); + } + + #[test] + fn trust_boundary_keys_are_unknown() { + for key in [ + "apiUrl", + "apiToken", + "org", + "mode", + "downloadMode", + "patchServerUrl", + "noVerify", + "strict", + ] { + let text = format!("version: 2\npatches:\n {key}: x\n"); + assert_eq!(err_key(&text).0, format!("patches.{key}")); + } + } + + #[test] + fn wrong_types() { + let cases = [ + ("enabled: \"false\"", "patches.enabled"), + ("enabled: no", "patches.enabled"), + ("enabled: 1", "patches.enabled"), + ("enabled:", "patches.enabled"), + ("includePaths: \"/a/\"", "patches.includePaths"), + ("includePaths: [1]", "patches.includePaths[0]"), + ("includePaths: []", "patches.includePaths"), + ("ecosystems: []", "patches.ecosystems"), + ("ecosystems: [npn]", "patches.ecosystems[0]"), + ("packages: []", "patches.packages"), + ("packages: [\"pkg:\"]", "patches.packages[0]"), + ( + "ignorePackages: [\"pkg:npm/\"]", + "patches.ignorePackages[0]", + ), + ("ignorePackages: [\" \"]", "patches.ignorePackages[0]"), + ("minSeverity: severe", "patches.minSeverity"), + ("minSeverity: none", "patches.minSeverity"), + ("minSeverity: 1", "patches.minSeverity"), + ("maxNewPatches: -1", "patches.maxNewPatches"), + ("maxNewPatches: 4294967296", "patches.maxNewPatches"), + ("maxNewPatches: 1.5", "patches.maxNewPatches"), + ("maxNewPatches: \"5\"", "patches.maxNewPatches"), + ("ignorePaths: [\"../x\"]", "patches.ignorePaths[0]"), + ("ignorePaths: [\"C:/x\"]", "patches.ignorePaths[0]"), + ("ignorePaths: [\"a/[b\"]", "patches.ignorePaths[0]"), + ("ignorePaths: {a: 1}", "patches.ignorePaths"), + ]; + for (line, key) in cases { + let text = format!("version: 2\npatches:\n {line}\n"); + assert_eq!(err_key(&text).0, key, "{line}"); + } + assert!(parse("version: 2\npatches: [a]\n").is_err()); + assert!(parse("version: 2\npatches: true\n").is_err()); + } + + #[test] + fn size_limits_on_lists() { + let many: Vec = (0..1001).map(|i| format!("\"/a{i}/\"")).collect(); + let text = format!( + "version: 2\npatches:\n ignorePaths: [{}]\n", + many.join(",") + ); + assert_eq!(err_key(&text).0, "patches.ignorePaths"); + let long = "a".repeat(1025); + let text = format!("version: 2\npatches:\n ignorePackages: [\"{long}\"]\n"); + assert_eq!(err_key(&text).0, "patches.ignorePackages[0]"); + } + + #[test] + fn integer_forms_and_boundaries() { + assert_eq!( + block("version: 2\npatches:\n maxNewPatches: 0\n").max_new_patches, + Some(0) + ); + assert_eq!( + block("version: 2\npatches:\n maxNewPatches: 4294967295\n").max_new_patches, + Some(u32::MAX) + ); + assert_eq!( + block("version: 2\npatches:\n maxNewPatches: 0x10\n").max_new_patches, + Some(16) + ); + assert_eq!( + block("version: 2\npatches:\n maxNewPatches: !!int \"7\"\n").max_new_patches, + Some(7) + ); + assert!(parse("version: 2\npatches:\n maxNewPatches: !!int seven\n").is_err()); + } + + #[test] + fn moderate_is_medium() { + assert_eq!( + block("version: 2\npatches:\n minSeverity: moderate\n").min_severity, + Some(2) + ); + assert_eq!( + block("version: 2\npatches:\n minSeverity: medium\n").min_severity, + Some(2) + ); + } + + #[test] + fn project_ignore_paths_rules() { + // Strict with a patches block. + let parsed = parse("version: 2\nprojectIgnorePaths: \"/a/\"\npatches: {}\n").unwrap(); + assert_eq!(parsed.project_ignore_paths, vec!["/a/".to_string()]); + assert_eq!( + err_key("version: 2\nprojectIgnorePaths: 5\npatches: {}\n").0, + "projectIgnorePaths" + ); + assert_eq!( + err_key("version: 2\nprojectIgnorePaths: [\"../x\"]\npatches: {}\n").0, + "projectIgnorePaths[0]" + ); + // Lenient without one: warning, key ignored. + let (parsed, warnings) = parse_warn("version: 2\nprojectIgnorePaths: {a: 1}\n"); + assert!(parsed.project_ignore_paths.is_empty()); + assert_eq!(warnings.len(), 1); + assert_eq!(warnings[0].code, "socket_yml_ignored_value"); + let (parsed, warnings) = parse_warn("projectIgnorePaths: [\"/a/\", \"b/\"]\n"); + assert_eq!(parsed.project_ignore_paths.len(), 2); + assert!(warnings.is_empty()); + } + + #[test] + fn yaml12_scalars() { + assert_eq!(resolve_plain("no"), Scalar::Str("no".to_string())); + assert_eq!(resolve_plain("yes"), Scalar::Str("yes".to_string())); + assert_eq!(resolve_plain("True"), Scalar::Bool(true)); + assert_eq!(resolve_plain("~"), Scalar::Null); + assert_eq!(resolve_plain("-12"), Scalar::Int(-12)); + assert_eq!(resolve_plain("0o17"), Scalar::Int(15)); + assert_eq!(resolve_plain("1e3"), Scalar::Number); + assert_eq!(resolve_plain(".5"), Scalar::Number); + assert_eq!(resolve_plain("1.2.3"), Scalar::Str("1.2.3".to_string())); + assert_eq!(resolve_plain("0x"), Scalar::Str("0x".to_string())); + } + + #[test] + fn same_policy_compares_parsed_values() { + let a = parse("version: 2\npatches: {maxNewPatches: 1}\n").unwrap(); + let b = parse( + "# other comments\nversion: \"2\"\npatches:\n maxNewPatches: 0x1\nissueRules: {}\n", + ) + .unwrap(); + assert!(a.same_policy(&b)); + let c = parse("version: 2\npatches: {maxNewPatches: 2}\n").unwrap(); + assert!(!a.same_policy(&c)); + let d = parse("version: 2\nprojectIgnorePaths: [\"/b/\", \"/a/\"]\n").unwrap(); + let e = parse("version: 2\nprojectIgnorePaths: [\"/a/\", \"/b/\"]\n").unwrap(); + assert!(!d.same_policy(&e), "order-sensitive"); + } + + #[test] + fn package_specs() { + for good in [ + "lodash", + "@babel/core", + "pkg:npm/lodash", + "pkg:npm/lodash@4.17.21", + "pkg:pypi/requests", + "PKG:npm/x", + ] { + assert!(package_spec_error(good).is_none(), "{good}"); + } + for bad in [ + "", + " ", + "pkg:", + "pkg:npm", + "pkg:npm/", + "pkg:/lodash", + "pkg:npm/@1.0.0", + ] { + assert!(package_spec_error(bad).is_some(), "{bad:?}"); + } + } + + #[test] + fn did_you_mean_distance() { + assert_eq!( + did_you_mean("ignorePath", PATCHES_KEYS), + Some("ignorePaths") + ); + assert_eq!(did_you_mean("zzzzzz", PATCHES_KEYS), None); + } +} diff --git a/crates/socket-patch-core/src/policy/tests.rs b/crates/socket-patch-core/src/policy/tests.rs new file mode 100644 index 00000000..8f053ffb --- /dev/null +++ b/crates/socket-patch-core/src/policy/tests.rs @@ -0,0 +1,587 @@ +use super::*; + +fn mem(files: &[(&str, &str)]) -> MemoryPolicyFs { + let mut fs = MemoryPolicyFs::default(); + for (name, text) in files { + fs.files.insert( + name.to_string(), + RootFile::Present(text.as_bytes().to_vec()), + ); + fs.root_names.push(name.to_string()); + } + fs +} + +fn load(files: &[(&str, &str)]) -> SelectionPolicy { + SelectionPolicy::load(&mem(files), &PolicyOverrides::default()) + .expect("valid policy") + .0 +} + +fn strings(v: &[&str]) -> Vec { + v.iter().map(|s| s.to_string()).collect() +} + +fn root<'a>(rel_dir: &'a str, markers: &'a [String], explicit: bool) -> Root<'a> { + Root { + rel_dir, + markers, + explicit, + } +} + +#[test] +fn no_file_is_unrestricted_with_default_ignores() { + let (policy, warnings) = + SelectionPolicy::load(&MemoryPolicyFs::default(), &PolicyOverrides::default()).unwrap(); + assert_eq!(policy.source(), &PolicySource::None); + assert!(warnings.is_empty()); + assert!(policy.enabled()); + assert_eq!(policy.max_new_patches(), None); + let lock = strings(&["package-lock.json"]); + assert!(policy.admits_root(&root("", &lock, false)).is_ok()); + let err = policy + .admits_root(&root("packages/a/test", &lock, false)) + .unwrap_err(); + assert_eq!(err.code(), "policy_path_excluded"); + assert_eq!(err.detail(), "test/ (built-in default)"); + // Case-insensitive defaults, unlike the old hard-coded segment list. + assert!(policy + .admits_root(&root("Tests/app", &lock, false)) + .is_err()); + // Explicit roots never see the defaults. + assert!(policy + .admits_root(&root("packages/a/test", &lock, true)) + .is_ok()); +} + +#[test] +fn empty_file_is_source_none() { + let policy = load(&[("socket.yml", "# nothing\n")]); + assert_eq!(policy.source(), &PolicySource::None); +} + +#[test] +fn file_source_carries_path_and_hash() { + let text = "version: 2\npatches:\n maxNewPatches: 3\n"; + let policy = load(&[("socket.yml", text)]); + match policy.source() { + PolicySource::File { path, sha256 } => { + assert_eq!(path, "socket.yml"); + assert_eq!(sha256, &hex::encode(Sha256::digest(text.as_bytes()))); + } + other => panic!("{other:?}"), + } + assert_eq!(policy.max_new_patches(), Some(3)); + let yaml = load(&[("socket.yaml", text)]); + assert!(matches!(yaml.source(), PolicySource::File { path, .. } if path == "socket.yaml")); +} + +#[test] +fn bypass_ignores_the_file_but_keeps_defaults_and_flag_floor() { + let overrides = PolicyOverrides { + bypass: true, + min_severity: Some((Some(1), OverrideSource::Flag)), + }; + let fs = mem(&[( + "socket.yml", + "version: 2\npatches:\n enabled: false\n maxNewPatches: 1\n", + )]); + let (policy, _) = SelectionPolicy::load(&fs, &overrides).unwrap(); + assert_eq!(policy.source(), &PolicySource::Bypassed); + assert!(policy.enabled()); + assert_eq!(policy.max_new_patches(), None); + assert_eq!(policy.min_severity(), (Some(1), SeveritySource::Flag)); + let lock = strings(&["yarn.lock"]); + assert!(policy + .admits_root(&root("fixtures/x", &lock, false)) + .is_err()); + // An invalid file is not even read when bypassed. + let broken = mem(&[("socket.yml", "version: 2\npatches: [\n")]); + assert!(SelectionPolicy::load(&broken, &overrides).is_ok()); +} + +#[test] +fn severity_precedence_flag_env_file_default() { + let fs = mem(&[("socket.yml", "version: 2\npatches:\n minSeverity: high\n")]); + let (p, _) = SelectionPolicy::load(&fs, &PolicyOverrides::default()).unwrap(); + assert_eq!(p.min_severity(), (Some(1), SeveritySource::File)); + let env = PolicyOverrides { + bypass: false, + min_severity: Some((Some(0), OverrideSource::Env)), + }; + assert_eq!( + SelectionPolicy::load(&fs, &env).unwrap().0.min_severity(), + (Some(0), SeveritySource::Env) + ); + let none = PolicyOverrides { + bypass: false, + min_severity: Some((None, OverrideSource::Flag)), + }; + assert_eq!( + SelectionPolicy::load(&fs, &none).unwrap().0.min_severity(), + (None, SeveritySource::Flag) + ); + assert_eq!( + SelectionPolicy::unrestricted().min_severity(), + (None, SeveritySource::Default) + ); +} + +#[test] +fn severity_floor_filters_unknown_and_below() { + let policy = load(&[( + "socket.yml", + "version: 2\npatches:\n minSeverity: moderate\n", + )]); + assert!(policy.admits_severity(0).is_ok()); + assert!(policy.admits_severity(2).is_ok()); + let low = policy.admits_severity(3).unwrap_err(); + assert_eq!(low.code(), "policy_severity"); + assert_eq!(low.detail(), "low < medium"); + assert_eq!( + policy.admits_severity(4).unwrap_err().detail(), + "unknown < medium" + ); + let lowest = load(&[("socket.yml", "version: 2\npatches:\n minSeverity: low\n")]); + assert!(lowest.admits_severity(3).is_ok()); + assert!( + lowest.admits_severity(4).is_err(), + "low still drops unknown severity" + ); + assert!(SelectionPolicy::unrestricted().admits_severity(4).is_ok()); +} + +#[test] +fn floor_filter_uses_max_advisory_severity() { + use crate::api::types::VulnerabilityResponse; + use std::collections::HashMap; + let patch = |uuid: &str, severities: &[&str]| PatchSearchResult { + uuid: uuid.to_string(), + purl: "pkg:npm/a@1.0.0".to_string(), + published_at: String::new(), + description: String::new(), + license: String::new(), + tier: "free".to_string(), + vulnerabilities: severities + .iter() + .enumerate() + .map(|(i, s)| { + ( + format!("GHSA-{i}"), + VulnerabilityResponse { + cves: vec![], + summary: String::new(), + severity: s.to_string(), + description: String::new(), + }, + ) + }) + .collect::>(), + }; + let policy = load(&[("socket.yml", "version: 2\npatches:\n minSeverity: high\n")]); + let (kept, dropped) = policy.floor_filter(vec![ + patch("merged", &["LOW", "CRITICAL"]), + patch("low", &["LOW"]), + patch("none", &[]), + ]); + assert_eq!( + kept.iter().map(|p| p.uuid.as_str()).collect::>(), + ["merged"] + ); + assert_eq!(dropped.len(), 2); +} + +#[test] +fn both_files_equal_different_and_one_invalid() { + let a = "version: 2\npatches:\n maxNewPatches: 2\n"; + let same = "# different bytes, same policy\nversion: \"2\"\npatches: {maxNewPatches: 2}\n"; + let policy = load(&[("socket.yml", a), ("socket.yaml", same)]); + assert!(matches!(policy.source(), PolicySource::File { path, .. } if path == "socket.yml")); + + let other = "version: 2\npatches:\n maxNewPatches: 3\n"; + let err = SelectionPolicy::load( + &mem(&[("socket.yml", a), ("socket.yaml", other)]), + &PolicyOverrides::default(), + ) + .unwrap_err(); + assert_eq!(err.code(), "socket_yml_ambiguous"); + + let broken = "version: 2\npatches: [\n"; + let err = SelectionPolicy::load( + &mem(&[("socket.yml", a), ("socket.yaml", broken)]), + &PolicyOverrides::default(), + ) + .unwrap_err(); + assert_eq!(err.code(), "socket_yml_invalid"); + assert!(err.detail().starts_with("socket.yaml:"), "{}", err.detail()); +} + +#[test] +fn present_without_content_is_invalid_not_absent() { + let mut fs = MemoryPolicyFs::default(); + fs.files + .insert("socket.yml".to_string(), RootFile::PresentWithoutContent); + let err = SelectionPolicy::load(&fs, &PolicyOverrides::default()).unwrap_err(); + assert_eq!(err.code(), "socket_yml_invalid"); +} + +#[test] +fn oversize_memory_file_is_invalid() { + let mut fs = MemoryPolicyFs::default(); + fs.files.insert( + "socket.yml".to_string(), + RootFile::Present(vec![b' '; MAX_FILE_BYTES + 1]), + ); + assert!(SelectionPolicy::load(&fs, &PolicyOverrides::default()).is_err()); +} + +#[test] +fn case_variant_is_not_read_and_warns() { + let mut fs = mem(&[]); + fs.root_names.push("Socket.yml".to_string()); + let (policy, warnings) = SelectionPolicy::load(&fs, &PolicyOverrides::default()).unwrap(); + assert_eq!(policy.source(), &PolicySource::None); + assert_eq!(warnings[0].code, "socket_yml_name_case"); +} + +#[test] +fn error_display_names_file_key_and_remedy() { + let err = SelectionPolicy::load( + &mem(&[( + "socket.yml", + "version: 2\npatches:\n minSeverity: severe\n", + )]), + &PolicyOverrides::default(), + ) + .unwrap_err(); + let text = err.to_string(); + assert!( + text.starts_with("socket.yml: patches.minSeverity: unknown severity `severe`"), + "{text}" + ); + assert!(text.contains("--no-socket-yml"), "{text}"); +} + +#[test] +fn marker_rule_all_ignored_or_any_included() { + let policy = load(&[( + "socket.yml", + "version: 2\npatches:\n ignorePaths: [\"**/yarn.lock\"]\n includePaths: [\"/services/payments/\"]\n", + )]); + let both = strings(&["package.json", "yarn.lock"]); + let yarn = strings(&["yarn.lock"]); + // Not every marker ignored: the root stays. + assert!(policy + .admits_root(&root("services/payments", &both, false)) + .is_ok()); + assert_eq!( + policy + .admits_root(&root("services/payments", &yarn, false)) + .unwrap_err() + .code(), + "policy_path_excluded" + ); + assert_eq!( + policy + .admits_root(&root("services/api", &both, false)) + .unwrap_err(), + FilterReason::PathNotIncluded + ); + // The repo-root project alone: `/*` plus `!/*/`. + let only_root = load(&[( + "socket.yml", + "version: 2\npatches:\n includePaths: [\"/*\", \"!/*/\"]\n", + )]); + let lock = strings(&["package-lock.json"]); + assert!(only_root.admits_root(&root("", &lock, true)).is_ok()); + assert!(only_root.admits_root(&root("a", &lock, false)).is_err()); +} + +#[test] +fn deny_wins_over_include() { + let policy = load(&[( + "socket.yml", + "version: 2\npatches:\n includePaths: [\"/services/\"]\n ignorePaths: [\"/services/legacy/\"]\n", + )]); + let lock = strings(&["package-lock.json"]); + let err = policy + .admits_root(&root("services/legacy", &lock, true)) + .unwrap_err(); + assert_eq!(err.detail(), "/services/legacy/ (patches.ignorePaths)"); +} + +#[test] +fn defaults_negation_and_project_ignore_paths() { + let policy = load(&[( + "socket.yml", + "version: 2\nprojectIgnorePaths: [\"examples/**\"]\npatches:\n ignorePaths: [\"!/e2e/tests/\"]\n", + )]); + let lock = strings(&["package-lock.json"]); + assert!(policy.admits_root(&root("e2e/tests", &lock, false)).is_ok()); + assert!(policy + .admits_root(&root("other/tests", &lock, false)) + .is_err()); + let err = policy + .admits_root(&root("examples/demo", &lock, true)) + .unwrap_err(); + assert_eq!(err.detail(), "examples/** (projectIgnorePaths)"); + // An unrelated ignore never re-enables fixtures. + let unrelated = load(&[( + "socket.yml", + "version: 2\npatches:\n ignorePaths: [\"/legacy/\"]\n", + )]); + assert!(unrelated + .admits_root(&root("a/fixtures", &lock, false)) + .is_err()); + // projectIgnorePaths without a patches block is honored too. + let scanner_only = load(&[( + "socket.yml", + "version: 2\nprojectIgnorePaths:\n - \"crates/*/tests/fixtures/**\"\n", + )]); + let cargo = strings(&["Cargo.lock"]); + assert!(scanner_only + .admits_root(&root("crates/x/tests/fixtures/app", &cargo, true)) + .is_err()); +} + +#[test] +fn ecosystems_and_packages() { + let policy = load(&[( + "socket.yml", + "version: 2\npatches:\n ecosystems: [npm, deno]\n packages: [\"pkg:npm/lodash\", \"left-pad\", \"@std/path\"]\n ignorePackages: [\"pkg:npm/left-pad@1.0.0\"]\n", + )]); + assert!(policy.admits_purl("pkg:npm/lodash@4.17.20").is_ok()); + assert!(policy.admits_purl("pkg:jsr/@std/path@1.0.0").is_ok()); + assert_eq!( + policy.admits_purl("pkg:pypi/requests@2.0.0").unwrap_err(), + FilterReason::Ecosystem + ); + assert_eq!( + policy.admits_purl("pkg:npm/qs@6.5.2").unwrap_err(), + FilterReason::PackageNotListed + ); + let err = policy.admits_purl("pkg:npm/left-pad@1.0.0").unwrap_err(); + assert_eq!(err.code(), "policy_package_ignored"); + assert_eq!( + err.detail(), + "pkg:npm/left-pad@1.0.0 (patches.ignorePackages)" + ); + assert!(policy.admits_purl("pkg:npm/left-pad@1.1.0").is_ok()); + assert_eq!( + policy.admits_purl("pkg:unknown/x@1").unwrap_err(), + FilterReason::Ecosystem + ); +} + +#[test] +fn package_spec_matching_grammar() { + assert!(package_spec_matches("lodash", "pkg:npm/lodash@4.17.20")); + assert!(package_spec_matches("core", "pkg:npm/%40babel/core@7.0.0")); + assert!(package_spec_matches( + "@babel/core", + "pkg:npm/@babel/core@7.0.0" + )); + assert!(package_spec_matches( + "Requests", + "pkg:pypi/requests@2.0.0?artifact_id=x" + )); + assert!(package_spec_matches( + "pkg:npm/lodash", + "pkg:npm/lodash@1.0.0" + )); + assert!(!package_spec_matches( + "pkg:npm/lodash", + "pkg:npm/lodash-es@1.0.0" + )); + assert!(!package_spec_matches( + "pkg:npm/lodash@1.0.1", + "pkg:npm/lodash@1.0.0" + )); + assert!(!package_spec_matches("", "pkg:npm/lodash@1.0.0")); + assert!(!package_spec_matches("pkg:", "pkg:npm/lodash@1.0.0")); + assert!(package_spec_matches( + "org.example:lib", + "pkg:maven/org.example/lib@1.0" + )); +} + +#[test] +fn enabled_false_is_reported_by_callers() { + let policy = load(&[("socket.yml", "version: 2\npatches:\n enabled: false\n")]); + assert!(!policy.enabled()); + assert_eq!(FilterReason::Disabled.code(), "policy_disabled"); +} + +#[test] +fn min_severity_flag_values() { + assert_eq!(parse_min_severity("none"), Ok(None)); + assert_eq!(parse_min_severity("NONE"), Ok(None)); + assert_eq!(parse_min_severity("Critical"), Ok(Some(0))); + assert_eq!(parse_min_severity("moderate"), Ok(Some(2))); + assert!(parse_min_severity("severe").is_err()); + assert!(parse_min_severity("").is_err()); +} + +#[test] +fn sanitize_strips_controls_and_truncates() { + assert_eq!(sanitize("a\u{1b}[31mb\nc"), "a[31mbc"); + assert_eq!(sanitize(&"x".repeat(500)).chars().count(), 200); +} + +#[test] +fn repo_relative_paths() { + let root = Path::new("/r"); + assert_eq!(repo_relative(root, Path::new("/r")), ""); + assert_eq!(repo_relative(root, Path::new("/r/a/b")), "a/b"); + assert_eq!(repo_relative_checked(root, Path::new("/other")), None); +} + +mod disk { + use super::*; + use std::fs; + + fn read(dir: &Path, name: &str) -> std::io::Result { + DiskPolicyFs::new(dir).read_root_file(name, MAX_FILE_BYTES) + } + + #[test] + fn regular_file_absent_and_case_variant() { + let tmp = tempfile::tempdir().unwrap(); + assert_eq!(read(tmp.path(), "socket.yml").unwrap(), RootFile::Absent); + fs::write(tmp.path().join("Socket.yml"), "version: 2\n").unwrap(); + // Even on case-insensitive disks the exact name must be listed. + assert_eq!(read(tmp.path(), "socket.yml").unwrap(), RootFile::Absent); + assert_eq!( + DiskPolicyFs::new(tmp.path()).case_variants(), + vec!["Socket.yml".to_string()] + ); + fs::write(tmp.path().join("socket.yaml"), "version: 2\n").unwrap(); + assert_eq!( + read(tmp.path(), "socket.yaml").unwrap(), + RootFile::Present(b"version: 2\n".to_vec()) + ); + } + + #[test] + fn directory_and_oversize_are_errors() { + let tmp = tempfile::tempdir().unwrap(); + fs::create_dir(tmp.path().join("socket.yml")).unwrap(); + assert!(read(tmp.path(), "socket.yml").is_err()); + fs::write( + tmp.path().join("socket.yaml"), + vec![b'#'; MAX_FILE_BYTES + 1], + ) + .unwrap(); + assert!(read(tmp.path(), "socket.yaml").is_err()); + let err = + SelectionPolicy::load(&DiskPolicyFs::new(tmp.path()), &PolicyOverrides::default()) + .unwrap_err(); + assert_eq!(err.code(), "socket_yml_invalid"); + } + + #[test] + fn exactly_the_size_limit_is_fine() { + let tmp = tempfile::tempdir().unwrap(); + fs::write(tmp.path().join("socket.yml"), vec![b'#'; MAX_FILE_BYTES]).unwrap(); + assert!( + matches!(read(tmp.path(), "socket.yml").unwrap(), RootFile::Present(b) if b.len() == MAX_FILE_BYTES) + ); + } + + #[cfg(unix)] + #[test] + fn symlink_inside_is_followed_outside_is_refused() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repo"); + fs::create_dir_all(repo.join("config")).unwrap(); + fs::write(repo.join("config/policy.yml"), "version: 2\n").unwrap(); + std::os::unix::fs::symlink("config/policy.yml", repo.join("socket.yml")).unwrap(); + assert!(matches!( + read(&repo, "socket.yml").unwrap(), + RootFile::Present(_) + )); + + fs::write(tmp.path().join("outside.yml"), "version: 2\n").unwrap(); + std::os::unix::fs::symlink("../outside.yml", repo.join("socket.yaml")).unwrap(); + let err = read(&repo, "socket.yaml").unwrap_err(); + assert!(err.to_string().contains("outside"), "{err}"); + } + + #[cfg(unix)] + #[test] + fn fifo_is_refused_without_blocking() { + let tmp = tempfile::tempdir().unwrap(); + let fifo = tmp.path().join("socket.yml"); + let c = std::ffi::CString::new(fifo.to_str().unwrap()).unwrap(); + // SAFETY: a valid NUL-terminated path. + assert_eq!(unsafe { libc::mkfifo(c.as_ptr(), 0o600) }, 0); + let err = read(tmp.path(), "socket.yml").unwrap_err(); + assert!(err.to_string().contains("regular file"), "{err}"); + } + + #[test] + fn repo_root_lookup_git_dir_git_file_and_none() { + let tmp = tempfile::tempdir().unwrap(); + let base = fs::canonicalize(tmp.path()).unwrap(); + let repo = base.join("repo"); + fs::create_dir_all(repo.join(".git")).unwrap(); + fs::create_dir_all(repo.join("a/b")).unwrap(); + assert_eq!(find_repo_root(&repo.join("a/b")), repo); + assert_eq!(find_repo_root(&repo), repo); + + let worktree = base.join("wt"); + fs::create_dir_all(worktree.join("sub")).unwrap(); + fs::write(worktree.join(".git"), "gitdir: /elsewhere\n").unwrap(); + assert_eq!(find_repo_root(&worktree.join("sub")), worktree); + + let bare = base.join("plain/x"); + fs::create_dir_all(&bare).unwrap(); + assert_eq!(find_repo_root(&bare), bare); + } + + #[test] + #[serial_test::serial(git_ceiling_env)] + fn repo_root_lookup_honors_ceiling_dirs() { + let tmp = tempfile::tempdir().unwrap(); + let base = fs::canonicalize(tmp.path()).unwrap(); + fs::create_dir_all(base.join(".git")).unwrap(); + let cwd = base.join("ceiling/cwd"); + fs::create_dir_all(&cwd).unwrap(); + std::env::set_var("GIT_CEILING_DIRECTORIES", base.join("ceiling")); + let found = find_repo_root(&cwd); + std::env::remove_var("GIT_CEILING_DIRECTORIES"); + assert_eq!(found, cwd); + assert_eq!(find_repo_root(&cwd), base); + } + + #[cfg(unix)] + #[test] + fn owner_rule() { + assert!(owner_trusted(1000, 1000)); + assert!(owner_trusted(0, 1000)); + assert!(!owner_trusted(1001, 1000)); + } + + #[cfg(unix)] + #[test] + fn foreign_owned_git_stops_the_walk() { + // SAFETY: no preconditions. + if unsafe { libc::geteuid() } != 0 { + // Only root can hand `.git` to another owner; `owner_rule` + // covers the decision itself. + return; + } + let tmp = tempfile::tempdir().unwrap(); + let base = fs::canonicalize(tmp.path()).unwrap(); + fs::create_dir_all(base.join(".git")).unwrap(); + let cwd = base.join("sub"); + fs::create_dir_all(&cwd).unwrap(); + let git = std::ffi::CString::new(base.join(".git").to_str().unwrap()).unwrap(); + // SAFETY: a valid NUL-terminated path. + assert_eq!(unsafe { libc::chown(git.as_ptr(), 4242, 4242) }, 0); + let (found, warnings) = find_repo_root_with_warnings(&cwd); + assert_eq!(found, cwd); + assert_eq!(warnings[0].code, "socket_yml_repo_untrusted"); + } +} diff --git a/crates/socket-patch-core/tests/fixtures/ignore_golden.json b/crates/socket-patch-core/tests/fixtures/ignore_golden.json new file mode 100644 index 00000000..af975e19 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/ignore_golden.json @@ -0,0 +1,1379 @@ +{"generator": "ignore@7.0.10", "cases": [ + {"patterns":[],"path":"package-lock.json","ignored":false}, + {"patterns":[],"path":"Cargo.lock","ignored":false}, + {"patterns":[],"path":"a/package-lock.json","ignored":false}, + {"patterns":[],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":[],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":[],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":[],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":[],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":[],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":[],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":[],"path":"test/package-lock.json","ignored":false}, + {"patterns":[],"path":"Test/package-lock.json","ignored":false}, + {"patterns":[],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":[],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":[],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":[],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":[],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":[],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":[],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":[],"path":"examples/package-lock.json","ignored":false}, + {"patterns":[],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":[],"path":"testdata/go.sum","ignored":false}, + {"patterns":[],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":[],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":[],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":[],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":[],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["/package-lock.json"],"path":"package-lock.json","ignored":true}, + {"patterns":["/package-lock.json"],"path":"Cargo.lock","ignored":false}, + {"patterns":["/package-lock.json"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["/package-lock.json"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["/package-lock.json"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["/package-lock.json"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["/package-lock.json"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["/package-lock.json"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["/package-lock.json"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["/package-lock.json"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["/package-lock.json"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["/package-lock.json"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["/package-lock.json"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["/package-lock.json"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["/package-lock.json"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["/package-lock.json"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["/package-lock.json"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["/package-lock.json"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["/package-lock.json"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["/package-lock.json"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["/package-lock.json"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["/package-lock.json"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["/package-lock.json"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["/package-lock.json"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["/package-lock.json"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["/package-lock.json"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["/package-lock.json"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["package-lock.json"],"path":"package-lock.json","ignored":true}, + {"patterns":["package-lock.json"],"path":"Cargo.lock","ignored":false}, + {"patterns":["package-lock.json"],"path":"a/package-lock.json","ignored":true}, + {"patterns":["package-lock.json"],"path":"a/b/package-lock.json","ignored":true}, + {"patterns":["package-lock.json"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["package-lock.json"],"path":"services/api/package-lock.json","ignored":true}, + {"patterns":["package-lock.json"],"path":"services/payments/package-lock.json","ignored":true}, + {"patterns":["package-lock.json"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["package-lock.json"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["package-lock.json"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["package-lock.json"],"path":"test/package-lock.json","ignored":true}, + {"patterns":["package-lock.json"],"path":"Test/package-lock.json","ignored":true}, + {"patterns":["package-lock.json"],"path":"tests/fixtures/app/package-lock.json","ignored":true}, + {"patterns":["package-lock.json"],"path":"e2e/tests/package-lock.json","ignored":true}, + {"patterns":["package-lock.json"],"path":"e2e/tests/keep/package-lock.json","ignored":true}, + {"patterns":["package-lock.json"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["package-lock.json"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["package-lock.json"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["package-lock.json"],"path":"examples/demo/package-lock.json","ignored":true}, + {"patterns":["package-lock.json"],"path":"examples/package-lock.json","ignored":true}, + {"patterns":["package-lock.json"],"path":"docs/examples/package-lock.json","ignored":true}, + {"patterns":["package-lock.json"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["package-lock.json"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["package-lock.json"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["package-lock.json"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["package-lock.json"],"path":"foo bar/package-lock.json","ignored":true}, + {"patterns":["package-lock.json"],"path":"x[1]/package-lock.json","ignored":true}, + {"patterns":["**/yarn.lock"],"path":"package-lock.json","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"Cargo.lock","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"a/b/c/yarn.lock","ignored":true}, + {"patterns":["**/yarn.lock"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"a/fixtures/keep/yarn.lock","ignored":true}, + {"patterns":["**/yarn.lock"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"a.lock/yarn.lock","ignored":true}, + {"patterns":["**/yarn.lock"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["**/yarn.lock"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["examples/**"],"path":"package-lock.json","ignored":false}, + {"patterns":["examples/**"],"path":"Cargo.lock","ignored":false}, + {"patterns":["examples/**"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["examples/**"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["examples/**"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["examples/**"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["examples/**"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["examples/**"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["examples/**"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["examples/**"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["examples/**"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["examples/**"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["examples/**"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["examples/**"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["examples/**"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["examples/**"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["examples/**"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["examples/**"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["examples/**"],"path":"examples/demo/package-lock.json","ignored":true}, + {"patterns":["examples/**"],"path":"examples/package-lock.json","ignored":true}, + {"patterns":["examples/**"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["examples/**"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["examples/**"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["examples/**"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["examples/**"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["examples/**"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["examples/**"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["examples/"],"path":"package-lock.json","ignored":false}, + {"patterns":["examples/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["examples/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["examples/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["examples/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["examples/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["examples/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["examples/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["examples/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["examples/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["examples/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["examples/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["examples/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["examples/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["examples/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["examples/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["examples/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["examples/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["examples/"],"path":"examples/demo/package-lock.json","ignored":true}, + {"patterns":["examples/"],"path":"examples/package-lock.json","ignored":true}, + {"patterns":["examples/"],"path":"docs/examples/package-lock.json","ignored":true}, + {"patterns":["examples/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["examples/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["examples/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["examples/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["examples/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["examples/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["/examples/"],"path":"package-lock.json","ignored":false}, + {"patterns":["/examples/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["/examples/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["/examples/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["/examples/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["/examples/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["/examples/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["/examples/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["/examples/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["/examples/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["/examples/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["/examples/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["/examples/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["/examples/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["/examples/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["/examples/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["/examples/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["/examples/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["/examples/"],"path":"examples/demo/package-lock.json","ignored":true}, + {"patterns":["/examples/"],"path":"examples/package-lock.json","ignored":true}, + {"patterns":["/examples/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["/examples/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["/examples/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["/examples/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["/examples/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["/examples/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["/examples/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["examples"],"path":"package-lock.json","ignored":false}, + {"patterns":["examples"],"path":"Cargo.lock","ignored":false}, + {"patterns":["examples"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["examples"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["examples"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["examples"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["examples"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["examples"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["examples"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["examples"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["examples"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["examples"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["examples"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["examples"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["examples"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["examples"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["examples"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["examples"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["examples"],"path":"examples/demo/package-lock.json","ignored":true}, + {"patterns":["examples"],"path":"examples/package-lock.json","ignored":true}, + {"patterns":["examples"],"path":"docs/examples/package-lock.json","ignored":true}, + {"patterns":["examples"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["examples"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["examples"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["examples"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["examples"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["examples"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"package-lock.json","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"Cargo.lock","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":true}, + {"patterns":["crates/x/fixtures/**"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["crates/x/fixtures/**"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"package-lock.json","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"Cargo.lock","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":true}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["crates/*/tests/fixtures/**"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["/legacy/"],"path":"package-lock.json","ignored":false}, + {"patterns":["/legacy/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["/legacy/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["/legacy/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["/legacy/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["/legacy/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["/legacy/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["/legacy/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["/legacy/"],"path":"legacy/requirements.txt","ignored":true}, + {"patterns":["/legacy/"],"path":"Legacy/requirements.txt","ignored":true}, + {"patterns":["/legacy/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["/legacy/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["/legacy/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["/legacy/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["/legacy/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["/legacy/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["/legacy/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["/legacy/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["/legacy/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["/legacy/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["/legacy/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["/legacy/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["/legacy/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["/legacy/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["/legacy/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["/legacy/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["/legacy/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["legacy/"],"path":"package-lock.json","ignored":false}, + {"patterns":["legacy/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["legacy/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["legacy/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["legacy/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["legacy/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["legacy/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["legacy/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["legacy/"],"path":"legacy/requirements.txt","ignored":true}, + {"patterns":["legacy/"],"path":"Legacy/requirements.txt","ignored":true}, + {"patterns":["legacy/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["legacy/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["legacy/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["legacy/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["legacy/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["legacy/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["legacy/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["legacy/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["legacy/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["legacy/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["legacy/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["legacy/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["legacy/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["legacy/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["legacy/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["legacy/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["legacy/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["/services/payments/"],"path":"package-lock.json","ignored":false}, + {"patterns":["/services/payments/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["/services/payments/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["/services/payments/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["/services/payments/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["/services/payments/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["/services/payments/"],"path":"services/payments/package-lock.json","ignored":true}, + {"patterns":["/services/payments/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":true}, + {"patterns":["/services/payments/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["/services/payments/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["/services/payments/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["/services/payments/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["/services/payments/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["/services/payments/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["/services/payments/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["/services/payments/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["/services/payments/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["/services/payments/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["/services/payments/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["/services/payments/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["/services/payments/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["/services/payments/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["/services/payments/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["/services/payments/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["/services/payments/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["/services/payments/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["/services/payments/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["/services/*/"],"path":"package-lock.json","ignored":false}, + {"patterns":["/services/*/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["/services/*/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["/services/*/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["/services/*/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["/services/*/"],"path":"services/api/package-lock.json","ignored":true}, + {"patterns":["/services/*/"],"path":"services/payments/package-lock.json","ignored":true}, + {"patterns":["/services/*/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":true}, + {"patterns":["/services/*/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["/services/*/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["/services/*/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["/services/*/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["/services/*/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["/services/*/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["/services/*/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["/services/*/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["/services/*/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["/services/*/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["/services/*/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["/services/*/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["/services/*/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["/services/*/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["/services/*/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["/services/*/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["/services/*/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["/services/*/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["/services/*/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"package-lock.json","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"Cargo.lock","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"services/api/package-lock.json","ignored":true}, + {"patterns":["services/**/package-lock.json"],"path":"services/payments/package-lock.json","ignored":true}, + {"patterns":["services/**/package-lock.json"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["services/**/package-lock.json"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["/*","!/*/"],"path":"package-lock.json","ignored":true}, + {"patterns":["/*","!/*/"],"path":"Cargo.lock","ignored":true}, + {"patterns":["/*","!/*/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["/*","!/*/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["/*","!/*/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["/*","!/*/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["/*","!/*/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["/*","!/*/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["/*","!/*/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["/*","!/*/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["/*","!/*/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["/*","!/*/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["/*","!/*/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["/*","!/*/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["/*","!/*/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["/*","!/*/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["/*","!/*/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["/*","!/*/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["/*","!/*/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["/*","!/*/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["/*","!/*/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["/*","!/*/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["/*","!/*/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["/*","!/*/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["/*","!/*/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["/*","!/*/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["/*","!/*/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["*","!*/"],"path":"package-lock.json","ignored":true}, + {"patterns":["*","!*/"],"path":"Cargo.lock","ignored":true}, + {"patterns":["*","!*/"],"path":"a/package-lock.json","ignored":true}, + {"patterns":["*","!*/"],"path":"a/b/package-lock.json","ignored":true}, + {"patterns":["*","!*/"],"path":"a/b/c/yarn.lock","ignored":true}, + {"patterns":["*","!*/"],"path":"services/api/package-lock.json","ignored":true}, + {"patterns":["*","!*/"],"path":"services/payments/package-lock.json","ignored":true}, + {"patterns":["*","!*/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":true}, + {"patterns":["*","!*/"],"path":"legacy/requirements.txt","ignored":true}, + {"patterns":["*","!*/"],"path":"Legacy/requirements.txt","ignored":true}, + {"patterns":["*","!*/"],"path":"test/package-lock.json","ignored":true}, + {"patterns":["*","!*/"],"path":"Test/package-lock.json","ignored":true}, + {"patterns":["*","!*/"],"path":"tests/fixtures/app/package-lock.json","ignored":true}, + {"patterns":["*","!*/"],"path":"e2e/tests/package-lock.json","ignored":true}, + {"patterns":["*","!*/"],"path":"e2e/tests/keep/package-lock.json","ignored":true}, + {"patterns":["*","!*/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":true}, + {"patterns":["*","!*/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":true}, + {"patterns":["*","!*/"],"path":"a/fixtures/keep/yarn.lock","ignored":true}, + {"patterns":["*","!*/"],"path":"examples/demo/package-lock.json","ignored":true}, + {"patterns":["*","!*/"],"path":"examples/package-lock.json","ignored":true}, + {"patterns":["*","!*/"],"path":"docs/examples/package-lock.json","ignored":true}, + {"patterns":["*","!*/"],"path":"testdata/go.sum","ignored":true}, + {"patterns":["*","!*/"],"path":"pkg/testdata/go.sum","ignored":true}, + {"patterns":["*","!*/"],"path":"__fixtures__/x/package.json","ignored":true}, + {"patterns":["*","!*/"],"path":"a.lock/yarn.lock","ignored":true}, + {"patterns":["*","!*/"],"path":"foo bar/package-lock.json","ignored":true}, + {"patterns":["*","!*/"],"path":"x[1]/package-lock.json","ignored":true}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"package-lock.json","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"tests/fixtures/app/package-lock.json","ignored":true}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":true}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":true}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"a/fixtures/keep/yarn.lock","ignored":true}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!/a/fixtures/keep/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"package-lock.json","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["fixtures/","!fixtures/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"test/package-lock.json","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"Test/package-lock.json","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"tests/fixtures/app/package-lock.json","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"e2e/tests/package-lock.json","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"e2e/tests/keep/package-lock.json","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"a/fixtures/keep/yarn.lock","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"testdata/go.sum","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"pkg/testdata/go.sum","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"__fixtures__/x/package.json","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"test/package-lock.json","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"Test/package-lock.json","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"tests/fixtures/app/package-lock.json","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"a/fixtures/keep/yarn.lock","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"testdata/go.sum","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"pkg/testdata/go.sum","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"__fixtures__/x/package.json","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!/e2e/tests/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"test/package-lock.json","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"Test/package-lock.json","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"tests/fixtures/app/package-lock.json","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"a/fixtures/keep/yarn.lock","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"testdata/go.sum","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"pkg/testdata/go.sum","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"__fixtures__/x/package.json","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","!tests/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"legacy/requirements.txt","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"Legacy/requirements.txt","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"test/package-lock.json","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"Test/package-lock.json","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"tests/fixtures/app/package-lock.json","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"e2e/tests/package-lock.json","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"e2e/tests/keep/package-lock.json","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"a/fixtures/keep/yarn.lock","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"testdata/go.sum","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"pkg/testdata/go.sum","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"__fixtures__/x/package.json","ignored":true}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["test/","tests/","fixtures/","__fixtures__/","testdata/","/legacy/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"package-lock.json","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"a/package-lock.json","ignored":true}, + {"patterns":["a/","!a/b/"],"path":"a/b/package-lock.json","ignored":true}, + {"patterns":["a/","!a/b/"],"path":"a/b/c/yarn.lock","ignored":true}, + {"patterns":["a/","!a/b/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"a/fixtures/keep/yarn.lock","ignored":true}, + {"patterns":["a/","!a/b/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["a/","!a/b/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"package-lock.json","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"a/package-lock.json","ignored":true}, + {"patterns":["a/*","!a/b/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"a/fixtures/keep/yarn.lock","ignored":true}, + {"patterns":["a/*","!a/b/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["a/*","!a/b/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"package-lock.json","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"Cargo.lock","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"a/package-lock.json","ignored":true}, + {"patterns":["a/**","!a/b/**"],"path":"a/b/package-lock.json","ignored":true}, + {"patterns":["a/**","!a/b/**"],"path":"a/b/c/yarn.lock","ignored":true}, + {"patterns":["a/**","!a/b/**"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"a/fixtures/keep/yarn.lock","ignored":true}, + {"patterns":["a/**","!a/b/**"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["a/**","!a/b/**"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"package-lock.json","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"Cargo.lock","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"a/b/c/yarn.lock","ignored":true}, + {"patterns":["a/**/yarn.lock"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"a/fixtures/keep/yarn.lock","ignored":true}, + {"patterns":["a/**/yarn.lock"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["a/**/yarn.lock"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["**/b/**"],"path":"package-lock.json","ignored":false}, + {"patterns":["**/b/**"],"path":"Cargo.lock","ignored":false}, + {"patterns":["**/b/**"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["**/b/**"],"path":"a/b/package-lock.json","ignored":true}, + {"patterns":["**/b/**"],"path":"a/b/c/yarn.lock","ignored":true}, + {"patterns":["**/b/**"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["**/b/**"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["**/b/**"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["**/b/**"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["**/b/**"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["**/b/**"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["**/b/**"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["**/b/**"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["**/b/**"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["**/b/**"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["**/b/**"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["**/b/**"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["**/b/**"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["**/b/**"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["**/b/**"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["**/b/**"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["**/b/**"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["**/b/**"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["**/b/**"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["**/b/**"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["**/b/**"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["**/b/**"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["*.lock"],"path":"package-lock.json","ignored":false}, + {"patterns":["*.lock"],"path":"Cargo.lock","ignored":true}, + {"patterns":["*.lock"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["*.lock"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["*.lock"],"path":"a/b/c/yarn.lock","ignored":true}, + {"patterns":["*.lock"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["*.lock"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["*.lock"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["*.lock"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["*.lock"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["*.lock"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["*.lock"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["*.lock"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["*.lock"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["*.lock"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["*.lock"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":true}, + {"patterns":["*.lock"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":true}, + {"patterns":["*.lock"],"path":"a/fixtures/keep/yarn.lock","ignored":true}, + {"patterns":["*.lock"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["*.lock"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["*.lock"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["*.lock"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["*.lock"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["*.lock"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["*.lock"],"path":"a.lock/yarn.lock","ignored":true}, + {"patterns":["*.lock"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["*.lock"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"package-lock.json","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"Cargo.lock","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"__fixtures__/x/package.json","ignored":true}, + {"patterns":["*.json","!package-lock.json"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["*.json","!package-lock.json"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["!package-lock.json"],"path":"package-lock.json","ignored":false}, + {"patterns":["!package-lock.json"],"path":"Cargo.lock","ignored":false}, + {"patterns":["!package-lock.json"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["!package-lock.json"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["!package-lock.json"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["!package-lock.json"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["!package-lock.json"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["!package-lock.json"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["!package-lock.json"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["!package-lock.json"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["!package-lock.json"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["!package-lock.json"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["!package-lock.json"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["!package-lock.json"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["!package-lock.json"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["!package-lock.json"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["!package-lock.json"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["!package-lock.json"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["!package-lock.json"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["!package-lock.json"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["!package-lock.json"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["!package-lock.json"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["!package-lock.json"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["!package-lock.json"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["!package-lock.json"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["!package-lock.json"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["!package-lock.json"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["a/b"],"path":"package-lock.json","ignored":false}, + {"patterns":["a/b"],"path":"Cargo.lock","ignored":false}, + {"patterns":["a/b"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["a/b"],"path":"a/b/package-lock.json","ignored":true}, + {"patterns":["a/b"],"path":"a/b/c/yarn.lock","ignored":true}, + {"patterns":["a/b"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["a/b"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["a/b"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["a/b"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["a/b"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["a/b"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["a/b"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["a/b"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["a/b"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["a/b"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["a/b"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["a/b"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["a/b"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["a/b"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["a/b"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["a/b"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["a/b"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["a/b"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["a/b"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["a/b"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["a/b"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["a/b"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["/a/b/"],"path":"package-lock.json","ignored":false}, + {"patterns":["/a/b/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["/a/b/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["/a/b/"],"path":"a/b/package-lock.json","ignored":true}, + {"patterns":["/a/b/"],"path":"a/b/c/yarn.lock","ignored":true}, + {"patterns":["/a/b/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["/a/b/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["/a/b/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["/a/b/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["/a/b/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["/a/b/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["/a/b/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["/a/b/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["/a/b/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["/a/b/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["/a/b/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["/a/b/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["/a/b/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["/a/b/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["/a/b/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["/a/b/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["/a/b/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["/a/b/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["/a/b/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["/a/b/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["/a/b/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["/a/b/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["b/"],"path":"package-lock.json","ignored":false}, + {"patterns":["b/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["b/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["b/"],"path":"a/b/package-lock.json","ignored":true}, + {"patterns":["b/"],"path":"a/b/c/yarn.lock","ignored":true}, + {"patterns":["b/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["b/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["b/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["b/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["b/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["b/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["b/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["b/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["b/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["b/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["b/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["b/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["b/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["b/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["b/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["b/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["b/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["b/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["b/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["b/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["b/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["b/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["**/c/"],"path":"package-lock.json","ignored":false}, + {"patterns":["**/c/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["**/c/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["**/c/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["**/c/"],"path":"a/b/c/yarn.lock","ignored":true}, + {"patterns":["**/c/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["**/c/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["**/c/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["**/c/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["**/c/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["**/c/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["**/c/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["**/c/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["**/c/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["**/c/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["**/c/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["**/c/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["**/c/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["**/c/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["**/c/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["**/c/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["**/c/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["**/c/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["**/c/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["**/c/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["**/c/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["**/c/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["LEGACY/"],"path":"package-lock.json","ignored":false}, + {"patterns":["LEGACY/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["LEGACY/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["LEGACY/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["LEGACY/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["LEGACY/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["LEGACY/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["LEGACY/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["LEGACY/"],"path":"legacy/requirements.txt","ignored":true}, + {"patterns":["LEGACY/"],"path":"Legacy/requirements.txt","ignored":true}, + {"patterns":["LEGACY/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["LEGACY/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["LEGACY/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["LEGACY/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["LEGACY/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["LEGACY/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["LEGACY/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["LEGACY/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["LEGACY/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["LEGACY/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["LEGACY/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["LEGACY/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["LEGACY/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["LEGACY/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["LEGACY/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["LEGACY/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["LEGACY/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["Services/API/"],"path":"package-lock.json","ignored":false}, + {"patterns":["Services/API/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["Services/API/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["Services/API/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["Services/API/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["Services/API/"],"path":"services/api/package-lock.json","ignored":true}, + {"patterns":["Services/API/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["Services/API/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["Services/API/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["Services/API/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["Services/API/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["Services/API/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["Services/API/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["Services/API/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["Services/API/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["Services/API/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["Services/API/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["Services/API/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["Services/API/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["Services/API/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["Services/API/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["Services/API/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["Services/API/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["Services/API/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["Services/API/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["Services/API/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["Services/API/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["foo bar/"],"path":"package-lock.json","ignored":false}, + {"patterns":["foo bar/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["foo bar/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["foo bar/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["foo bar/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["foo bar/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["foo bar/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["foo bar/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["foo bar/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["foo bar/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["foo bar/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["foo bar/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["foo bar/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["foo bar/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["foo bar/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["foo bar/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["foo bar/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["foo bar/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["foo bar/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["foo bar/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["foo bar/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["foo bar/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["foo bar/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["foo bar/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["foo bar/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["foo bar/"],"path":"foo bar/package-lock.json","ignored":true}, + {"patterns":["foo bar/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"package-lock.json","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["foo\\ bar/"],"path":"foo bar/package-lock.json","ignored":true}, + {"patterns":["foo\\ bar/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"package-lock.json","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["x\\[1\\]/"],"path":"x[1]/package-lock.json","ignored":true}, + {"patterns":["x[1]/"],"path":"package-lock.json","ignored":false}, + {"patterns":["x[1]/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["x[1]/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["x[1]/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["x[1]/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["x[1]/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["x[1]/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["x[1]/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["x[1]/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["x[1]/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["x[1]/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["x[1]/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["x[1]/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["x[1]/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["x[1]/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["x[1]/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["x[1]/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["x[1]/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["x[1]/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["x[1]/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["x[1]/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["x[1]/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["x[1]/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["x[1]/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["x[1]/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["x[1]/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["x[1]/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["?.lock/"],"path":"package-lock.json","ignored":false}, + {"patterns":["?.lock/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["?.lock/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["?.lock/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["?.lock/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["?.lock/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["?.lock/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["?.lock/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["?.lock/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["?.lock/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["?.lock/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["?.lock/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["?.lock/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["?.lock/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["?.lock/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["?.lock/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["?.lock/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["?.lock/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["?.lock/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["?.lock/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["?.lock/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["?.lock/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["?.lock/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["?.lock/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["?.lock/"],"path":"a.lock/yarn.lock","ignored":true}, + {"patterns":["?.lock/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["?.lock/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["a.lock/"],"path":"package-lock.json","ignored":false}, + {"patterns":["a.lock/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["a.lock/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["a.lock/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["a.lock/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["a.lock/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["a.lock/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["a.lock/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["a.lock/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["a.lock/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["a.lock/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["a.lock/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["a.lock/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["a.lock/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["a.lock/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["a.lock/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["a.lock/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["a.lock/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["a.lock/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["a.lock/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["a.lock/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["a.lock/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["a.lock/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["a.lock/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["a.lock/"],"path":"a.lock/yarn.lock","ignored":true}, + {"patterns":["a.lock/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["a.lock/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["a/b/c/"],"path":"package-lock.json","ignored":false}, + {"patterns":["a/b/c/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["a/b/c/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["a/b/c/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["a/b/c/"],"path":"a/b/c/yarn.lock","ignored":true}, + {"patterns":["a/b/c/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["a/b/c/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["a/b/c/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["a/b/c/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["a/b/c/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["a/b/c/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["a/b/c/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["a/b/c/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["a/b/c/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["a/b/c/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["a/b/c/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["a/b/c/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["a/b/c/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["a/b/c/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["a/b/c/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["a/b/c/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["a/b/c/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["a/b/c/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["a/b/c/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["a/b/c/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["a/b/c/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["a/b/c/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["**"],"path":"package-lock.json","ignored":true}, + {"patterns":["**"],"path":"Cargo.lock","ignored":true}, + {"patterns":["**"],"path":"a/package-lock.json","ignored":true}, + {"patterns":["**"],"path":"a/b/package-lock.json","ignored":true}, + {"patterns":["**"],"path":"a/b/c/yarn.lock","ignored":true}, + {"patterns":["**"],"path":"services/api/package-lock.json","ignored":true}, + {"patterns":["**"],"path":"services/payments/package-lock.json","ignored":true}, + {"patterns":["**"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":true}, + {"patterns":["**"],"path":"legacy/requirements.txt","ignored":true}, + {"patterns":["**"],"path":"Legacy/requirements.txt","ignored":true}, + {"patterns":["**"],"path":"test/package-lock.json","ignored":true}, + {"patterns":["**"],"path":"Test/package-lock.json","ignored":true}, + {"patterns":["**"],"path":"tests/fixtures/app/package-lock.json","ignored":true}, + {"patterns":["**"],"path":"e2e/tests/package-lock.json","ignored":true}, + {"patterns":["**"],"path":"e2e/tests/keep/package-lock.json","ignored":true}, + {"patterns":["**"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":true}, + {"patterns":["**"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":true}, + {"patterns":["**"],"path":"a/fixtures/keep/yarn.lock","ignored":true}, + {"patterns":["**"],"path":"examples/demo/package-lock.json","ignored":true}, + {"patterns":["**"],"path":"examples/package-lock.json","ignored":true}, + {"patterns":["**"],"path":"docs/examples/package-lock.json","ignored":true}, + {"patterns":["**"],"path":"testdata/go.sum","ignored":true}, + {"patterns":["**"],"path":"pkg/testdata/go.sum","ignored":true}, + {"patterns":["**"],"path":"__fixtures__/x/package.json","ignored":true}, + {"patterns":["**"],"path":"a.lock/yarn.lock","ignored":true}, + {"patterns":["**"],"path":"foo bar/package-lock.json","ignored":true}, + {"patterns":["**"],"path":"x[1]/package-lock.json","ignored":true}, + {"patterns":["**/"],"path":"package-lock.json","ignored":false}, + {"patterns":["**/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["**/"],"path":"a/package-lock.json","ignored":true}, + {"patterns":["**/"],"path":"a/b/package-lock.json","ignored":true}, + {"patterns":["**/"],"path":"a/b/c/yarn.lock","ignored":true}, + {"patterns":["**/"],"path":"services/api/package-lock.json","ignored":true}, + {"patterns":["**/"],"path":"services/payments/package-lock.json","ignored":true}, + {"patterns":["**/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":true}, + {"patterns":["**/"],"path":"legacy/requirements.txt","ignored":true}, + {"patterns":["**/"],"path":"Legacy/requirements.txt","ignored":true}, + {"patterns":["**/"],"path":"test/package-lock.json","ignored":true}, + {"patterns":["**/"],"path":"Test/package-lock.json","ignored":true}, + {"patterns":["**/"],"path":"tests/fixtures/app/package-lock.json","ignored":true}, + {"patterns":["**/"],"path":"e2e/tests/package-lock.json","ignored":true}, + {"patterns":["**/"],"path":"e2e/tests/keep/package-lock.json","ignored":true}, + {"patterns":["**/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":true}, + {"patterns":["**/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":true}, + {"patterns":["**/"],"path":"a/fixtures/keep/yarn.lock","ignored":true}, + {"patterns":["**/"],"path":"examples/demo/package-lock.json","ignored":true}, + {"patterns":["**/"],"path":"examples/package-lock.json","ignored":true}, + {"patterns":["**/"],"path":"docs/examples/package-lock.json","ignored":true}, + {"patterns":["**/"],"path":"testdata/go.sum","ignored":true}, + {"patterns":["**/"],"path":"pkg/testdata/go.sum","ignored":true}, + {"patterns":["**/"],"path":"__fixtures__/x/package.json","ignored":true}, + {"patterns":["**/"],"path":"a.lock/yarn.lock","ignored":true}, + {"patterns":["**/"],"path":"foo bar/package-lock.json","ignored":true}, + {"patterns":["**/"],"path":"x[1]/package-lock.json","ignored":true}, + {"patterns":["/**/package-lock.json"],"path":"package-lock.json","ignored":true}, + {"patterns":["/**/package-lock.json"],"path":"Cargo.lock","ignored":false}, + {"patterns":["/**/package-lock.json"],"path":"a/package-lock.json","ignored":true}, + {"patterns":["/**/package-lock.json"],"path":"a/b/package-lock.json","ignored":true}, + {"patterns":["/**/package-lock.json"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["/**/package-lock.json"],"path":"services/api/package-lock.json","ignored":true}, + {"patterns":["/**/package-lock.json"],"path":"services/payments/package-lock.json","ignored":true}, + {"patterns":["/**/package-lock.json"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["/**/package-lock.json"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["/**/package-lock.json"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["/**/package-lock.json"],"path":"test/package-lock.json","ignored":true}, + {"patterns":["/**/package-lock.json"],"path":"Test/package-lock.json","ignored":true}, + {"patterns":["/**/package-lock.json"],"path":"tests/fixtures/app/package-lock.json","ignored":true}, + {"patterns":["/**/package-lock.json"],"path":"e2e/tests/package-lock.json","ignored":true}, + {"patterns":["/**/package-lock.json"],"path":"e2e/tests/keep/package-lock.json","ignored":true}, + {"patterns":["/**/package-lock.json"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["/**/package-lock.json"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["/**/package-lock.json"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["/**/package-lock.json"],"path":"examples/demo/package-lock.json","ignored":true}, + {"patterns":["/**/package-lock.json"],"path":"examples/package-lock.json","ignored":true}, + {"patterns":["/**/package-lock.json"],"path":"docs/examples/package-lock.json","ignored":true}, + {"patterns":["/**/package-lock.json"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["/**/package-lock.json"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["/**/package-lock.json"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["/**/package-lock.json"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["/**/package-lock.json"],"path":"foo bar/package-lock.json","ignored":true}, + {"patterns":["/**/package-lock.json"],"path":"x[1]/package-lock.json","ignored":true}, + {"patterns":["services/"],"path":"package-lock.json","ignored":false}, + {"patterns":["services/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["services/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["services/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["services/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["services/"],"path":"services/api/package-lock.json","ignored":true}, + {"patterns":["services/"],"path":"services/payments/package-lock.json","ignored":true}, + {"patterns":["services/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":true}, + {"patterns":["services/"],"path":"legacy/requirements.txt","ignored":false}, + {"patterns":["services/"],"path":"Legacy/requirements.txt","ignored":false}, + {"patterns":["services/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["services/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["services/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["services/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["services/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["services/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["services/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["services/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["services/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["services/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["services/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["services/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["services/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["services/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["services/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["services/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["services/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"package-lock.json","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"legacy/requirements.txt","ignored":true}, + {"patterns":["#comment","legacy/"],"path":"Legacy/requirements.txt","ignored":true}, + {"patterns":["#comment","legacy/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["#comment","legacy/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"package-lock.json","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"Cargo.lock","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"a/package-lock.json","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"legacy/requirements.txt","ignored":true}, + {"patterns":["\\#x","legacy/"],"path":"Legacy/requirements.txt","ignored":true}, + {"patterns":["\\#x","legacy/"],"path":"test/package-lock.json","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"testdata/go.sum","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["\\#x","legacy/"],"path":"x[1]/package-lock.json","ignored":false}, + {"patterns":["legacy/ "],"path":"package-lock.json","ignored":false}, + {"patterns":["legacy/ "],"path":"Cargo.lock","ignored":false}, + {"patterns":["legacy/ "],"path":"a/package-lock.json","ignored":false}, + {"patterns":["legacy/ "],"path":"a/b/package-lock.json","ignored":false}, + {"patterns":["legacy/ "],"path":"a/b/c/yarn.lock","ignored":false}, + {"patterns":["legacy/ "],"path":"services/api/package-lock.json","ignored":false}, + {"patterns":["legacy/ "],"path":"services/payments/package-lock.json","ignored":false}, + {"patterns":["legacy/ "],"path":"services/payments/sub/pnpm-lock.yaml","ignored":false}, + {"patterns":["legacy/ "],"path":"legacy/requirements.txt","ignored":true}, + {"patterns":["legacy/ "],"path":"Legacy/requirements.txt","ignored":true}, + {"patterns":["legacy/ "],"path":"test/package-lock.json","ignored":false}, + {"patterns":["legacy/ "],"path":"Test/package-lock.json","ignored":false}, + {"patterns":["legacy/ "],"path":"tests/fixtures/app/package-lock.json","ignored":false}, + {"patterns":["legacy/ "],"path":"e2e/tests/package-lock.json","ignored":false}, + {"patterns":["legacy/ "],"path":"e2e/tests/keep/package-lock.json","ignored":false}, + {"patterns":["legacy/ "],"path":"crates/x/tests/fixtures/app/Cargo.lock","ignored":false}, + {"patterns":["legacy/ "],"path":"crates/x/fixtures/keep/Cargo.lock","ignored":false}, + {"patterns":["legacy/ "],"path":"a/fixtures/keep/yarn.lock","ignored":false}, + {"patterns":["legacy/ "],"path":"examples/demo/package-lock.json","ignored":false}, + {"patterns":["legacy/ "],"path":"examples/package-lock.json","ignored":false}, + {"patterns":["legacy/ "],"path":"docs/examples/package-lock.json","ignored":false}, + {"patterns":["legacy/ "],"path":"testdata/go.sum","ignored":false}, + {"patterns":["legacy/ "],"path":"pkg/testdata/go.sum","ignored":false}, + {"patterns":["legacy/ "],"path":"__fixtures__/x/package.json","ignored":false}, + {"patterns":["legacy/ "],"path":"a.lock/yarn.lock","ignored":false}, + {"patterns":["legacy/ "],"path":"foo bar/package-lock.json","ignored":false}, + {"patterns":["legacy/ "],"path":"x[1]/package-lock.json","ignored":false} +]} diff --git a/scripts/gen-ignore-golden.mjs b/scripts/gen-ignore-golden.mjs new file mode 100644 index 00000000..35bedac3 --- /dev/null +++ b/scripts/gen-ignore-golden.mjs @@ -0,0 +1,113 @@ +// Regenerates crates/socket-patch-core/tests/fixtures/ignore_golden.json: +// the (patterns, path, ignored) table the socket.yml path matcher must +// agree with. The expected values come from the npm `ignore` package, the +// matcher the Socket backend applies to `projectIgnorePaths`. +// +// cd "$(mktemp -d)" && npm init -y >/dev/null && npm i ignore@7.0.10 \ +// && NODE_PATH="$PWD/node_modules" node /path/to/scripts/gen-ignore-golden.mjs +import { createRequire } from 'node:module' +import { writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const require = createRequire(join(process.env.NODE_PATH ?? '.', 'x.js')) +const ignore = require('ignore') +const version = require('ignore/package.json').version + +const paths = [ + 'package-lock.json', + 'Cargo.lock', + 'a/package-lock.json', + 'a/b/package-lock.json', + 'a/b/c/yarn.lock', + 'services/api/package-lock.json', + 'services/payments/package-lock.json', + 'services/payments/sub/pnpm-lock.yaml', + 'legacy/requirements.txt', + 'Legacy/requirements.txt', + 'test/package-lock.json', + 'Test/package-lock.json', + 'tests/fixtures/app/package-lock.json', + 'e2e/tests/package-lock.json', + 'e2e/tests/keep/package-lock.json', + 'crates/x/tests/fixtures/app/Cargo.lock', + 'crates/x/fixtures/keep/Cargo.lock', + 'a/fixtures/keep/yarn.lock', + 'examples/demo/package-lock.json', + 'examples/package-lock.json', + 'docs/examples/package-lock.json', + 'testdata/go.sum', + 'pkg/testdata/go.sum', + '__fixtures__/x/package.json', + 'a.lock/yarn.lock', + 'foo bar/package-lock.json', + 'x[1]/package-lock.json', +] + +const sets = [ + [], + ['/package-lock.json'], + ['package-lock.json'], + ['**/yarn.lock'], + ['examples/**'], + ['examples/'], + ['/examples/'], + ['examples'], + ['crates/x/fixtures/**'], + ['crates/*/tests/fixtures/**'], + ['/legacy/'], + ['legacy/'], + ['/services/payments/'], + ['/services/*/'], + ['services/**/package-lock.json'], + ['/*', '!/*/'], + ['*', '!*/'], + ['fixtures/', '!/a/fixtures/keep/'], + ['fixtures/', '!fixtures/'], + ['test/', 'tests/', 'fixtures/', '__fixtures__/', 'testdata/'], + ['test/', 'tests/', 'fixtures/', '__fixtures__/', 'testdata/', '!/e2e/tests/'], + ['test/', 'tests/', 'fixtures/', '__fixtures__/', 'testdata/', '!tests/'], + ['test/', 'tests/', 'fixtures/', '__fixtures__/', 'testdata/', '/legacy/'], + ['a/', '!a/b/'], + ['a/*', '!a/b/'], + ['a/**', '!a/b/**'], + ['a/**/yarn.lock'], + ['**/b/**'], + ['*.lock'], + ['*.json', '!package-lock.json'], + ['!package-lock.json'], + ['a/b'], + ['/a/b/'], + ['b/'], + ['**/c/'], + ['LEGACY/'], + ['Services/API/'], + ['foo bar/'], + ['foo\\ bar/'], + ['x\\[1\\]/'], + ['x[1]/'], + ['?.lock/'], + ['a.lock/'], + ['a/b/c/'], + ['**'], + ['**/'], + ['/**/package-lock.json'], + ['services/'], + ['#comment', 'legacy/'], + ['\\#x', 'legacy/'], + ['legacy/ '], +] + +const cases = [] +for (const patterns of sets) { + const ig = ignore().add(patterns) + for (const path of paths) { + cases.push({ patterns, path, ignored: ig.ignores(path) }) + } +} + +const here = dirname(fileURLToPath(import.meta.url)) +const out = join(here, '..', 'crates', 'socket-patch-core', 'tests', 'fixtures', 'ignore_golden.json') +const body = cases.map((c) => ' ' + JSON.stringify(c)).join(',\n') +writeFileSync(out, `{"generator": "ignore@${version}", "cases": [\n${body}\n]}\n`) +console.log(`wrote ${cases.length} cases to ${out}`) From b0eaacba8a0e29f07d840e53ce1fd141a5b9d1e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 14:07:33 +0000 Subject: [PATCH 07/22] Cap new patches per scan, most critical first scan gains --max-new-patches (env SOCKET_MAX_NEW_PATCHES). Each selected package is classified against the recorded state (manifest, hosted pins, vendor ledger): packages that already carry a patch always go through, and an already-applied patch is kept unless the selection really supersedes it, so re-scans never swap patches. Packages getting their first patch are admitted most severe first until the budget is spent; the rest are deferred to the next scan. Hosted, vendored and agent runs decide eligibility with their own write-free checks before any budget is spent, so a patch that cannot land never holds a slot. Several PATH directories share one budget. --json reports a rollout block, deferred rows show up in redirect.skipped[] as rollout_deferred, and human output adds a Rollout line and a Next up list. updates[] now uses the by-package records. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/src/commands/get.rs | 2 + .../src/commands/scan/hosted.rs | 599 +++++++----- .../socket-patch-cli/src/commands/scan/mod.rs | 427 ++++++--- .../src/commands/scan/rollout.rs | 876 ++++++++++++++++++ .../src/commands/scan/rollout_args.rs | 145 +++ .../src/commands/scan/vendor_flow.rs | 64 +- .../socket-patch-cli/tests/cli_parse_scan.rs | 6 + .../tests/in_process_cargo_apply.rs | 3 + .../tests/in_process_gem_apply.rs | 2 + .../tests/in_process_gem_multi_platform.rs | 1 + .../tests/in_process_pypi_apply.rs | 4 + .../tests/in_process_pypi_multi_release.rs | 1 + .../tests/in_process_python_envs.rs | 1 + .../tests/in_process_redirect.rs | 1 + .../tests/in_process_redirect_pdm.rs | 1 + .../tests/in_process_redirect_pipenv.rs | 1 + .../tests/in_process_redirect_pnpm.rs | 1 + .../tests/in_process_redirect_poetry.rs | 1 + .../in_process_remote_ecosystems_apply.rs | 1 + .../tests/in_process_rollback_hosted.rs | 1 + .../socket-patch-cli/tests/in_process_scan.rs | 1 + .../tests/in_process_vendor.rs | 1 + crates/socket-patch-core/src/rollout.rs | 5 +- 23 files changed, 1795 insertions(+), 350 deletions(-) create mode 100644 crates/socket-patch-cli/src/commands/scan/rollout.rs create mode 100644 crates/socket-patch-cli/src/commands/scan/rollout_args.rs diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index e6a2c1ad..dd945d39 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -3722,6 +3722,8 @@ async fn run_get_hosted( &pairs, scan_result, None, + // `get` is explicit intent: the rollout cap never applies. + None, ) .await } diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 6231254a..26a904b2 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -1041,6 +1041,7 @@ fn gem_sha_key(purl: &str) -> (String, String) { /// then rewrite ONLY those dependencies' lockfile/registry-config entries to /// point at the hosted vendored patches (the byte-identical counterpart of the /// GitHub-app registry mode). No artifact bytes land in the repo. +#[allow(clippy::too_many_arguments)] pub(super) async fn run_redirect( args: &ScanArgs, api_client: &socket_patch_core::api::client::ApiClient, @@ -1058,13 +1059,19 @@ pub(super) async fn run_redirect( // handed to the VEX step so it does not walk the tree for the npm // roots again. npm_prior: Option<&crate::ecosystem_dispatch::NpmCrawlSnapshot>, + // The merged recorded view (manifest > hosted pins > vendor ledger) the + // rollout classifies against, whether a batch failed, and the stage + // that holds this directory's budget. + recorded: Option<&socket_patch_core::manifest::schema::PatchManifest>, + batch_failed: bool, + stage: &mut super::rollout::Stage, ) -> i32 { // Same discovery/selection as `--apply`/`--vendor`. - let selected = match discover_selected( + let discovered = match discover_selected( api_client, all_packages_with_patches, can_access_paid_patches, - &args.common, + false, false, false, telemetry, @@ -1072,7 +1079,7 @@ pub(super) async fn run_redirect( ) .await { - Ok(s) => s, + Ok(d) => d, // Hosted mode has no discovery envelope to fold the message into at // this point (it builds its `redirect` result further down). // `discover_selected` already printed the message to stderr; a @@ -1089,13 +1096,22 @@ pub(super) async fn run_redirect( return code; } }; + let rows = super::classified_rows( + stage, + &discovered, + recorded, + batch_failed, + all_packages_with_patches, + scan_result.as_mut(), + ); // The redirect body consumes the selection only as (purl, uuid) pairs — // the seam `get --mode hosted` injects its advisory-pinned selection - // through (see `run_redirect_selected`). - let pairs: Vec<(String, String)> = selected + // through (see `run_redirect_selected`). ALREADY rows carry the + // recorded uuid, so a re-scan re-confirms the pin instead of swapping it. + let pairs: Vec<(String, String)> = rows .iter() - .map(|s| (s.purl.clone(), s.uuid.clone())) + .map(|r| (r.writer.purl.clone(), r.writer.uuid.clone())) .collect(); run_redirect_selected( &args.common, @@ -1105,10 +1121,258 @@ pub(super) async fn run_redirect( &pairs, scan_result, npm_prior, + Some(super::rollout::Gate { stage, rows }), ) .await } +/// One granted reference in [`run_redirect_selected`]: the purl it was +/// granted for plus the rewriter override built from it. The purl is what +/// the takeover, the skip records and the confirmation probe key on; +/// everything the probe needs AFTER the rewrite to decide whether the dep +/// was actually redirected (artifact URL, registry index URL, fail-closed +/// maven's suffixed version) already rides the override. The single vector +/// is filtered in place by every withhold/refusal step, and the rewriters' +/// `overrides` slice is materialized from it once, after the last filter. +struct Candidate { + purl: String, + /// The selection's own purl spelling (the rollout rows key on it; the + /// server's reference may spell `purl` differently). + sel_purl: String, + dep: DepOverride, +} + +/// What every rewrite pass of [`run_redirect_selected`] shares besides the +/// files and the overrides. +struct RewriteInputs<'a> { + python_metadata: &'a std::collections::BTreeMap, + pipenv_major: Option, + bun_lockb_present: bool, + withheld_from_vlt: &'a std::collections::BTreeSet, + /// The binary `bun.lockb` bytes (or why they are unusable), when the + /// lock is rewritten directly. + binary_content: Option<&'a Result, socket_patch_core::patch::redirect::RewriteWarning>>, +} + +/// One pure rewrite pass over the candidate files: the text rewriters on +/// the blocking pool, then the binary Bun lock. `files` comes back for the +/// confirmation probe. +async fn rewrite_candidates( + files: std::collections::BTreeMap, + overrides: &[DepOverride], + inputs: &RewriteInputs<'_>, +) -> ( + std::collections::BTreeMap, + socket_patch_core::patch::redirect::RewriteResult, +) { + // A malformed primary lock must not cause edits to stale npm siblings. + let rewrite_overrides: Vec = overrides + .iter() + .filter(|o| !(inputs.binary_content.is_some_and(Result::is_err) && o.ecosystem == "npm")) + .cloned() + .collect(); + // Pure CPU over every lock text (the independent rewriter groups run + // concurrently inside), so it runs on the blocking pool rather than on a + // runtime worker. + let python_metadata = inputs.python_metadata.clone(); + let pipenv_major = inputs.pipenv_major; + let bun_lockb_present = inputs.bun_lockb_present; + let withheld_from_vlt = inputs.withheld_from_vlt.clone(); + let (files, mut rewrite) = tokio::task::spawn_blocking(move || { + let rewrite = socket_patch_core::patch::redirect::rewrite_registry_redirect_withholding_vlt( + &files, + &rewrite_overrides, + &python_metadata, + pipenv_major, + bun_lockb_present, + &withheld_from_vlt, + ); + (files, rewrite) + }) + .await + .unwrap_or_else(|e| match e.try_into_panic() { + Ok(payload) => std::panic::resume_unwind(payload), + Err(e) => panic!("hosted rewrite task failed: {e}"), + }); + if let Some(content) = inputs.binary_content { + rewrite + .warnings + .retain(|w| w.code != "redirect_npm_no_lockfile"); + match content { + Ok(bytes) => { + socket_patch_core::patch::redirect::rewrite_bun_binary(bytes, overrides, &mut rewrite) + } + Err(warning) => rewrite.warnings.push(warning.clone()), + } + } + (files, rewrite) +} + +/// The confirmation probe of [`run_redirect_selected`], one answer per +/// candidate: whether its redirect lands in the project's final texts. +fn confirmed_mask( + candidates: &[Candidate], + files: &std::collections::BTreeMap, + rewrite: &socket_patch_core::patch::redirect::RewriteResult, + binary_bun: bool, + withheld_from_vlt: &std::collections::BTreeSet, +) -> Vec { + // A dep counts as REDIRECTED only if its hosted-artifact URL (or its + // per-dependency registry index URL) actually landed in the project's + // files — either written by this run or already present from an earlier + // one. A granted reference whose rewriter found nothing to edit (e.g. no + // lockfile) must NOT be recorded or attested: nothing pins the patch. + // A `pdm.lock` that is NOT the PyPI install driver (a `uv.lock` or + // `poetry.lock` sits beside it) is never rewritten, yet can still carry a + // Socket artifact URL from an earlier run. That stale text pins nothing, + // so it must not feed the substring probe below. When pdm DOES drive, + // pypi confirmation keys off `confirmed_pdm_uuids`, so dropping the file + // is always safe. + let pdm_inactive = + files.contains_key("pdm.lock") && !socket_patch_core::patch::redirect::pdm_drives(files); + // Likewise a `vlt-lock.json` the vlt rewrite was withheld from (its + // artifact failed the preflight beside another npm-family lock) may + // still hold an earlier run's pin: only the sibling lock this run + // rewrote can confirm that dep. + let final_texts: Vec<(&str, &String)> = files + .iter() + .filter(|(name, _)| !(pdm_inactive && name.as_str() == "pdm.lock")) + .map(|(name, content)| (name.as_str(), rewrite.files.get(name).unwrap_or(content))) + .chain( + rewrite + .files + .iter() + .filter(|(name, _)| !files.contains_key(*name)) + .map(|(name, content)| (name.as_str(), content)), + ) + .collect(); + // Every non-substring rule decides a candidate outright; the rest are + // confirmed by substring presence of their needles in the final texts. + // All needle groups are answered in ONE multi-needle pass per text + // (`groups_present`), which is the per-candidate `any()` exactly — + // presence does not depend on search order, and `confirmed` keeps + // candidate order. `candidate_present_oracle` is the reference form. + let steps: Vec = candidates + .iter() + .map(|c| { + let purl = c.purl.as_str(); + let uuid = c.dep.patch_uuid.as_str(); + // vlt decides before the binary-bun rule, so `bun.lockb` beside + // a vlt-driven `vlt-lock.json` never confirms an npm purl. + if rewrite.refused_vlt_uuids.contains(uuid) { + return ProbeStep::Decided(false); + } + if rewrite.vlt_drives && purl.starts_with("pkg:npm/") { + return ProbeStep::Decided(rewrite.confirmed_vlt_uuids.contains(uuid)); + } + if binary_bun && purl.starts_with("pkg:npm/") { + return ProbeStep::Decided(rewrite.confirmed_bun_binary_uuids.contains(uuid)); + } + if rewrite.refused_pipenv_uuids.contains(uuid) { + return ProbeStep::Decided(false); + } + // pdm is transactional like cargo: a refused uuid is never + // confirmed, and when `pdm.lock` is the PyPI install driver + // (no `uv.lock` / `poetry.lock`) a pypi dep is confirmed ONLY + // by the pdm rewriter's own report — the URL landing in a + // sibling `requirements.txt` the project does not install from + // pins nothing. When uv/poetry drive, their own lock proof + // below still confirms them. This check precedes the hatch + // gate: a PDM project may declare `hatchling` as its build + // backend, which registers every pypi uuid as hatch-owned while + // the lock's presence keeps hatch from confirming any of them. + if rewrite.refused_pdm_uuids.contains(uuid) { + return ProbeStep::Decided(false); + } + if purl.starts_with("pkg:pypi/") + && socket_patch_core::patch::redirect::pdm_drives(files) + { + return ProbeStep::Decided(rewrite.confirmed_pdm_uuids.contains(uuid)); + } + if rewrite.python_lock_uuids.contains(uuid) { + return ProbeStep::Decided( + rewrite.confirmed_python_lock_uuids.contains(uuid) + && !rewrite.refused_python_lock_uuids.contains(uuid), + ); + } + if rewrite.hatch_uuids.contains(uuid) { + return ProbeStep::Decided(rewrite.confirmed_hatch_uuids.contains(uuid)); + } + // A Pipfile.lock rewrite confirms its own uuids (the sibling + // requirements.txt rewriter may have had nothing to do). + if purl.starts_with("pkg:pypi/") { + return ProbeStep::Decided( + rewrite.confirmed_pipenv_uuids.contains(uuid) + || rewrite.confirmed_requirements_uuids.contains(uuid), + ); + } + if rewrite.refused_pnpm_uuids.contains(uuid) { + return ProbeStep::Decided(false); + } + // Cargo is transactional: the rewriter reports exactly which + // patch uuids FULLY landed (manifest pin + lock + registry + // block). Substring presence must never confirm a cargo dep — + // the `[registries.…]` config block contains the index URL while + // pinning nothing, so a config-block-only rewrite would be + // attested with zero enforcement in any build. + if purl.starts_with("pkg:cargo/") { + return ProbeStep::Decided(rewrite.confirmed_cargo_uuids.contains(uuid)); + } + // Golang likewise: the goproxy `indexUrl` is the bare + // patch-server origin (present in any other hosted lock), and + // the socket module's go.sum lines outlive a removed replace. + if purl.starts_with("pkg:golang/") { + return ProbeStep::Decided(rewrite.confirmed_golang_uuids.contains(uuid)); + } + let needles = candidate_presence_needles(&c.dep); + if withheld_from_vlt.contains(uuid) { + ProbeStep::NeedlesOutsideVlt(needles) + } else { + ProbeStep::Needles(needles) + } + }) + .collect(); + let groups = |outside_vlt: bool| -> Vec<&[String]> { + steps + .iter() + .filter_map(|step| match step { + ProbeStep::Needles(needles) if !outside_vlt => Some(needles.as_slice()), + ProbeStep::NeedlesOutsideVlt(needles) if outside_vlt => Some(needles.as_slice()), + _ => None, + }) + .collect() + }; + let all_texts: Vec<&String> = final_texts.iter().map(|(_, text)| *text).collect(); + let mut present = + socket_patch_core::patch::redirect::presence::groups_present(&all_texts, &groups(false)) + .into_iter(); + let outside_vlt_groups = groups(true); + let mut present_outside_vlt = if outside_vlt_groups.is_empty() { + Vec::new() + } else { + let texts: Vec<&String> = final_texts + .iter() + .filter(|(name, _)| *name != socket_patch_core::constants::npm_family::VLT_LOCK) + .map(|(_, text)| *text) + .collect(); + socket_patch_core::patch::redirect::presence::groups_present(&texts, &outside_vlt_groups) + } + .into_iter(); + candidates + .iter() + .zip(&steps) + .map(|(_, step)| match step { + ProbeStep::Decided(keep) => *keep, + ProbeStep::Needles(_) => present + .next() + .expect("one presence answer per needle group"), + ProbeStep::NeedlesOutsideVlt(_) => present_outside_vlt + .next() + .expect("one presence answer per needle group"), + }) + .collect() +} + /// How the confirmation probe in [`run_redirect_selected`] settles one /// candidate: a non-substring rule (a transactional rewriter's own report, a /// refusal) decides it outright, otherwise it is confirmed iff any of its @@ -1193,6 +1457,7 @@ fn candidate_present_oracle( /// human/JSON split keys on `common.json`; a `--json` caller passing `None` /// would get a minimal envelope that drops its own keys). `prune_requested` /// only feeds the `redirect_prune_ignored` warning — `get` passes `false`. +#[allow(clippy::too_many_arguments)] pub(crate) async fn run_redirect_selected( common: &crate::args::GlobalArgs, vex: &crate::commands::vex::VexEmbedArgs, @@ -1201,25 +1466,13 @@ pub(crate) async fn run_redirect_selected( selected: &[(String, String)], mut scan_result: Option, npm_prior: Option<&crate::ecosystem_dispatch::NpmCrawlSnapshot>, + // `scan`'s rollout gate: NEW rows past the budget are deferred after + // every write-free eligibility check below (§5.2). `get` passes `None`. + mut rollout: Option>, ) -> i32 { use socket_patch_core::manifest::schema::PatchRecord; - use socket_patch_core::patch::redirect::{ - rewrite_registry_redirect_withholding_vlt, - }; let mut skipped: Vec = Vec::new(); - /// One granted reference: the purl it was granted for plus the rewriter - /// override built from it. The purl is what the takeover, the skip - /// records and the confirmation probe key on; everything the probe - /// needs AFTER the rewrite to decide whether the dep was actually - /// redirected (artifact URL, registry index URL, fail-closed maven's - /// suffixed version) already rides the override. The single vector is - /// filtered in place by every withhold/refusal step, and the rewriters' - /// `overrides` slice is materialized from it once, after the last filter. - struct Candidate { - purl: String, - dep: DepOverride, - } let mut candidates: Vec = Vec::new(); // The network phases below (reference grants, wheel metadata, patch // records) would otherwise be silent gaps on a terminal. Inert under @@ -1236,6 +1489,20 @@ pub(crate) async fn run_redirect_selected( status.finish(); let references = match fetched { Ok(r) => r, + // A capped run whose every row is NEW: the failure affects only + // rows the incomplete lookup defers anyway (§5.2), so it becomes + // a warning instead of failing the run. + Err(e) + if rollout.as_ref().is_some_and(|gate| { + gate.stage.capped() && selected.iter().all(|(p, u)| gate.is_new(p, u)) + }) => + { + if let Some(gate) = rollout.as_mut() { + gate.stage.incomplete = true; + gate.stage.reference_failed = Some(e.to_string()); + } + std::collections::HashMap::new() + } Err(e) => { let message = format!("failed to resolve patch references: {e}"); eprintln!( @@ -1248,7 +1515,13 @@ pub(crate) async fn run_redirect_selected( return 1; } }; + let references_failed = rollout + .as_ref() + .is_some_and(|gate| gate.stage.reference_failed.is_some()); for (sel_purl, sel_uuid) in selected { + if references_failed { + continue; + } let Some(reference) = references.get(sel_uuid) else { skipped.push(serde_json::json!({ "purl": sel_purl, "uuid": sel_uuid, "reason": "not_found" })); continue; @@ -1330,6 +1603,7 @@ pub(crate) async fn run_redirect_selected( .unwrap_or_default(); candidates.push(Candidate { purl: purl.to_string(), + sel_purl: sel_purl.clone(), dep: DepOverride { ecosystem, name, @@ -1405,7 +1679,15 @@ pub(crate) async fn run_redirect_selected( // preview must not create `.socket/`, flip to `lock_held` under a // concurrent wet run, or fail on a read-only checkout). Held to the end // of the function. - let _lock: Option = if !common.dry_run && !candidates.is_empty() { + // A capped run whose only candidates are NEW rows it cannot admit + // (budget 0, or incomplete data) writes nothing: take no lock either. + let may_write = rollout.as_ref().is_none_or(|gate| { + gate.stage.may_admit_new() + || candidates + .iter() + .any(|c| !gate.is_new(&c.sel_purl, &c.dep.patch_uuid)) + }); + let _lock: Option = if !common.dry_run && !candidates.is_empty() && may_write { match acquire_hosted_lock(common, &mut scan_result) { Ok(guard) => Some(guard), Err(code) => return code, @@ -2059,9 +2341,10 @@ pub(crate) async fn run_redirect_selected( } status.finish(); candidates.retain(|c| !unavailable_python_artifacts.contains(&c.dep.artifact_url)); - // The rewriters' override slice — materialized ONCE, after the last - // candidate filter, so it can never disagree with `candidates`. - let overrides: Vec = candidates.iter().map(|c| c.dep.clone()).collect(); + // The rewriters' override slice — materialized after the last candidate + // filter (and again after the rollout gate), so it can never disagree + // with `candidates`. + let mut overrides: Vec = candidates.iter().map(|c| c.dep.clone()).collect(); // The Pipfile.lock reference shape depends on the installing Pipenv // (`path` for 7–11, `file` from 2018 on), so the installed release is // probed (`pipenv --version`, up to 10 s) — but only when a pypi patch @@ -2100,47 +2383,48 @@ pub(crate) async fn run_redirect_selected( } else { None }; - // A malformed primary lock must not cause edits to stale npm siblings. - let rewrite_overrides: Vec<_> = overrides - .iter() - .filter(|o| !(binary_content.as_ref().is_some_and(Result::is_err) && o.ecosystem == "npm")) - .cloned() - .collect(); - // Pure CPU over every lock text (the independent rewriter groups run - // concurrently inside), so it runs on the blocking pool rather than on a - // runtime worker; `files` comes back for the confirmation probe below. let bun_lockb_present = common.cwd.join("bun.lockb").exists(); - let withheld_from_vlt = vlt_preflight.withheld_from_vlt.clone(); + let inputs = RewriteInputs { + python_metadata: &python_metadata, + pipenv_major, + bun_lockb_present, + withheld_from_vlt: &vlt_preflight.withheld_from_vlt, + binary_content: binary_content.as_ref(), + }; // `mut`: the pnpm trustLockfile auto-config below may fold a // pnpm-workspace.yaml write (plus its ledger edit) into the rewrite set so // it rides the same atomic-write / ledger-first machinery as the locks. - let (files, mut rewrite) = tokio::task::spawn_blocking(move || { - let rewrite = rewrite_registry_redirect_withholding_vlt( - &files, - &rewrite_overrides, - &python_metadata, - pipenv_major, - bun_lockb_present, - &withheld_from_vlt, - ); - (files, rewrite) - }) - .await - .unwrap_or_else(|e| match e.try_into_panic() { - Ok(payload) => std::panic::resume_unwind(payload), - Err(e) => panic!("hosted rewrite task failed: {e}"), - }); - if let Some(content) = binary_content { - rewrite - .warnings - .retain(|w| w.code != "redirect_npm_no_lockfile"); - match content { - Ok(bytes) => socket_patch_core::patch::redirect::rewrite_bun_binary( - &bytes, - &overrides, - &mut rewrite, - ), - Err(warning) => rewrite.warnings.push(warning), + let (mut files, mut rewrite) = rewrite_candidates(files, &overrides, &inputs).await; + + // The rollout gate (§5.2): every write-free check has run — grants, + // purl/url, vlt preflight, takeover refusals, wheel metadata, and the + // rewrite above, whose confirmation probe proves a NEW row would be + // pinned. Only then is the budget spent; deferred rows leave the + // rewrite set, which is planned again without them. + if let Some(gate) = rollout.as_mut() { + let eligible: std::collections::HashSet<(String, String)> = candidates + .iter() + .zip(confirmed_mask( + &candidates, + &files, + &rewrite, + binary_bun, + &vlt_preflight.withheld_from_vlt, + )) + .filter(|(_, confirmed)| *confirmed) + .map(|(c, _)| (c.sel_purl.clone(), c.dep.patch_uuid.clone())) + .collect(); + let unknown = gate.stage.reference_failed.is_some(); + gate.stage.plan(&gate.rows, |row| { + unknown || eligible.contains(&(row.writer.purl.clone(), row.writer.uuid.clone())) + }); + let deferred = gate.stage.deferred_keys(); + skipped.extend(gate.stage.deferred_skips()); + let before = candidates.len(); + candidates.retain(|c| !deferred.contains(&(c.sel_purl.clone(), c.dep.patch_uuid.clone()))); + if candidates.len() != before { + overrides = candidates.iter().map(|c| c.dep.clone()).collect(); + (files, rewrite) = rewrite_candidates(files, &overrides, &inputs).await; } } @@ -2544,159 +2828,16 @@ pub(crate) async fn run_redirect_selected( .cloned() .collect(); - // A dep counts as REDIRECTED only if its hosted-artifact URL (or its - // per-dependency registry index URL) actually landed in the project's - // files — either written by this run or already present from an earlier - // one. A granted reference whose rewriter found nothing to edit (e.g. no - // lockfile) must NOT be recorded or attested: nothing pins the patch. - // A `pdm.lock` that is NOT the PyPI install driver (a `uv.lock` or - // `poetry.lock` sits beside it) is never rewritten, yet can still carry a - // Socket artifact URL from an earlier run. That stale text pins nothing, - // so it must not feed the substring probe below. When pdm DOES drive, - // pypi confirmation keys off `confirmed_pdm_uuids`, so dropping the file - // is always safe. - let pdm_inactive = - files.contains_key("pdm.lock") && !socket_patch_core::patch::redirect::pdm_drives(&files); - // Likewise a `vlt-lock.json` the vlt rewrite was withheld from (its - // artifact failed the preflight beside another npm-family lock) may - // still hold an earlier run's pin: only the sibling lock this run - // rewrote can confirm that dep. - let final_texts: Vec<(&str, &String)> = files - .iter() - .filter(|(name, _)| !(pdm_inactive && name.as_str() == "pdm.lock")) - .map(|(name, content)| (name.as_str(), rewrite.files.get(name).unwrap_or(content))) - .chain( - rewrite - .files - .iter() - .filter(|(name, _)| !files.contains_key(*name)) - .map(|(name, content)| (name.as_str(), content)), - ) - .collect(); - // Every non-substring rule decides a candidate outright; the rest are - // confirmed by substring presence of their needles in the final texts. - // All needle groups are answered in ONE multi-needle pass per text - // (`groups_present`), which is the per-candidate `any()` exactly — - // presence does not depend on search order, and `confirmed` keeps - // candidate order. `candidate_present_oracle` is the reference form. - let steps: Vec = candidates - .iter() - .map(|c| { - let purl = c.purl.as_str(); - let uuid = c.dep.patch_uuid.as_str(); - // vlt decides before the binary-bun rule, so `bun.lockb` beside - // a vlt-driven `vlt-lock.json` never confirms an npm purl. - if rewrite.refused_vlt_uuids.contains(uuid) { - return ProbeStep::Decided(false); - } - if rewrite.vlt_drives && purl.starts_with("pkg:npm/") { - return ProbeStep::Decided(rewrite.confirmed_vlt_uuids.contains(uuid)); - } - if binary_bun && purl.starts_with("pkg:npm/") { - return ProbeStep::Decided(rewrite.confirmed_bun_binary_uuids.contains(uuid)); - } - if rewrite.refused_pipenv_uuids.contains(uuid) { - return ProbeStep::Decided(false); - } - // pdm is transactional like cargo: a refused uuid is never - // confirmed, and when `pdm.lock` is the PyPI install driver - // (no `uv.lock` / `poetry.lock`) a pypi dep is confirmed ONLY - // by the pdm rewriter's own report — the URL landing in a - // sibling `requirements.txt` the project does not install from - // pins nothing. When uv/poetry drive, their own lock proof - // below still confirms them. This check precedes the hatch - // gate: a PDM project may declare `hatchling` as its build - // backend, which registers every pypi uuid as hatch-owned while - // the lock's presence keeps hatch from confirming any of them. - if rewrite.refused_pdm_uuids.contains(uuid) { - return ProbeStep::Decided(false); - } - if purl.starts_with("pkg:pypi/") - && socket_patch_core::patch::redirect::pdm_drives(&files) - { - return ProbeStep::Decided(rewrite.confirmed_pdm_uuids.contains(uuid)); - } - if rewrite.python_lock_uuids.contains(uuid) { - return ProbeStep::Decided( - rewrite.confirmed_python_lock_uuids.contains(uuid) - && !rewrite.refused_python_lock_uuids.contains(uuid), - ); - } - if rewrite.hatch_uuids.contains(uuid) { - return ProbeStep::Decided(rewrite.confirmed_hatch_uuids.contains(uuid)); - } - // A Pipfile.lock rewrite confirms its own uuids (the sibling - // requirements.txt rewriter may have had nothing to do). - if purl.starts_with("pkg:pypi/") { - return ProbeStep::Decided( - rewrite.confirmed_pipenv_uuids.contains(uuid) - || rewrite.confirmed_requirements_uuids.contains(uuid), - ); - } - if rewrite.refused_pnpm_uuids.contains(uuid) { - return ProbeStep::Decided(false); - } - // Cargo is transactional: the rewriter reports exactly which - // patch uuids FULLY landed (manifest pin + lock + registry - // block). Substring presence must never confirm a cargo dep — - // the `[registries.…]` config block contains the index URL while - // pinning nothing, so a config-block-only rewrite would be - // attested with zero enforcement in any build. - if purl.starts_with("pkg:cargo/") { - return ProbeStep::Decided(rewrite.confirmed_cargo_uuids.contains(uuid)); - } - // Golang likewise: the goproxy `indexUrl` is the bare - // patch-server origin (present in any other hosted lock), and - // the socket module's go.sum lines outlive a removed replace. - if purl.starts_with("pkg:golang/") { - return ProbeStep::Decided(rewrite.confirmed_golang_uuids.contains(uuid)); - } - let needles = candidate_presence_needles(&c.dep); - if vlt_preflight.withheld_from_vlt.contains(uuid) { - ProbeStep::NeedlesOutsideVlt(needles) - } else { - ProbeStep::Needles(needles) - } - }) - .collect(); - let groups = |outside_vlt: bool| -> Vec<&[String]> { - steps - .iter() - .filter_map(|step| match step { - ProbeStep::Needles(needles) if !outside_vlt => Some(needles.as_slice()), - ProbeStep::NeedlesOutsideVlt(needles) if outside_vlt => Some(needles.as_slice()), - _ => None, - }) - .collect() - }; - let all_texts: Vec<&String> = final_texts.iter().map(|(_, text)| *text).collect(); - let mut present = - socket_patch_core::patch::redirect::presence::groups_present(&all_texts, &groups(false)) - .into_iter(); - let outside_vlt_groups = groups(true); - let mut present_outside_vlt = if outside_vlt_groups.is_empty() { - Vec::new() - } else { - let texts: Vec<&String> = final_texts - .iter() - .filter(|(name, _)| *name != socket_patch_core::constants::npm_family::VLT_LOCK) - .map(|(_, text)| *text) - .collect(); - socket_patch_core::patch::redirect::presence::groups_present(&texts, &outside_vlt_groups) - } - .into_iter(); let confirmed: Vec<(String, String)> = candidates .iter() - .zip(&steps) - .filter(|(_, step)| match step { - ProbeStep::Decided(keep) => *keep, - ProbeStep::Needles(_) => present - .next() - .expect("one presence answer per needle group"), - ProbeStep::NeedlesOutsideVlt(_) => present_outside_vlt - .next() - .expect("one presence answer per needle group"), - }) + .zip(confirmed_mask( + &candidates, + &files, + &rewrite, + binary_bun, + &vlt_preflight.withheld_from_vlt, + )) + .filter(|(_, confirmed)| *confirmed) .map(|(c, _)| (c.purl.clone(), c.dep.patch_uuid.clone())) .collect(); // Dry-run mode-takeover previews were withheld from the rewriters (their @@ -3020,6 +3161,9 @@ pub(crate) async fn run_redirect_selected( common.dry_run, ); let mut result = build_redirect_json_envelope(scan_result.take(), redirect); + if let Some(gate) = &rollout { + super::finish_rollout_json(gate.stage, &mut result); + } if let Some(statements) = vex_statements { result["vex"] = serde_json::json!({ "path": vex.vex.as_ref().expect("vex_statements is Some only when --vex was given").display().to_string(), @@ -3082,8 +3226,10 @@ pub(crate) async fn run_redirect_selected( .collect(); // Human output prints the bare strings — `Value`'s `Display` // would JSON-quote them. + // Deferred rows are summed up by the rollout lines instead. let skipped_pairs: Vec<(String, String)> = skipped .iter() + .filter(|s| s["reason"] != super::rollout::ROLLOUT_DEFERRED) .map(|s| { ( s["purl"].as_str().unwrap_or_default().to_string(), @@ -3146,12 +3292,26 @@ pub(crate) async fn run_redirect_selected( crate::commands::vex::format_vex_dry_run_skip("redirected") ); } - if !common.dry_run { - for line in - format_next_steps(&human_files, !takeover_migrated.is_empty()) - { - println!("{line}"); + let (rollout_line, deferred_steps) = match &rollout { + Some(gate) => { + for (code, detail) in gate.stage.warnings() { + eprintln!("{}", format_warning(code, &detail, width)); + } + gate.stage.human(common.dry_run) } + None => (None, Vec::new()), + }; + if let Some(line) = rollout_line { + println!("{line}"); + } + let mut next_steps = if common.dry_run { + Vec::new() + } else { + format_next_steps(&human_files, !takeover_migrated.is_empty()) + }; + next_steps.extend(deferred_steps); + for line in next_steps { + println!("{line}"); } } // Errors print even under --silent ("errors only", never @@ -3487,6 +3647,7 @@ fn format_next_steps(files: &[String], vendored_removed: bool) -> Vec { /// future embeds the whole hosted engine, and callers outside scan (`get /// --mode hosted`) must not materialize it in their own poll frame (Windows /// 1 MiB main-thread stack; same rationale as scan's `boxed_*` family). +#[allow(clippy::too_many_arguments)] pub(crate) fn boxed_run_redirect_selected<'a>( common: &'a crate::args::GlobalArgs, vex: &'a crate::commands::vex::VexEmbedArgs, @@ -3495,6 +3656,7 @@ pub(crate) fn boxed_run_redirect_selected<'a>( selected: &'a [(String, String)], scan_result: Option, npm_prior: Option<&'a crate::ecosystem_dispatch::NpmCrawlSnapshot>, + rollout: Option>, ) -> std::pin::Pin + 'a>> { Box::pin(run_redirect_selected( common, @@ -3504,6 +3666,7 @@ pub(crate) fn boxed_run_redirect_selected<'a>( selected, scan_result, npm_prior, + rollout, )) } diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index adaa869c..32d21355 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -33,12 +33,14 @@ use crate::commands::vex::{generate_vex_from_manifest_path, VexEmbedArgs}; use crate::ecosystem_dispatch::{crawl_ecosystems, crawl_ecosystems_with_npm}; use crate::ui::{self, plural, print_json, StatusLine}; -use super::get::{download_and_apply_patches_with, select_patches, DownloadParams, DownloadRun}; +use super::get::{download_and_apply_patches_with, DownloadParams, DownloadRun}; mod discovery; mod gc; pub(crate) mod hosted; pub(crate) mod render; +pub(crate) mod rollout; +pub mod rollout_args; pub(crate) mod vendor_flow; use self::discovery::{ @@ -372,6 +374,9 @@ pub struct ScanArgs { /// VEX makes the command exit non-zero. #[command(flatten)] pub vex: VexEmbedArgs, + + #[command(flatten)] + pub rollout: rollout_args::RolloutArgs, } /// Whether a `--package` spec names the package at `purl`: a purl spec @@ -535,31 +540,30 @@ async fn embed_vex_human( } } -/// The per-package discovery + selection step shared by the apply, vendor, -/// and redirect flows: search each patched package's full patch list, then -/// resolve the top-ranked accessible patch per PURL. Per-package search -/// errors are skipped, but when EVERY query errors the empty set would be -/// indistinguishable from a genuine "no patches" result, so that surfaces -/// as `Err(1)` with the failure on stderr. Selects with [`selection_args`]: -/// scan never prompts, so every run auto-selects the top-ranked patch (see -/// `api::ranking`) rather than erroring with `selection_required`. `Err` -/// carries the exit code AND the message, since JSON callers must fold it -/// into their single envelope (CLI_CONTRACT.md). `show_progress` / `warn` -/// are the human-only knobs of [`fetch_patch_details`] (JSON callers pass -/// `false, false`). `json_warnings` is the JSON callers' envelope: a -/// partial failure adds one [`PATCH_DETAILS_FAILED`] warning per failed -/// package to it. +/// The per-package discovery + selection step every mode shares: search +/// each patched package's full patch list, then take the top-ranked +/// accessible patch per purl (see `api::ranking`; scan never prompts). +/// Per-package search errors are skipped (their purls come back in +/// [`Discovered::failed`]), but when EVERY query errors the empty set would +/// be indistinguishable from a genuine "no patches" result, so that +/// surfaces as `Err(1)` with the failure on stderr (`detail_error_line` +/// picks the agent/vendored human arm's wording). `Err` carries the exit +/// code AND the message, since JSON callers must fold it into their single +/// envelope (CLI_CONTRACT.md). `show_progress` / `warn` are the human-only +/// knobs of [`fetch_patch_details`] (JSON callers pass `false, false`). +/// `json_warnings` is the JSON callers' envelope: a partial failure adds +/// one [`PATCH_DETAILS_FAILED`] warning per failed package to it. #[allow(clippy::too_many_arguments)] async fn discover_selected( api_client: &socket_patch_core::api::client::ApiClient, packages: &[BatchPackagePatches], can_access_paid_patches: bool, - common: &GlobalArgs, show_progress: bool, warn: bool, + detail_error_line: bool, telemetry: &mut PendingTelemetry, json_warnings: Option<&mut serde_json::Value>, -) -> Result, (i32, String)> { +) -> Result { let (all_search_results, failures) = fetch_patch_details(api_client, packages, show_progress, warn).await; // The scan event's send overlapped the detail fetches; every caller's @@ -569,11 +573,14 @@ async fn discover_selected( let error_count = failures.len(); if error_count > 0 && error_count == packages.len() { let err = failures - .into_iter() .last() - .map_or_else(|| "all patch-detail queries failed".to_string(), |(_, e)| e); + .map_or_else(|| "all patch-detail queries failed".to_string(), |(_, e)| e.clone()); let message = format!("all {error_count} patch-detail queries failed: {err}"); - eprintln!("Error: {message}"); + if detail_error_line { + eprintln!("{}", render::fetch_details_failed(&failures)); + } else { + eprintln!("Error: {message}"); + } return Err((1, message)); } // Some queries failed, some succeeded: a `--json` run has no stderr @@ -588,35 +595,128 @@ async fn discover_selected( ); } } - if all_search_results.is_empty() { - return Ok(Vec::new()); - } - if common.json { - // Pre-filter to accessible patches so `select_patches` takes the - // top-ranked one per PURL. - let accessible: Vec = all_search_results - .into_iter() - .filter(|p| can_access_paid_patches || p.tier == "free") - .collect(); - return select_patches(&accessible, true, &selection_args(common)) - .map_err(|code| (code, "patch selection failed".to_string())); + Ok(Discovered { + offers: rollout::Offers::from_results(&all_search_results, can_access_paid_patches), + fetched: all_search_results.len(), + failed: failures, + }) +} + +/// [`discover_selected`]'s result: the offers, how many records came back +/// (before the tier filter), and each failed detail query as `(purl, +/// error)` (a failure for a package with no recorded patch makes a capped +/// run's data incomplete). +struct Discovered { + offers: rollout::Offers, + fetched: usize, + failed: Vec<(String, String)>, +} + +/// The `updates[]` JSON array. +fn updates_json(updates: &[discovery::UpdateInfo]) -> Vec { + updates + .iter() + .map(|u| { + serde_json::json!({ + "purl": u.purl, + "oldUuid": u.old_uuid, + "newUuid": u.new_uuid, + }) + }) + .collect() +} + +/// Classify the discovered offers against the recorded view (§5.1), note +/// whether a capped run's data is incomplete, and (JSON) replace the +/// batch-derived `updates[]` with the by-package UPGRADE rows. +fn classified_rows( + stage: &mut rollout::Stage, + discovered: &Discovered, + recorded: Option<&PatchManifest>, + batch_failed: bool, + packages: &[BatchPackagePatches], + result: Option<&mut serde_json::Value>, +) -> Vec { + let failed: Vec = discovered.failed.iter().map(|(purl, _)| purl.clone()).collect(); + stage.incomplete = rollout::lookup_incomplete(recorded, &failed, batch_failed); + let rows = rollout::classify(&discovered.offers, recorded, &stage.project); + if let Some(result) = result { + let updates = offer_updates(&rows, discovered, recorded, packages); + result["updates"] = serde_json::Value::Array(updates_json(&updates)); } - select_patches( - &all_search_results, - can_access_paid_patches, - &selection_args(common), + rows +} + +/// `updates[]` from the by-package records (see [`rollout::merge_updates`]). +fn offer_updates( + rows: &[rollout::Row], + discovered: &Discovered, + recorded: Option<&PatchManifest>, + packages: &[BatchPackagePatches], +) -> Vec { + let purls: Vec = packages.iter().map(|p| p.purl.clone()).collect(); + rollout::merge_updates( + rows, + &discovered.offers, + &purls, + detect_updates(recorded, packages), ) - .map_err(|code| (code, "patch selection failed".to_string())) } -/// `common` for `select_patches`: scan never prompts, so it always takes -/// the top-ranked patch, and with `json` off it never gets -/// `selection_required` (scan has no "re-run with the chosen UUID" path). -fn selection_args(common: &GlobalArgs) -> GlobalArgs { - GlobalArgs { - json: false, - yes: true, - ..common.clone() +/// The offers the writers would receive, one per row. +fn writers_of(rows: &[rollout::Row]) -> Vec { + rows.iter().map(|r| r.writer.clone()).collect() +} + +/// Plan the rows whose writer survived the mode's own partition (`kept`; +/// the rest cannot land and hold no slot), then return `kept` without the +/// deferred rows. +fn plan_kept_rows( + stage: &mut rollout::Stage, + rows: Vec, + kept: Vec, +) -> Vec { + let kept_keys: HashSet<(&str, &str)> = kept + .iter() + .map(|p| (p.purl.as_str(), p.uuid.as_str())) + .collect(); + let kept_rows: Vec = rows + .into_iter() + .filter(|r| kept_keys.contains(&(r.writer.purl.as_str(), r.writer.uuid.as_str()))) + .collect(); + stage.plan(&kept_rows, |_| true); + let deferred = stage.deferred_keys(); + kept.into_iter() + .filter(|p| !deferred.contains(&(p.purl.clone(), p.uuid.clone()))) + .collect() +} + +/// Fold the stage's `rollout` block and warnings into a JSON result. +pub(super) fn finish_rollout_json(stage: &rollout::Stage, result: &mut serde_json::Value) { + result["rollout"] = stage.json(); + for (code, detail) in stage.warnings() { + push_scan_json_warning(result, code, &detail); + } +} + +/// The human `Rollout:` line and the Next-steps lines about deferred +/// patches, for the agent and vendored summaries. +fn print_rollout_human(stage: &rollout::Stage, dry_run: bool, silent: bool) { + if silent { + return; + } + for (code, detail) in stage.warnings() { + eprintln!("Warning ({code}): {detail}"); + } + let (line, next) = stage.human(dry_run); + if let Some(line) = line { + println!("\n{line}"); + } + if !next.is_empty() { + println!("Next steps:"); + for step in next { + println!(" {step}"); + } } } @@ -1306,6 +1406,17 @@ async fn run_project_dirs(args: ScanArgs, telemetry: &mut PendingTelemetry) -> i ); return 2; } + // One budget per invocation (§5.2): the directories spend it in sorted + // order, and a package admitted in one is admitted free in the next. + let configured = match args.rollout.resolve_from_env(None) { + Ok(max) => max, + Err(message) => { + eprintln!("Error: {message}"); + return 2; + } + }; + let root = std::fs::canonicalize(&args.common.cwd).unwrap_or_else(|_| args.common.cwd.clone()); + let carry = rollout_args::RolloutCarry::new(configured, root); let mut code = 0; for dir in &dirs { if dirs.len() > 1 && !args.common.silent { @@ -1315,6 +1426,7 @@ async fn run_project_dirs(args: ScanArgs, telemetry: &mut PendingTelemetry) -> i let mut child = args.clone(); child.paths.clear(); child.common.cwd = dir.clone(); + child.rollout.carry = Some(carry.clone()); code = code.max(Box::pin(run_scan(child, telemetry)).await); } code @@ -1350,6 +1462,25 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { } }; + // The per-run cap on NEW patches (`--max-new-patches` > env > the + // file's `maxNewPatches`, which work item A wires in). A malformed env + // value is a usage error. + let configured_cap = match args.rollout.carry.as_ref() { + Some(carry) => carry.lock().configured, + None => match args.rollout.resolve_from_env(None) { + Ok(max) => max, + Err(message) => { + eprintln!("Error: {message}"); + return 2; + } + }, + }; + let mut stage = rollout::Stage::new( + configured_cap, + args.rollout.carry.clone(), + &args.common.cwd, + ); + // Strict airgap (CLI_CONTRACT.md `--offline`): scan's patch discovery // is remote data, so refuse before the crawl and before the API client // is built (org auto-resolve is itself a network call). @@ -1597,6 +1728,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { "packages": [], "updates": [], "paths": path_scope.raw(), + "rollout": stage.json(), }); // Layout refusals: additive top-level `warnings` (omitted when // empty) so a consumer can tell an unscannable project from an @@ -1909,7 +2041,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { vendor_state.as_ref().ok(), &hosted_pins, ); - let updates = detect_updates(update_manifest.as_deref(), &all_packages_with_patches); + let mut updates = detect_updates(update_manifest.as_deref(), &all_packages_with_patches); // The hosted-wiring probes below take `all_purls` (POST-filter: only // packages this run covered), unlike the PRE-filter `scanned_purls` @@ -1927,11 +2059,8 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { "canAccessPaidPatches": can_access_paid_patches, "packages": all_packages_with_patches, "paths": path_scope.raw(), - "updates": updates.iter().map(|u| serde_json::json!({ - "purl": u.purl, - "oldUuid": u.old_uuid, - "newUuid": u.new_uuid, - })).collect::>(), + "updates": updates_json(&updates), + "rollout": stage.json(), }); // Layout refusals ride the non-empty envelope too (additive, // omitted when empty). @@ -1971,6 +2100,9 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { Some(result), telemetry, npm_crawl.as_ref(), + update_manifest.as_deref(), + batch_error_count > 0, + &mut stage, ) .await; } @@ -1996,11 +2128,11 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { // --- Apply path (if requested) ----------------------------------- if apply { - let selected = match discover_selected( + let discovered = match discover_selected( &api_client, &all_packages_with_patches, can_access_paid_patches, - &args.common, + false, false, false, telemetry, @@ -2008,21 +2140,31 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { ) .await { - Ok(s) => s, + Ok(d) => d, Err((code, message)) => { emit_discovery_error_json(&mut result, &message); return code; } }; + let rows = classified_rows( + &mut stage, + &discovered, + update_manifest.as_deref(), + batch_error_count > 0, + &all_packages_with_patches, + Some(&mut result), + ); // Vendor-owned and lockfile-only purls leave the selection as - // skip records BEFORE download (see `partition_agent_selection`). + // skip records BEFORE download (see `partition_agent_selection`); + // they cannot land, so they hold no rollout slot either. let AgentSelection { - kept: selected, + kept, skip_records: vendored_records, vendored_purls: vendored_skip_purls, .. - } = partition_agent_selection(selected, &vendored_purls, &lockfile_only); + } = partition_agent_selection(writers_of(&rows), &vendored_purls, &lockfile_only); + let selected = plan_kept_rows(&mut stage, rows, kept); if dry { // Synthesize the per-patch outcome without touching disk. @@ -2120,6 +2262,9 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { use_public_proxy, &all_packages_with_patches, can_access_paid_patches, + update_manifest.as_deref(), + batch_error_count > 0, + &mut stage, &mut result, &manifest_path, &socket_dir, @@ -2152,6 +2297,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { .await; } + finish_rollout_json(&stage, &mut result); let final_code = embed_vex_into_json( &args.common, &args.vex, @@ -2199,6 +2345,72 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { return finish_human(0).await; } + // Count downloadable patches: a free-tier org whose every offer is + // paid-tier has nothing any mode could select, so every human arm stops + // below the table with the same paid-subscription line. + let downloadable_count = if can_access_paid_patches { + all_packages_with_patches.len() + } else { + all_packages_with_patches + .iter() + .filter(|pkg| pkg.patches.iter().any(|p| p.tier == "free")) + .count() + }; + + // The by-package records every arm selects from, fetched before the + // table so its `[UPDATE]` markers are the same UPGRADE rows the + // selection acts on (§5.1). Discovery said these packages HAVE + // patches, so an empty merged set is a fetch failure. + // A failed discovery still prints the table first; its exit code is + // returned below it. + let mut discovery_failure: Option = None; + let rows: Vec = if downloadable_count == 0 { + Vec::new() + } else { + match discover_selected( + &api_client, + &all_packages_with_patches, + can_access_paid_patches, + human, + !silent, + !hosted, + telemetry, + None, + ) + .await + { + // The agent / vendored / report-only arms need records to show: + // an empty merged set is a fetch failure there. + Ok(discovered) if !hosted && discovered.fetched == 0 => { + eprintln!("{}", render::fetch_details_failed(&discovered.failed)); + discovery_failure = Some(1); + Vec::new() + } + Ok(discovered) => { + let rows = classified_rows( + &mut stage, + &discovered, + update_manifest.as_deref(), + batch_error_count > 0, + &all_packages_with_patches, + None, + ); + updates = offer_updates( + &rows, + &discovered, + update_manifest.as_deref(), + &all_packages_with_patches, + ); + rows + } + // `discover_selected` already printed the failure to stderr. + Err((code, _)) => { + discovery_failure = Some(code); + Vec::new() + } + } + }; + // Presentational only, so `--silent` skips it wholesale. if !silent { let mut updates_available = 0usize; @@ -2322,51 +2534,24 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { } } - // Count downloadable patches: a free-tier org whose every offer is - // paid-tier has nothing any mode could select, so every human arm stops - // here with the same paid-subscription line. - let downloadable_count = if can_access_paid_patches { - all_packages_with_patches.len() - } else { - all_packages_with_patches - .iter() - .filter(|pkg| pkg.patches.iter().any(|p| p.tier == "free")) - .count() - }; - if downloadable_count == 0 { if !silent { println!("\nNo downloadable patches (paid subscription required)."); } return finish_human(0).await; } + if let Some(code) = discovery_failure { + return code; + } // Hosted mode is a self-contained flow: it reuses the discovery, table // and update detection above, then hands the selection to the redirect // engine (the same entry as `get --mode hosted`) — it must NOT fall // through to the apply/vendor branches. if hosted { - let selected = match discover_selected( - &api_client, - &all_packages_with_patches, - can_access_paid_patches, - &args.common, - human, - !silent, - telemetry, - None, - ) - .await - { - Ok(s) => s, - // `discover_selected` already printed the failure to stderr. - Err((code, _)) => { - return code; - } - }; - let pairs: Vec<(String, String)> = selected + let pairs: Vec<(String, String)> = rows .iter() - .map(|s| (s.purl.clone(), s.uuid.clone())) + .map(|r| (r.writer.purl.clone(), r.writer.uuid.clone())) .collect(); return boxed_run_redirect_selected( &args.common, @@ -2376,33 +2561,18 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { &pairs, None, npm_crawl.as_ref(), + Some(rollout::Gate { + stage: &mut stage, + rows, + }), ) .await; } - // Fetch the full per-package patch lists — the same loop the JSON arms - // run through `discover_selected`, here with progress + per-package - // warnings. Discovery said these packages HAVE patches, so an empty - // merged set is a fetch failure. - let (all_search_results, detail_failures) = - fetch_patch_details(&api_client, &all_packages_with_patches, human, !silent).await; - if all_search_results.is_empty() { - eprintln!("{}", render::fetch_details_failed(&detail_failures)); - return 1; - } - // A scan left without a mode (`--prune` or global; see // `resolve_mode_flags`) only reports, plus the `--prune` GC. let report_only = args.mode.is_none(); - - // Scan always takes the top-ranked patch (see `selection_args`). - let mut select_common = selection_args(&args.common); - select_common.silent |= report_only; - let selected: Vec = - match select_patches(&all_search_results, can_access_paid_patches, &select_common) { - Ok(s) => s, - Err(code) => return code, - }; + let selected: Vec = writers_of(&rows); // The skip / already-recorded lines below open their own paragraph // under the table's Summary: one blank line before the first of them. @@ -2427,6 +2597,23 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { split.kept }; + // The rollout plan (§5.2): deferred NEW rows leave the selection here. + // Vendored eligibility is the wet run's Bun / vlt preflight; the agent + // partition above already removed what cannot land in place. + let selected = if report_only { + selected + } else if vendor { + let refused = vendor_flow::preflight_refused_purls(&args.common.cwd, &selected).await; + stage.plan(&rows, |r| !refused.contains(&r.writer.purl)); + let deferred = stage.deferred_keys(); + selected + .into_iter() + .filter(|p| !deferred.contains(&(p.purl.clone(), p.uuid.clone()))) + .collect() + } else { + plan_kept_rows(&mut stage, rows, selected) + }; + // Drop selections the manifest already records at the same uuid. // Agent mode only: vendored mode never reads the manifest. let recorded = |p: &PatchSearchResult| { @@ -2453,12 +2640,15 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { if selected.is_empty() { if !silent { open_paragraph(&mut skip_paragraph); - if already_recorded.is_empty() { + if !stage.deferred_keys().is_empty() { + println!("No new patches admitted this run."); + } else if already_recorded.is_empty() { println!("No patches selected."); } else { println!("{}", render::ALL_ALREADY_RECORDED); } } + print_rollout_human(&stage, args.common.dry_run, silent); return finish_human(0).await; } @@ -2537,6 +2727,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { print_dry_run_refusals(preview); } } + print_rollout_human(&stage, true, silent); return finish_human(0).await; } @@ -2636,6 +2827,8 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { } } + print_rollout_human(&stage, false, silent); + // Post-apply GC: only with `--prune` or `--sync`; otherwise an agent // apply leaves every other manifest entry alone (`socket-patch repair` // cleans up explicitly). Vendor mode runs its own GC in `vendor_flow`. @@ -3040,20 +3233,6 @@ mod tests { ); } - #[test] - fn selection_args_never_prompts() { - for common in [ - GlobalArgs::default(), - GlobalArgs { - json: true, - ..GlobalArgs::default() - }, - ] { - let picked = selection_args(&common); - assert!(!picked.json && picked.yes, "scan always takes the top patch"); - } - } - #[test] fn takeover_detail_names_package_and_remediation() { let purls = vec!["pkg:npm/minimist@1.2.2".to_string()]; diff --git a/crates/socket-patch-cli/src/commands/scan/rollout.rs b/crates/socket-patch-cli/src/commands/scan/rollout.rs new file mode 100644 index 00000000..ea623748 --- /dev/null +++ b/crates/socket-patch-cli/src/commands/scan/rollout.rs @@ -0,0 +1,876 @@ +//! Scan's step-7 stage (`docs/design/staged-rollout.md` §5, §9.2): +//! classify the selected offers against the recorded state, let the +//! mode's planning pass decide eligibility, spend the per-run budget on +//! NEW packages most critical first, and report what was deferred. + +use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::path::Path; + +use socket_patch_core::api::ranking::{ + cmp_search_results, max_severity_order, search_result_supersedes, +}; +use socket_patch_core::api::types::PatchSearchResult; +use socket_patch_core::crawlers::Ecosystem; +use socket_patch_core::manifest::schema::PatchManifest; +use socket_patch_core::rollout::{ + canonical_base_purl, plan_rollout, severity_label, Candidate, MaxNew, MaxNewSource, Recorded, + RolloutPlan, +}; +use socket_patch_core::utils::purl::normalize_purl; + +use super::discovery::UpdateInfo; +use super::rollout_args::RolloutCarry; + +/// Warning: a lookup failed for a package that could have been NEW, so a +/// capped run admitted no NEW patch. +pub(crate) const ROLLOUT_INCOMPLETE_LOOKUP: &str = "rollout_incomplete_lookup"; +/// Warning: the reference lookup failed, but only for rows that were +/// deferred anyway, so the run went on. +pub(crate) const ROLLOUT_REFERENCE_FAILED: &str = "rollout_reference_failed"; +/// `skipped[].reason` of a deferred row. +pub(crate) const ROLLOUT_DEFERRED: &str = "rollout_deferred"; + +/// The step 5 → 7 seam: every offer per purl, and the winner per purl. +/// +/// Work item A owns the shared `policy::Offers`; until it lands the +/// severity floor does not exist, so `selected` is the top of `unfiltered`. +#[derive(Debug, Clone, Default)] +pub(crate) struct Offers { + /// purl → every accessible offer, best first. + pub(crate) unfiltered: BTreeMap>, + /// purl → the offer per-package ranking selects. + pub(crate) selected: BTreeMap, +} + +impl Offers { + /// Group the by-package records per purl, dropping paid patches the + /// org cannot download, and take the top-ranked one per purl. + pub(crate) fn from_results(results: &[PatchSearchResult], can_access_paid: bool) -> Self { + let mut unfiltered: BTreeMap> = BTreeMap::new(); + for p in results { + if can_access_paid || p.tier == "free" { + unfiltered + .entry(p.purl.clone()) + .or_default() + .push(p.clone()); + } + } + for group in unfiltered.values_mut() { + group.sort_by(cmp_search_results); + group.dedup_by(|a, b| a.uuid == b.uuid); + } + let selected = unfiltered + .iter() + .filter_map(|(purl, group)| group.first().map(|p| (purl.clone(), p.clone()))) + .collect(); + Offers { + unfiltered, + selected, + } + } +} + +/// One classified row plus the offer its writer receives: the selection, +/// or the recorded patch when the selection does not supersede it. +#[derive(Debug, Clone)] +pub(crate) struct Row { + pub(crate) candidate: Candidate, + pub(crate) writer: PatchSearchResult, +} + +/// The uuids the recorded view holds for `purl`: exact key, else the same +/// purl up to percent-encoding, else any qualifier twin. +pub(crate) fn recorded_uuids(recorded: &PatchManifest, purl: &str) -> Vec { + if let Some(r) = recorded.patches.get(purl) { + return vec![r.uuid.clone()]; + } + let want = normalize_purl(purl); + let mut same: Vec = recorded + .patches + .iter() + .filter(|(k, _)| normalize_purl(k) == want) + .map(|(_, r)| r.uuid.clone()) + .collect(); + if same.is_empty() { + let base = canonical_base_purl(purl); + same = recorded + .patches + .iter() + .filter(|(k, _)| canonical_base_purl(k) == base) + .map(|(_, r)| r.uuid.clone()) + .collect(); + } + same.sort(); + same.dedup(); + same +} + +/// Classify every selected purl of one project root (§5.1). `recorded` is +/// the merged view (manifest > hosted pins > vendor ledger). +pub(crate) fn classify( + offers: &Offers, + recorded: Option<&PatchManifest>, + project: &str, +) -> Vec { + offers + .selected + .iter() + .map(|(purl, selected)| { + let uuids = recorded + .map(|m| recorded_uuids(m, purl)) + .unwrap_or_default(); + let offered = offers + .unfiltered + .get(purl) + .map(Vec::as_slice) + .unwrap_or(&[]); + let (class, writer) = if uuids.is_empty() { + (Recorded::None, selected.clone()) + } else if uuids.contains(&selected.uuid) { + (Recorded::Same, selected.clone()) + } else { + let old = uuids[0].clone(); + match offered.iter().find(|p| p.uuid == old) { + Some(prior) if !search_result_supersedes(selected, prior) => { + (Recorded::Kept { uuid: old }, prior.clone()) + } + _ => (Recorded::Superseded { old_uuid: old }, selected.clone()), + } + }; + Row { + candidate: Candidate { + project: project.to_string(), + purl: purl.clone(), + base_purl: canonical_base_purl(purl), + uuid: selected.uuid.clone(), + ecosystem: Ecosystem::from_purl(purl).map_or("", |e| e.cli_name()), + severity_order: max_severity_order( + selected + .vulnerabilities + .values() + .map(|v| v.severity.as_str()), + ), + advisory_count: selected.vulnerabilities.len(), + recorded: class, + eligible: true, + in_flight: false, + }, + writer, + } + }) + .collect() +} + +/// `updates[]`: the UPGRADE rows, reported under the batch package's purl +/// spelling when one names the same base purl (one entry per package). +pub(super) fn upgrades(rows: &[Row], package_purls: &[String]) -> Vec { + let by_base: BTreeMap = package_purls + .iter() + .map(|p| (canonical_base_purl(p), p)) + .collect(); + let mut seen: HashSet = HashSet::new(); + let mut out = Vec::new(); + for row in rows { + let Recorded::Superseded { old_uuid } = &row.candidate.recorded else { + continue; + }; + let purl = by_base + .get(&row.candidate.base_purl) + .map_or_else(|| row.candidate.purl.clone(), |p| (*p).clone()); + if seen.insert(purl.clone()) { + out.push(UpdateInfo { + purl, + old_uuid: old_uuid.clone(), + new_uuid: row.candidate.uuid.clone(), + }); + } + } + out.sort_by(|a, b| a.purl.cmp(&b.purl)); + out +} + +/// Whether a failed detail lookup hit a package that could have been NEW +/// (nothing recorded for it), or a whole batch failed (its packages are +/// unknown). +pub(crate) fn lookup_incomplete( + recorded: Option<&PatchManifest>, + failed_details: &[String], + batch_failed: bool, +) -> bool { + batch_failed + || failed_details + .iter() + .any(|purl| recorded.is_none_or(|m| recorded_uuids(m, purl).is_empty())) +} + +/// The rows the hosted engine plans (§9.0 step 7 inside the engine, after +/// its eligibility checks) and the stage that records the plan. +pub(crate) struct Gate<'a> { + pub(crate) stage: &'a mut Stage, + pub(crate) rows: Vec, +} + +impl Gate<'_> { + /// Whether `(purl, uuid)` is a NEW row. + pub(crate) fn is_new(&self, purl: &str, uuid: &str) -> bool { + self.rows.iter().any(|r| { + r.candidate.recorded.is_new() && r.writer.purl == purl && r.writer.uuid == uuid + }) + } +} + +/// `updates[]` for a run that fetched by-package records: the UPGRADE rows, +/// plus the batch-derived entries for packages the by-package lookup +/// returned no offer for (nothing was selected there to disagree with). +pub(super) fn merge_updates( + rows: &[Row], + offers: &Offers, + package_purls: &[String], + batch: Vec, +) -> Vec { + let offered: BTreeSet = offers + .unfiltered + .keys() + .map(|p| canonical_base_purl(p)) + .collect(); + let mut out = upgrades(rows, package_purls); + out.extend( + batch + .into_iter() + .filter(|u| !offered.contains(&canonical_base_purl(&u.purl))), + ); + out.sort_by(|a, b| a.purl.cmp(&b.purl)); + out.dedup_by(|a, b| a.purl == b.purl); + out +} + +/// Repo-relative `/`-separated path of `dir` under `root`; `""` for the +/// root itself. +pub(crate) fn project_rel(root: &Path, dir: &Path) -> String { + let rel = dir.strip_prefix(root).unwrap_or(dir); + rel.components() + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .filter(|s| s != ".") + .collect::>() + .join("/") +} + +/// One directory's budget and the outcome of its plan. +#[derive(Debug, Clone)] +pub(crate) struct Stage { + /// The cap as configured (reported). + pub(crate) configured: MaxNew, + /// The budget this directory may spend (the carried remainder). + pub(crate) budget: MaxNew, + pub(crate) already_admitted: BTreeSet, + /// A lookup failed for a package that could have been NEW. + pub(crate) incomplete: bool, + pub(crate) project: String, + pub(crate) carry: Option, + /// Set by [`Self::plan`]. + pub(crate) plan: Option, + /// The reference lookup failed and only deferred rows were affected. + pub(crate) reference_failed: Option, +} + +impl Stage { + /// The stage for one scan of `cwd`: a fresh budget, or the invocation's + /// shared one. + pub(crate) fn new(configured: MaxNew, carry: Option, cwd: &Path) -> Self { + let (budget, already_admitted, project) = match &carry { + Some(c) => { + let c = c.lock(); + ( + MaxNew { + value: c.remaining, + source: c.configured.source, + }, + c.admitted.clone(), + project_rel( + &c.root, + &std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf()), + ), + ) + } + None => (configured, BTreeSet::new(), String::new()), + }; + Stage { + configured, + budget, + already_admitted, + incomplete: false, + project, + carry, + plan: None, + reference_failed: None, + } + } + + pub(crate) fn capped(&self) -> bool { + self.configured.value.is_some() + } + + /// Whether any NEW row can be admitted at all. + pub(crate) fn may_admit_new(&self) -> bool { + !(self.capped() && self.incomplete) && self.budget.value != Some(0) + } + + /// Plan `rows` with `eligible` deciding each NEW row, record the plan + /// and hand the remaining budget to the next directory. + pub(crate) fn plan(&mut self, rows: &[Row], eligible: impl Fn(&Row) -> bool) -> &RolloutPlan { + let candidates: Vec = rows + .iter() + .map(|row| Candidate { + eligible: !row.candidate.recorded.is_new() || eligible(row), + ..row.candidate.clone() + }) + .collect(); + let plan = plan_rollout( + candidates, + &self.budget, + self.incomplete, + &self.already_admitted, + ); + if let Some(carry) = &self.carry { + let mut c = carry.lock(); + c.remaining = plan.remaining; + c.admitted = plan.admitted_base_purls.clone(); + } + self.plan.insert(plan) + } + + /// `(purl, uuid)` of every deferred row. + pub(crate) fn deferred_keys(&self) -> HashSet<(String, String)> { + self.plan + .iter() + .flat_map(|p| &p.deferred) + .map(|(c, _)| (c.purl.clone(), c.uuid.clone())) + .collect() + } + + /// Run-level warnings the plan earned. + pub(crate) fn warnings(&self) -> Vec<(&'static str, String)> { + let mut out = Vec::new(); + if let Some(detail) = &self.reference_failed { + out.push(( + ROLLOUT_REFERENCE_FAILED, + format!( + "the hosted reference lookup failed ({detail}); only new patches were \ + affected and they were deferred to the next scan" + ), + )); + } + let deferred = self.plan.as_ref().map_or(0, |p| p.counts.deferred); + if self.capped() && self.incomplete && deferred > 0 { + out.push(( + ROLLOUT_INCOMPLETE_LOOKUP, + format!( + "a patch lookup failed for a package that could get its first patch, so \ + no new patches were added this run ({} deferred) and none can take the \ + missing package's place; re-run once the API answers", + crate::ui::plural(deferred as usize, "package", "packages") + ), + )); + } + out + } + + /// The top-level `rollout` block (§5.5). + pub(crate) fn json(&self) -> serde_json::Value { + rollout_json(&self.configured, self.plan.as_ref()) + } + + /// `redirect.skipped[]` entries mirroring the deferred rows. + pub(crate) fn deferred_skips(&self) -> Vec { + self.plan + .iter() + .flat_map(|p| &p.deferred) + .map(|(c, rank)| { + serde_json::json!({ + "purl": c.purl, + "uuid": c.uuid, + "reason": ROLLOUT_DEFERRED, + "detail": format!("rank {rank} in the rollout queue; a later scan adds it"), + }) + }) + .collect() + } + + /// The human `Rollout:` line (only when a cap is set) and the + /// Next-steps lines about deferred patches. + pub(crate) fn human(&self, dry_run: bool) -> (Option, Vec) { + let Some(plan) = self.plan.as_ref() else { + return (None, Vec::new()); + }; + human_lines(&self.configured, plan, dry_run) + } +} + +fn source_label(max: &MaxNew) -> &'static str { + match max.source { + MaxNewSource::Flag => "--max-new-patches", + MaxNewSource::Env => super::rollout_args::MAX_NEW_PATCHES_ENV, + MaxNewSource::File => "socket.yml", + MaxNewSource::Cap => "the server cap", + MaxNewSource::Default => "default", + } +} + +/// One deferred package, grouped over its rows. +struct DeferredGroup { + base_purl: String, + uuids: BTreeSet, + severity_order: u8, + advisory_count: usize, + projects: BTreeSet, + rank: u32, +} + +fn deferred_groups(plan: &RolloutPlan) -> Vec { + let mut groups: Vec = Vec::new(); + for (c, rank) in &plan.deferred { + match groups.iter_mut().find(|g| g.base_purl == c.base_purl) { + Some(g) => { + g.uuids.insert(c.uuid.clone()); + g.projects.insert(c.project.clone()); + g.severity_order = g.severity_order.min(c.severity_order); + g.advisory_count = g.advisory_count.max(c.advisory_count); + } + None => groups.push(DeferredGroup { + base_purl: c.base_purl.clone(), + uuids: BTreeSet::from([c.uuid.clone()]), + severity_order: c.severity_order, + advisory_count: c.advisory_count, + projects: BTreeSet::from([c.project.clone()]), + rank: *rank, + }), + } + } + groups.sort_by_key(|g| g.rank); + groups +} + +/// The `rollout` block. `plan: None` (a run that planned nothing) reports +/// zero counts. +pub(crate) fn rollout_json(configured: &MaxNew, plan: Option<&RolloutPlan>) -> serde_json::Value { + let counts = plan.map(|p| p.counts).unwrap_or_default(); + let deferred: Vec = plan + .map(deferred_groups) + .unwrap_or_default() + .into_iter() + .map(|g| { + serde_json::json!({ + "purl": g.base_purl, + "uuids": g.uuids, + "severity": severity_label(g.severity_order), + "advisoryCount": g.advisory_count, + "projects": g.projects, + "rank": g.rank, + }) + }) + .collect(); + serde_json::json!({ + "maxNewPatches": { + "value": configured.value, + "source": configured.source.as_str(), + }, + "counts": { + "new": counts.new, + "deferred": counts.deferred, + "upgrade": counts.upgrade, + "already": counts.already, + }, + "deferred": deferred, + }) +} + +/// `pkg:npm/@scope/x@1.0.0` → `@scope/x@1.0.0`. +fn short_name(base_purl: &str) -> &str { + base_purl + .strip_prefix("pkg:") + .and_then(|rest| rest.split_once('/')) + .map_or(base_purl, |(_, name)| name) +} + +pub(crate) fn human_lines( + configured: &MaxNew, + plan: &RolloutPlan, + dry_run: bool, +) -> (Option, Vec) { + let c = plan.counts; + let line = configured.value.map(|cap| { + let verb = if dry_run { + "would be applied" + } else { + "applied" + }; + format!( + "Rollout: {} of {} {verb} (maxNewPatches={cap} from {}); {}, {} already applied.", + c.new, + crate::ui::plural((c.new + c.deferred) as usize, "new patch", "new patches"), + source_label(configured), + crate::ui::plural(c.upgrade as usize, "upgrade", "upgrades"), + c.already, + ) + }); + let groups = deferred_groups(plan); + if groups.is_empty() { + return (line, Vec::new()); + } + let deferred = crate::ui::plural(groups.len(), "new patch", "new patches"); + let first = match configured.value { + Some(0) => format!( + "{deferred} deferred: maxNewPatches=0 adds no new patches; raise it (or pass \ + --max-new-patches) to add them." + ), + Some(cap) => format!( + "{deferred} deferred; commit these changes and run scan again to apply the next {}.", + (cap as usize).min(groups.len()) + ), + None => format!("{deferred} deferred; run scan again once the patch API answers."), + }; + let shown: Vec = groups + .iter() + .take(3) + .map(|g| { + format!( + "{} ({})", + short_name(&g.base_purl), + severity_label(g.severity_order) + ) + }) + .collect(); + let more = if groups.len() > 3 { ", …" } else { "" }; + ( + line, + vec![first, format!("Next up: {}{more}", shown.join(", "))], + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use socket_patch_core::api::types::VulnerabilityResponse; + use socket_patch_core::manifest::schema::PatchRecord; + use std::collections::HashMap; + + fn offer(purl: &str, uuid: &str, published: &str, severities: &[&str]) -> PatchSearchResult { + PatchSearchResult { + uuid: uuid.to_string(), + purl: purl.to_string(), + published_at: published.to_string(), + description: String::new(), + license: "MIT".to_string(), + tier: "free".to_string(), + vulnerabilities: severities + .iter() + .enumerate() + .map(|(i, s)| { + ( + format!("GHSA-{uuid}-{i}"), + VulnerabilityResponse { + cves: Vec::new(), + summary: String::new(), + severity: (*s).to_string(), + description: String::new(), + }, + ) + }) + .collect(), + } + } + + fn manifest(entries: &[(&str, &str)]) -> PatchManifest { + let mut m = PatchManifest::new(); + for (purl, uuid) in entries { + m.patches.insert( + (*purl).to_string(), + PatchRecord { + uuid: (*uuid).to_string(), + exported_at: String::new(), + files: HashMap::new(), + vulnerabilities: HashMap::new(), + description: String::new(), + license: String::new(), + tier: String::new(), + }, + ); + } + m + } + + fn classes(rows: &[Row]) -> Vec<(String, Recorded, String)> { + rows.iter() + .map(|r| { + ( + r.candidate.purl.clone(), + r.candidate.recorded.clone(), + r.writer.uuid.clone(), + ) + }) + .collect() + } + + #[test] + fn offers_drop_inaccessible_paid_patches_and_pick_the_top_one() { + let mut paid = offer("pkg:npm/a@1", "p", "2026-01-01T00:00:00Z", &["critical"]); + paid.tier = "paid".into(); + let results = vec![ + offer("pkg:npm/a@1", "old", "2020-01-01T00:00:00Z", &["high"]), + paid, + offer("pkg:npm/a@1", "new", "2026-01-01T00:00:00Z", &["high"]), + ]; + let free = Offers::from_results(&results, false); + assert_eq!(free.selected["pkg:npm/a@1"].uuid, "new"); + assert_eq!(free.unfiltered["pkg:npm/a@1"].len(), 2); + let all = Offers::from_results(&results, true); + assert_eq!(all.selected["pkg:npm/a@1"].uuid, "p"); + } + + #[test] + fn classification_rows() { + let results = vec![ + // NEW + offer("pkg:npm/new@1", "n1", "2026-01-01T00:00:00Z", &["high"]), + // ALREADY: recorded == selected + offer("pkg:npm/same@1", "s1", "2026-01-01T00:00:00Z", &["high"]), + // UPGRADE: the selection is newer than the recorded patch + offer("pkg:npm/up@1", "u-new", "2026-06-01T00:00:00Z", &["high"]), + offer("pkg:npm/up@1", "u-old", "2026-01-01T00:00:00Z", &["high"]), + // ALREADY (kept): the selection only wins on the uuid tiebreak + offer("pkg:npm/tie@1", "a-sel", "", &["high"]), + offer("pkg:npm/tie@1", "z-rec", "", &["high"]), + // UPGRADE: the recorded uuid is no longer offered + offer("pkg:npm/gone@1", "g2", "2026-01-01T00:00:00Z", &["low"]), + ]; + let offers = Offers::from_results(&results, true); + let recorded = manifest(&[ + ("pkg:npm/same@1", "s1"), + ("pkg:npm/up@1", "u-old"), + ("pkg:npm/tie@1", "z-rec"), + ("pkg:npm/gone@1", "g1"), + ]); + let rows = classify(&offers, Some(&recorded), ""); + assert_eq!( + classes(&rows), + vec![ + ( + "pkg:npm/gone@1".into(), + Recorded::Superseded { + old_uuid: "g1".into() + }, + "g2".into() + ), + ("pkg:npm/new@1".into(), Recorded::None, "n1".into()), + ("pkg:npm/same@1".into(), Recorded::Same, "s1".into()), + ( + "pkg:npm/tie@1".into(), + Recorded::Kept { + uuid: "z-rec".into() + }, + "z-rec".into() + ), + ( + "pkg:npm/up@1".into(), + Recorded::Superseded { + old_uuid: "u-old".into() + }, + "u-new".into() + ), + ] + ); + let updates = upgrades(&rows, &["pkg:npm/up@1".to_string()]); + assert_eq!( + updates + .iter() + .map(|u| (u.purl.as_str(), u.old_uuid.as_str(), u.new_uuid.as_str())) + .collect::>(), + [ + ("pkg:npm/gone@1", "g1", "g2"), + ("pkg:npm/up@1", "u-old", "u-new") + ] + ); + } + + #[test] + fn recorded_matches_percent_encoding_and_qualifier_twins() { + let results = vec![ + offer("pkg:npm/%40s/x@1", "e1", "", &["high"]), + offer("pkg:pypi/w@1?artifact_id=b", "w2", "", &["high"]), + ]; + let offers = Offers::from_results(&results, true); + let recorded = manifest(&[ + ("pkg:npm/@s/x@1", "e1"), + ("pkg:pypi/w@1?artifact_id=a", "w1"), + ]); + let rows = classify(&offers, Some(&recorded), ""); + assert_eq!(rows[0].candidate.recorded, Recorded::Same); + // The twin's recorded uuid is not offered for this twin: the late + // twin lands uncapped as an UPGRADE. + assert_eq!( + rows[1].candidate.recorded, + Recorded::Superseded { + old_uuid: "w1".into() + } + ); + } + + #[test] + fn several_recorded_uuids_prefer_the_selected_one_else_the_smallest() { + let results = vec![offer("pkg:pypi/w@1", "b", "", &["high"])]; + let offers = Offers::from_results(&results, true); + let recorded = manifest(&[ + ("pkg:pypi/w@1?artifact_id=1", "c"), + ("pkg:pypi/w@1?artifact_id=2", "b"), + ]); + let rows = classify(&offers, Some(&recorded), ""); + assert_eq!(rows[0].candidate.recorded, Recorded::Same); + let recorded = manifest(&[ + ("pkg:pypi/w@1?artifact_id=1", "d"), + ("pkg:pypi/w@1?artifact_id=2", "c"), + ]); + let rows = classify(&offers, Some(&recorded), ""); + assert_eq!( + rows[0].candidate.recorded, + Recorded::Superseded { + old_uuid: "c".into() + } + ); + } + + #[test] + fn stage_carries_the_budget_and_renders_the_block() { + let configured = MaxNew { + value: Some(2), + source: MaxNewSource::Flag, + }; + let carry = RolloutCarry::new(configured, "/repo".into()); + let results = vec![ + offer("pkg:npm/a@1", "ua", "", &["critical"]), + offer("pkg:npm/b@1", "ub", "", &["low"]), + ]; + let offers = Offers::from_results(&results, true); + let mut first = Stage::new(configured, Some(carry.clone()), Path::new("/repo/x")); + assert_eq!(first.project, "x"); + let rows = classify(&offers, None, &first.project); + first.plan(&rows, |_| true); + assert_eq!(carry.lock().remaining, Some(0)); + let results = vec![ + offer("pkg:npm/a@1", "ua", "", &["critical"]), + offer("pkg:npm/c@1", "uc", "", &["high"]), + ]; + let offers = Offers::from_results(&results, true); + let mut second = Stage::new(configured, Some(carry.clone()), Path::new("/repo/y")); + let rows = classify(&offers, None, &second.project); + second.plan(&rows, |_| true); + let json = second.json(); + assert_eq!( + json["maxNewPatches"], + serde_json::json!({"value": 2, "source": "flag"}) + ); + assert_eq!( + json["counts"], + serde_json::json!({"new": 1, "deferred": 1, "upgrade": 0, "already": 0}) + ); + assert_eq!( + json["deferred"], + serde_json::json!([{ + "purl": "pkg:npm/c@1", "uuids": ["uc"], "severity": "high", + "advisoryCount": 1, "projects": ["y"], "rank": 2 + }]) + ); + assert_eq!( + second.deferred_skips(), + vec![serde_json::json!({ + "purl": "pkg:npm/c@1", "uuid": "uc", "reason": "rollout_deferred", + "detail": "rank 2 in the rollout queue; a later scan adds it" + })] + ); + let (line, next) = second.human(false); + assert_eq!( + line.as_deref(), + Some( + "Rollout: 1 of 2 new patches applied (maxNewPatches=2 from --max-new-patches); \ + 0 upgrades, 0 already applied." + ) + ); + assert_eq!( + next, + [ + "1 new patch deferred; commit these changes and run scan again to apply the next 1.", + "Next up: c@1 (high)" + ] + ); + } + + #[test] + fn unlimited_runs_print_no_rollout_line() { + let results = vec![offer("pkg:npm/a@1", "ua", "", &["critical"])]; + let offers = Offers::from_results(&results, true); + let mut stage = Stage::new(MaxNew::UNLIMITED, None, Path::new("/repo")); + let rows = classify(&offers, None, ""); + stage.plan(&rows, |_| true); + assert_eq!(stage.human(false), (None, Vec::new())); + assert_eq!( + stage.json()["maxNewPatches"]["value"], + serde_json::Value::Null + ); + assert_eq!(stage.json()["counts"]["new"], 1); + } + + #[test] + fn a_zero_cap_says_how_to_add_the_deferred_patches() { + let results: Vec = ["a", "b", "c", "d"] + .iter() + .map(|n| offer(&format!("pkg:npm/{n}@1"), n, "", &["high"])) + .collect(); + let offers = Offers::from_results(&results, true); + let zero = MaxNew { + value: Some(0), + source: MaxNewSource::File, + }; + let mut stage = Stage::new(zero, None, Path::new("/repo")); + let rows = classify(&offers, None, ""); + stage.plan(&rows, |_| true); + let (line, next) = stage.human(true); + assert_eq!( + line.as_deref(), + Some( + "Rollout: 0 of 4 new patches would be applied (maxNewPatches=0 from socket.yml); \ + 0 upgrades, 0 already applied." + ) + ); + assert!( + next[0].starts_with("4 new patches deferred: maxNewPatches=0"), + "{next:?}" + ); + assert_eq!(next[1], "Next up: a@1 (high), b@1 (high), c@1 (high), …"); + } + + #[test] + fn incomplete_lookups_warn_only_when_they_deferred_something() { + let results = vec![offer("pkg:npm/a@1", "ua", "", &["critical"])]; + let offers = Offers::from_results(&results, true); + let capped = MaxNew { + value: Some(3), + source: MaxNewSource::Flag, + }; + let mut stage = Stage::new(capped, None, Path::new("/repo")); + stage.incomplete = true; + assert!(!stage.may_admit_new()); + stage.plan(&classify(&offers, None, ""), |_| true); + let codes: Vec<&str> = stage.warnings().iter().map(|(c, _)| *c).collect(); + assert_eq!(codes, [ROLLOUT_INCOMPLETE_LOOKUP]); + let mut unlimited = Stage::new(MaxNew::UNLIMITED, None, Path::new("/repo")); + unlimited.incomplete = true; + assert!(unlimited.may_admit_new()); + unlimited.plan(&classify(&offers, None, ""), |_| true); + assert!(unlimited.warnings().is_empty()); + } + + #[test] + fn project_paths_are_repo_relative() { + assert_eq!(project_rel(Path::new("/r"), Path::new("/r")), ""); + assert_eq!(project_rel(Path::new("/r"), Path::new("/r/a/b")), "a/b"); + } +} diff --git a/crates/socket-patch-cli/src/commands/scan/rollout_args.rs b/crates/socket-patch-cli/src/commands/scan/rollout_args.rs new file mode 100644 index 00000000..ae24cd07 --- /dev/null +++ b/crates/socket-patch-cli/src/commands/scan/rollout_args.rs @@ -0,0 +1,145 @@ +//! `scan --max-new-patches` (work item B of the staged-rollout design, +//! `docs/design/staged-rollout.md` §5). + +use std::collections::BTreeSet; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use clap::Args; +use socket_patch_core::rollout::{resolve_max_new, MaxNew}; + +/// The env binding of `--max-new-patches`. Read by [`RolloutArgs::resolve`] +/// rather than clap's `env =`, because the rollout block reports whether +/// the value came from the flag or the environment. +pub const MAX_NEW_PATCHES_ENV: &str = "SOCKET_MAX_NEW_PATCHES"; + +/// A parsed `--max-new-patches` value; `None` is `none` (no cap). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MaxNewPatches(pub Option); + +/// `N` (0..=4294967295) or `none`, case-insensitive. +pub fn parse_max_new_patches(s: &str) -> Result { + let s = s.trim(); + if s.eq_ignore_ascii_case("none") { + return Ok(MaxNewPatches(None)); + } + s.parse::() + .map(|n| MaxNewPatches(Some(n))) + .map_err(|_| format!("`{s}` is not a number of patches (0 to 4294967295) or `none`")) +} + +#[derive(Args, Clone, Default)] +pub struct RolloutArgs { + /// Add at most N patches to packages that have none yet, most severe + /// first; the rest are deferred to the next scan. Upgrades of patched + /// packages are not capped. `none` lifts a cap set in socket.yml + #[arg( + long = "max-new-patches", + value_name = "N|none", + value_parser = parse_max_new_patches + )] + pub max_new_patches: Option, + + /// The budget shared by the project directories of one invocation. + #[arg(skip)] + pub(crate) carry: Option, +} + +impl RolloutArgs { + /// The configured cap: the flag, then `env` (the + /// [`MAX_NEW_PATCHES_ENV`] value; empty is unset), then the socket.yml + /// value, then unlimited. A malformed env value is a usage error. + pub fn resolve(&self, env: Option<&str>, file: Option) -> Result { + let env = match env.filter(|v| !v.is_empty()) { + Some(raw) => Some( + parse_max_new_patches(raw) + .map_err(|e| format!("{MAX_NEW_PATCHES_ENV}: {e}"))? + .0, + ), + None => None, + }; + Ok(resolve_max_new( + self.max_new_patches.map(|v| v.0), + env, + file, + None, + )) + } + + /// [`Self::resolve`] against the process environment. + pub fn resolve_from_env(&self, file: Option) -> Result { + let env = std::env::var(MAX_NEW_PATCHES_ENV).ok(); + self.resolve(env.as_deref(), file) + } +} + +/// What one invocation's project directories share: the cap as configured, +/// the budget left, and the base purls already admitted (admitted free in +/// later directories). +#[derive(Debug)] +pub(crate) struct Carry { + pub(crate) configured: MaxNew, + pub(crate) remaining: Option, + pub(crate) admitted: BTreeSet, + /// The directory the project paths are relative to. + pub(crate) root: PathBuf, +} + +#[derive(Debug, Clone)] +pub(crate) struct RolloutCarry(pub(crate) Arc>); + +impl RolloutCarry { + pub(crate) fn new(configured: MaxNew, root: PathBuf) -> Self { + RolloutCarry(Arc::new(Mutex::new(Carry { + configured, + remaining: configured.value, + admitted: BTreeSet::new(), + root, + }))) + } + + pub(crate) fn lock(&self) -> std::sync::MutexGuard<'_, Carry> { + self.0.lock().unwrap_or_else(|e| e.into_inner()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use socket_patch_core::rollout::MaxNewSource; + + #[test] + fn parses_numbers_and_none() { + assert_eq!(parse_max_new_patches("5"), Ok(MaxNewPatches(Some(5)))); + assert_eq!(parse_max_new_patches("0"), Ok(MaxNewPatches(Some(0)))); + assert_eq!( + parse_max_new_patches("4294967295"), + Ok(MaxNewPatches(Some(u32::MAX))) + ); + assert_eq!(parse_max_new_patches("NONE"), Ok(MaxNewPatches(None))); + for bad in ["", "-1", "4294967296", "five", "1.5", "all"] { + assert!(parse_max_new_patches(bad).is_err(), "{bad:?}"); + } + } + + #[test] + fn flag_beats_env_beats_file() { + let flag = RolloutArgs { + max_new_patches: Some(MaxNewPatches(Some(1))), + carry: None, + }; + let none = RolloutArgs::default(); + let got = flag.resolve(Some("2"), Some(3)).unwrap(); + assert_eq!((got.value, got.source), (Some(1), MaxNewSource::Flag)); + let got = none.resolve(Some("2"), Some(3)).unwrap(); + assert_eq!((got.value, got.source), (Some(2), MaxNewSource::Env)); + let got = none.resolve(Some("none"), Some(3)).unwrap(); + assert_eq!((got.value, got.source), (None, MaxNewSource::Env)); + let got = none.resolve(Some(""), Some(3)).unwrap(); + assert_eq!((got.value, got.source), (Some(3), MaxNewSource::File)); + let got = none.resolve(None, None).unwrap(); + assert_eq!((got.value, got.source), (None, MaxNewSource::Default)); + let err = none.resolve(Some("lots"), None).unwrap_err(); + assert!(err.starts_with("SOCKET_MAX_NEW_PATCHES: "), "{err}"); + } +} diff --git a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs index db4aa9bb..6320adec 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -20,7 +20,7 @@ use socket_patch_core::api::client::ApiClient; use socket_patch_core::api::types::{BatchPackagePatches, PatchResponse, PatchSearchResult}; use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; -use socket_patch_core::manifest::schema::PatchRecord; +use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::telemetry::{track_patch_vendor_failed, PendingTelemetry}; use socket_patch_core::utils::purl::strip_purl_qualifiers; use socket_patch_core::vendor::{load_state, lookup_entry, save_state, VendorState}; @@ -42,9 +42,10 @@ use crate::json_envelope::{Command as EnvelopeCommand, Envelope}; use crate::ui::{plural, print_json}; use super::gc::{gc_json, print_gc_vendored_line, run_apply_gc}; +use super::rollout::Stage; use super::{ - discover_selected, download_params, embed_vex_into_json, emit_discovery_error_json, - push_run_warning, ScanArgs, + classified_rows, discover_selected, download_params, embed_vex_into_json, + emit_discovery_error_json, finish_rollout_json, push_run_warning, writers_of, ScanArgs, }; /// Run-level warning: a `.socket/manifest.json` record for a purl the @@ -125,6 +126,29 @@ pub(crate) async fn preview_vendor_json( serde_json::json!({ "dryRun": true, "patches": patches }) } +/// The purls of `selected` the wet run's Bun or vlt preflight would refuse +/// before any download (the `would_refuse` rows of +/// [`preview_vendor_json`]): the vendored planning pass, so a refused NEW +/// patch holds no rollout slot. +pub(super) async fn preflight_refused_purls( + cwd: &Path, + selected: &[PatchSearchResult], +) -> HashSet { + let state = load_state(cwd).await; + let refusal = + bun_vendor_preflight_with_ledger(cwd, selected, state.as_ref().map(|s| &s.entries)).await; + let vlt_refusals = + vlt_vendor_preflight_selected(cwd, selected, state.as_ref().map(|s| &s.entries)).await; + selected + .iter() + .filter(|p| { + refusal.as_ref().is_some_and(|r| r.applies_to(&p.purl)) + || vlt_refusal_for(&vlt_refusals, &p.purl).is_some() + }) + .map(|p| p.purl.clone()) + .collect() +} + /// Human rendering of the vendored dry-run preview's `would_refuse` records /// (see [`preview_vendor_json`]): the count line above it still says /// "would download and vendor", so name what the wet run would refuse and @@ -453,6 +477,9 @@ async fn run_vendor_json_path( use_public_proxy: bool, all_packages_with_patches: &[BatchPackagePatches], can_access_paid_patches: bool, + recorded: Option<&PatchManifest>, + batch_failed: bool, + stage: &mut Stage, result: &mut serde_json::Value, manifest_path: &Path, socket_dir: &Path, @@ -470,11 +497,11 @@ async fn run_vendor_json_path( // Same discovery as `--apply`. Vendored purls are NOT filtered here — // re-vendoring a stale uuid is the point of the flag (same-uuid re-runs // land on the backend's `already_vendored` skip). - let selected = match discover_selected( + let discovered = match discover_selected( api_client, all_packages_with_patches, can_access_paid_patches, - &args.common, + false, false, false, telemetry, @@ -482,12 +509,31 @@ async fn run_vendor_json_path( ) .await { - Ok(s) => s, + Ok(d) => d, Err((code, message)) => { emit_discovery_error_json(result, &message); return code; } }; + let rows = classified_rows( + stage, + &discovered, + recorded, + batch_failed, + all_packages_with_patches, + Some(&mut *result), + ); + // The planning pass: a patch the preflight refuses holds no slot (it + // still reaches the engine, which reports the refusal). + let writers = writers_of(&rows); + let refused = preflight_refused_purls(&args.common.cwd, &writers).await; + stage.plan(&rows, |r| !refused.contains(&r.writer.purl)); + let deferred = stage.deferred_keys(); + let selected: Vec = writers + .into_iter() + .filter(|p| !deferred.contains(&(p.purl.clone(), p.uuid.clone()))) + .collect(); + finish_rollout_json(stage, result); if args.common.dry_run { // No downloads, no backends: classify against the ledger @@ -781,6 +827,9 @@ pub(super) fn boxed_vendor_json_path<'a>( use_public_proxy: bool, all_packages_with_patches: &'a [BatchPackagePatches], can_access_paid_patches: bool, + recorded: Option<&'a PatchManifest>, + batch_failed: bool, + stage: &'a mut Stage, result: &'a mut serde_json::Value, manifest_path: &'a Path, socket_dir: &'a Path, @@ -798,6 +847,9 @@ pub(super) fn boxed_vendor_json_path<'a>( use_public_proxy, all_packages_with_patches, can_access_paid_patches, + recorded, + batch_failed, + stage, result, manifest_path, socket_dir, diff --git a/crates/socket-patch-cli/tests/cli_parse_scan.rs b/crates/socket-patch-cli/tests/cli_parse_scan.rs index 1ed6a66d..5983eae3 100644 --- a/crates/socket-patch-cli/tests/cli_parse_scan.rs +++ b/crates/socket-patch-cli/tests/cli_parse_scan.rs @@ -45,6 +45,7 @@ const SCAN_ENV_VARS: &[&str] = &[ "SOCKET_JSON", "SOCKET_LOCK_TIMEOUT", "SOCKET_MANIFEST_PATH", + "SOCKET_MAX_NEW_PATCHES", "SOCKET_NO_TRUST_LOCKFILE_CONFIG", "SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG", "SOCKET_NO_VLT_INSTALL_CLEANUP", @@ -518,6 +519,11 @@ fn scan_json_empty_cwd_emits_updates_key() { // v4 duality rework: the positional PATH globs are echoed on every // scan envelope, always present (empty when no scoping was given). "paths": [], + "rollout": { + "maxNewPatches": { "value": null, "source": "default" }, + "counts": { "new": 0, "deferred": 0, "upgrade": 0, "already": 0 }, + "deferred": [], + }, // v5: a bare scan runs hosted mode, so its result nests here. "redirect": { "mode": "hosted", 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 fce505b3..88507ea7 100644 --- a/crates/socket-patch-cli/tests/in_process_cargo_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_cargo_apply.rs @@ -247,6 +247,7 @@ async fn cargo_fetch_scan_sync_patches_real_file() { mode: None, all_releases: false, vex: Default::default(), + rollout: Default::default(), }; // CARGO_HOME must be set in this process's env so the cargo crawler // probes the isolated location (not the developer's real ~/.cargo). @@ -367,6 +368,7 @@ async fn cargo_apply_refuses_on_before_hash_mismatch() { mode: None, all_releases: false, vex: Default::default(), + rollout: Default::default(), }; std::env::set_var("CARGO_HOME", &cargo_home); @@ -466,6 +468,7 @@ async fn cargo_crawler_finds_real_fetched_crate() { mode: None, all_releases: false, vex: Default::default(), + rollout: Default::default(), }; assert_eq!(scan_run(args).await, 0); 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 ae9f45c4..df7246a2 100644 --- a/crates/socket-patch-cli/tests/in_process_gem_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_gem_apply.rs @@ -225,6 +225,7 @@ async fn gem_install_scan_sync_patches_real_file() { mode: None, all_releases: false, vex: Default::default(), + rollout: Default::default(), }; let code = scan_run(args).await; assert_eq!( @@ -338,6 +339,7 @@ async fn gem_crawler_finds_real_installed_gem() { mode: None, all_releases: false, vex: Default::default(), + rollout: Default::default(), }; assert_eq!(scan_run(args).await, 0); 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 ecf70b37..9a6251df 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 @@ -245,6 +245,7 @@ fn scan_args(cwd: &Path, api_url: String, all_releases: bool) -> ScanArgs { mode: None, all_releases, vex: Default::default(), + rollout: Default::default(), } } 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 c5bafe10..e5658d0d 100644 --- a/crates/socket-patch-cli/tests/in_process_pypi_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_pypi_apply.rs @@ -274,6 +274,7 @@ async fn pypi_install_scan_sync_patches_real_file() { mode: None, all_releases: false, vex: Default::default(), + rollout: Default::default(), }; // Avoid borrow problem with into_iter let _ = &mut args; @@ -351,6 +352,7 @@ async fn pypi_scan_then_apply_force_patches_real_file() { mode: None, all_releases: false, vex: Default::default(), + rollout: Default::default(), }; let scan_code = scan_run(scan_args).await; assert_eq!(scan_code, 0, "scan --sync should succeed (exit 0)"); @@ -461,6 +463,7 @@ async fn pypi_apply_dry_run_does_not_modify_file() { mode: None, all_releases: false, vex: Default::default(), + rollout: Default::default(), }; // Require success: otherwise an early crash (before the apply path // is ever reached) would leave the file untouched and let this test @@ -591,6 +594,7 @@ async fn pypi_crawler_finds_real_installed_six() { mode: None, all_releases: false, vex: Default::default(), + rollout: Default::default(), }; assert_eq!(scan_run(args).await, 0); 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 18b866a9..829a8083 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 @@ -323,6 +323,7 @@ fn scan_args(tmp: &Path, api_url: String, all_releases: bool) -> ScanArgs { mode: None, all_releases, vex: Default::default(), + rollout: Default::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 95325a01..777ce575 100644 --- a/crates/socket-patch-cli/tests/in_process_python_envs.rs +++ b/crates/socket-patch-cli/tests/in_process_python_envs.rs @@ -140,6 +140,7 @@ fn default_args(cwd: &Path, api_url: String) -> ScanArgs { mode: None, all_releases: false, vex: Default::default(), + rollout: Default::default(), } } diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index ef2c0309..b7c883b4 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -66,6 +66,7 @@ fn redirect_args(cwd: &Path, api_url: String) -> ScanArgs { mode: None, all_releases: false, vex: Default::default(), + rollout: Default::default(), } } diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs b/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs index 7eb92abf..8a1dc5f4 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs @@ -168,6 +168,7 @@ fn hosted_args(cwd: &Path, api_url: String, vex: Option<&Path>) -> ScanArgs { vex: vex.map(Path::to_path_buf), ..Default::default() }, + rollout: Default::default(), } } diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs b/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs index b176e9a6..31212e85 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs @@ -100,6 +100,7 @@ fn hosted_args(cwd: &Path, api_url: String, vex: Option<&Path>) -> ScanArgs { vex_product: vex.map(|_| "pkg:pypi/pipenv-fixture@0.1.0".to_string()), ..Default::default() }, + rollout: Default::default(), } } diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs index 03da8fcd..18f7d89f 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs @@ -100,6 +100,7 @@ fn hosted_args(cwd: &Path, api_url: String) -> ScanArgs { mode: Some(ScanMode::Hosted), all_releases: false, vex: Default::default(), + rollout: Default::default(), } } diff --git a/crates/socket-patch-cli/tests/in_process_redirect_poetry.rs b/crates/socket-patch-cli/tests/in_process_redirect_poetry.rs index 9ba3c5a9..a0f9801d 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_poetry.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_poetry.rs @@ -137,6 +137,7 @@ fn hosted_args(cwd: &Path, api_url: String, vex: Option<&Path>) -> ScanArgs { vex: vex.map(Path::to_path_buf), ..Default::default() }, + rollout: Default::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 094454bc..82d66d50 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 @@ -98,6 +98,7 @@ fn default_scan_args(cwd: &Path, eco: &str, api_url: String) -> ScanArgs { mode: None, all_releases: false, vex: Default::default(), + rollout: Default::default(), } } diff --git a/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs b/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs index 5f6072e0..6796c7aa 100644 --- a/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs +++ b/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs @@ -91,6 +91,7 @@ fn hosted_scan_args(cwd: &Path, api_url: String) -> ScanArgs { mode: Some(ScanMode::Hosted), all_releases: false, vex: Default::default(), + rollout: Default::default(), } } diff --git a/crates/socket-patch-cli/tests/in_process_scan.rs b/crates/socket-patch-cli/tests/in_process_scan.rs index 859bb611..38156701 100644 --- a/crates/socket-patch-cli/tests/in_process_scan.rs +++ b/crates/socket-patch-cli/tests/in_process_scan.rs @@ -44,6 +44,7 @@ fn default_args(cwd: &Path) -> ScanArgs { mode: None, all_releases: false, vex: Default::default(), + rollout: Default::default(), } } diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index 15c8aaf8..52d8630c 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -3267,6 +3267,7 @@ snapshots: mode: Some(ScanMode::Hosted), all_releases: false, vex: Default::default(), + rollout: Default::default(), } } diff --git a/crates/socket-patch-core/src/rollout.rs b/crates/socket-patch-core/src/rollout.rs index 89e2e11b..92016bd9 100644 --- a/crates/socket-patch-core/src/rollout.rs +++ b/crates/socket-patch-core/src/rollout.rs @@ -643,14 +643,15 @@ mod tests { #[test] fn resolve_max_new_precedence_table() { use MaxNewSource::*; - let cases: [( + type Case = ( Option>, Option>, Option, Option, Option, MaxNewSource, - ); 10] = [ + ); + let cases: [Case; 10] = [ (None, None, None, None, None, Default), (None, None, Some(5), None, Some(5), File), (None, Some(Some(2)), Some(5), None, Some(2), Env), From 7717f5082b8085819d06e9c52e4f0391aeac950b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 14:13:31 +0000 Subject: [PATCH 08/22] Test the scan cap end to end in every mode Nine packages under a cap of three roll forward three per run in hosted, agent and vendored mode, most severe first, and a fourth run changes nothing. The dry run predicts the wet run, upgrades land with a cap of zero, patches that cannot land hold no slot, a failed lookup admits nothing new, and several project directories share one budget. A hosted pin on a patch server scan does not recognize still counts as applied when the lockfile names its patch uuid, so the rollout cannot stall on a missing --patch-server-url. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/commands/scan/hosted.rs | 13 +- .../src/commands/scan/rollout.rs | 16 + .../socket-patch-cli/tests/cli_parse_scan.rs | 56 ++ .../tests/scan_rollout_e2e.rs | 737 ++++++++++++++++++ 4 files changed, 821 insertions(+), 1 deletion(-) create mode 100644 crates/socket-patch-cli/tests/scan_rollout_e2e.rs diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 26a904b2..54a89553 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -1687,7 +1687,7 @@ pub(crate) async fn run_redirect_selected( .iter() .any(|c| !gate.is_new(&c.sel_purl, &c.dep.patch_uuid)) }); - let _lock: Option = if !common.dry_run && !candidates.is_empty() && may_write { + let mut lock: Option = if !common.dry_run && !candidates.is_empty() && may_write { match acquire_hosted_lock(common, &mut scan_result) { Ok(guard) => Some(guard), Err(code) => return code, @@ -2414,6 +2414,8 @@ pub(crate) async fn run_redirect_selected( .filter(|(_, confirmed)| *confirmed) .map(|(c, _)| (c.sel_purl.clone(), c.dep.patch_uuid.clone())) .collect(); + let texts: Vec<&str> = files.values().map(String::as_str).collect(); + super::rollout::mark_pinned(&mut gate.rows, &texts); let unknown = gate.stage.reference_failed.is_some(); gate.stage.plan(&gate.rows, |row| { unknown || eligible.contains(&(row.writer.purl.clone(), row.writer.uuid.clone())) @@ -2426,7 +2428,16 @@ pub(crate) async fn run_redirect_selected( overrides = candidates.iter().map(|c| c.dep.clone()).collect(); (files, rewrite) = rewrite_candidates(files, &overrides, &inputs).await; } + // A row that turned out to be pinned already still gets written: take + // the lock skipped above (no takeover ran without it). + if lock.is_none() && !common.dry_run && !candidates.is_empty() { + match acquire_hosted_lock(common, &mut scan_result) { + Ok(guard) => lock = Some(guard), + Err(code) => return code, + } + } } + let _lock = lock; // Unknown installer → the modern `file` shape was chosen; say so only // when the lock was (or, on --dry-run, would be) rewritten. diff --git a/crates/socket-patch-cli/src/commands/scan/rollout.rs b/crates/socket-patch-cli/src/commands/scan/rollout.rs index ea623748..180fbe46 100644 --- a/crates/socket-patch-cli/src/commands/scan/rollout.rs +++ b/crates/socket-patch-cli/src/commands/scan/rollout.rs @@ -203,6 +203,22 @@ pub(crate) fn lookup_incomplete( .any(|purl| recorded.is_none_or(|m| recorded_uuids(m, purl).is_empty())) } +/// Mark NEW rows whose selected uuid the project's lockfile texts already +/// mention as ALREADY. A hosted pin on a patch server discovery does not +/// recognize (an origin missing from `--patch-server-url`) would otherwise +/// read as NEW on every run and hold its slot forever; patch uuids are +/// unique, so a mention is a pin. +pub(crate) fn mark_pinned(rows: &mut [Row], texts: &[&str]) { + for row in rows.iter_mut().filter(|r| r.candidate.recorded.is_new()) { + if texts + .iter() + .any(|t| t.contains(row.candidate.uuid.as_str())) + { + row.candidate.recorded = Recorded::Same; + } + } +} + /// The rows the hosted engine plans (§9.0 step 7 inside the engine, after /// its eligibility checks) and the stage that records the plan. pub(crate) struct Gate<'a> { diff --git a/crates/socket-patch-cli/tests/cli_parse_scan.rs b/crates/socket-patch-cli/tests/cli_parse_scan.rs index 5983eae3..6fe5cd1b 100644 --- a/crates/socket-patch-cli/tests/cli_parse_scan.rs +++ b/crates/socket-patch-cli/tests/cli_parse_scan.rs @@ -988,3 +988,59 @@ fn no_vlt_install_cleanup_flag_and_env_parse() { _ => panic!("expected Scan"), } } + +// ── --max-new-patches (staged rollout) ─────────────────────────────────── + +#[test] +#[serial_test::serial] +fn max_new_patches_defaults_to_unset() { + let args = parse_scan(&[]); + assert_eq!(args.rollout.max_new_patches, None); +} + +#[test] +#[serial_test::serial] +fn max_new_patches_takes_a_count_or_none() { + use socket_patch_cli::commands::scan::rollout_args::MaxNewPatches; + for (raw, want) in [ + ("5", Some(5)), + ("0", Some(0)), + ("4294967295", Some(u32::MAX)), + ("none", None), + ("NONE", None), + ] { + let args = parse_scan(&["--max-new-patches", raw]); + assert_eq!(args.rollout.max_new_patches, Some(MaxNewPatches(want)), "{raw}"); + } +} + +#[test] +#[serial_test::serial] +fn max_new_patches_rejects_malformed_values() { + for raw in ["-1", "4294967296", "five", "", "all"] { + let err = match try_parse_scan(&[&format!("--max-new-patches={raw}")]) { + Ok(_) => panic!("{raw:?} must not parse"), + Err(e) => e, + }; + assert_eq!(err.exit_code(), 2, "{raw:?}: usage error"); + } +} + +#[test] +fn max_new_patches_env_is_read_at_run_time() { + // The env binding is resolved by `RolloutArgs::resolve` (the rollout + // block reports flag vs env), not by clap: empty is unset, malformed is + // a usage error, and the flag wins. + use socket_patch_cli::commands::scan::rollout_args::{MaxNewPatches, RolloutArgs}; + use socket_patch_core::rollout::MaxNewSource; + let unset = RolloutArgs::default(); + let got = unset.resolve(Some("7"), None).unwrap(); + assert_eq!((got.value, got.source), (Some(7), MaxNewSource::Env)); + let got = unset.resolve(Some(""), None).unwrap(); + assert_eq!((got.value, got.source), (None, MaxNewSource::Default)); + assert!(unset.resolve(Some("x"), None).is_err()); + let mut flag = RolloutArgs::default(); + flag.max_new_patches = Some(MaxNewPatches(Some(1))); + let got = flag.resolve(Some("7"), None).unwrap(); + assert_eq!((got.value, got.source), (Some(1), MaxNewSource::Flag)); +} diff --git a/crates/socket-patch-cli/tests/scan_rollout_e2e.rs b/crates/socket-patch-cli/tests/scan_rollout_e2e.rs new file mode 100644 index 00000000..55409669 --- /dev/null +++ b/crates/socket-patch-cli/tests/scan_rollout_e2e.rs @@ -0,0 +1,737 @@ +//! `scan --max-new-patches` end to end (staged rollout, work item B): a +//! project with nine patchable packages and a cap of three rolls forward +//! three packages per run, most severe first, in hosted, agent and vendored +//! mode; a fourth run changes nothing. Mock API, the built binary. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use wiremock::matchers::{method, path, path_regex}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const ORG: &str = "test-org"; +const TOKEN: &str = "22222222-2222-4222-8222-222222222222"; +const HOST: &str = "http://patch.test"; + +/// `(name, severities)`: one advisory per severity; none = unknown. +const PACKAGES: [(&str, &[&str]); 9] = [ + ("roll-a", &["low"]), + ("roll-b", &["critical"]), + ("roll-c", &["high"]), + ("roll-d", &["medium"]), + ("roll-e", &["critical", "high"]), + ("roll-f", &[]), + ("roll-g", &["high", "low", "low"]), + ("roll-h", &["medium"]), + ("roll-i", &["low"]), +]; + +/// The rollout order: severity, then advisory count, then name. +const ORDER: [&str; 9] = [ + "roll-e", "roll-b", "roll-g", "roll-c", "roll-d", "roll-h", "roll-a", "roll-i", "roll-f", +]; + +fn binary() -> PathBuf { + env!("CARGO_BIN_EXE_socket-patch").into() +} + +fn uuid(name: &str) -> String { + let n = PACKAGES.iter().position(|(p, _)| *p == name).unwrap() + 1; + format!("{n:08x}-1111-4111-8111-{n:012x}") +} + +fn purl(name: &str) -> String { + format!("pkg:npm/{name}@1.0.0") +} + +fn hosted_url(name: &str) -> String { + format!( + "{HOST}/patch/npm/{name}/1.0.0/{TOKEN}/{}/{name}-1.0.0.tgz", + uuid(name) + ) +} + +fn before(name: &str) -> Vec { + format!("module.exports = '{name} before';\n").into_bytes() +} + +fn after(name: &str) -> Vec { + format!("module.exports = '{name} after';\n").into_bytes() +} + +fn git_sha256(content: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(format!("blob {}\0", content.len()).as_bytes()); + hasher.update(content); + hex::encode(hasher.finalize()) +} + +fn b64(bytes: &[u8]) -> String { + use base64::Engine; + base64::engine::general_purpose::STANDARD.encode(bytes) +} + +fn vulns(name: &str, severities: &[&str]) -> Value { + let mut map = serde_json::Map::new(); + for (i, sev) in severities.iter().enumerate() { + map.insert( + format!("GHSA-{name}-{i}"), + json!({ "cves": [], "summary": "s", "severity": sev, "description": "d" }), + ); + } + Value::Object(map) +} + +/// A v3 npm project in `dir` with `names` installed and locked. +fn write_project(dir: &Path, names: &[&str]) { + std::fs::create_dir_all(dir).unwrap(); + let deps: serde_json::Map = names + .iter() + .map(|n| (n.to_string(), json!("1.0.0"))) + .collect(); + std::fs::write( + dir.join("package.json"), + serde_json::to_vec_pretty(&json!({ + "name": "rollout-consumer", "version": "0.0.0", "dependencies": deps + })) + .unwrap(), + ) + .unwrap(); + let mut packages = serde_json::Map::new(); + packages.insert( + String::new(), + json!({ "name": "rollout-consumer", "version": "0.0.0", "dependencies": deps }), + ); + for name in names { + packages.insert( + format!("node_modules/{name}"), + json!({ + "version": "1.0.0", + "resolved": format!("https://registry.npmjs.org/{name}/-/{name}-1.0.0.tgz"), + "integrity": "sha512-UPSTREAMupstream==", + "license": "MIT" + }), + ); + let pkg = dir.join("node_modules").join(name); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + format!(r#"{{"name":"{name}","version":"1.0.0"}}"#), + ) + .unwrap(); + std::fs::write(pkg.join("index.js"), before(name)).unwrap(); + } + let mut lock = serde_json::to_vec_pretty(&json!({ + "name": "rollout-consumer", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": packages, + })) + .unwrap(); + lock.push(b'\n'); + std::fs::write(dir.join("package-lock.json"), lock).unwrap(); +} + +/// How the mock answers one package's reference grant. +#[derive(Clone, Copy, PartialEq)] +enum Grant { + Granted, + Withdrawn, + BadPurl, +} + +/// Batch, by-package, view and reference mocks for every package. +async fn mount_api(mock: &MockServer, grant: impl Fn(&str) -> Grant) { + let batch: Vec = PACKAGES + .iter() + .map(|(name, sevs)| { + json!({ + "purl": purl(name), + "patches": [{ + "uuid": uuid(name), "purl": purl(name), "tier": "free", + "cveIds": [], + "ghsaIds": (0..sevs.len()).map(|i| format!("GHSA-{name}-{i}")).collect::>(), + "severity": sevs.first().copied(), + "title": name, + }] + }) + }) + .collect(); + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(json!({ "packages": batch, "canAccessPaidPatches": false })), + ) + .mount(mock) + .await; + let mut results = serde_json::Map::new(); + for (name, sevs) in PACKAGES { + Mock::given(method("GET")) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.*{name}(%40|@)1\\.0\\.0$" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "patches": [{ + "uuid": uuid(name), "purl": purl(name), + "publishedAt": "2026-01-01T00:00:00Z", + "description": name, "license": "MIT", "tier": "free", + "vulnerabilities": vulns(name, sevs), + }], + "canAccessPaidPatches": false, + }))) + .mount(mock) + .await; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{}", uuid(name)))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "uuid": uuid(name), "purl": purl(name), + "publishedAt": "2026-01-01T00:00:00Z", + "files": { "package/index.js": { + "beforeHash": git_sha256(&before(name)), + "afterHash": git_sha256(&after(name)), + "blobContent": b64(&after(name)), + }}, + "vulnerabilities": vulns(name, sevs), + "description": name, "license": "MIT", "tier": "free", + }))) + .mount(mock) + .await; + let entry = match grant(name) { + Grant::Granted => json!({ + "status": "granted", + "url": hosted_url(name), + "purl": purl(name), + "artifacts": [{ + "kind": "tarball", "url": hosted_url(name), + "integrity": { "sha512": format!("sha512-PATCHED{name}==") } + }], + "registryOverride": null + }), + Grant::Withdrawn => json!({ "status": "withdrawn" }), + Grant::BadPurl => json!({ + "status": "granted", "url": hosted_url(name), "purl": "garbage", + "artifacts": [], "registryOverride": null + }), + }; + results.insert(uuid(name), entry); + } + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "results": results }))) + .mount(mock) + .await; +} + +fn run(root: &Path, mock: &MockServer, args: &[&str]) -> (i32, String, String) { + let uri = mock.uri(); + let mut argv = vec![ + "scan", + "--yes", + "--api-url", + uri.as_str(), + "--api-token", + "fake-token", + "--org", + ORG, + ]; + argv.extend_from_slice(args); + 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_") { + cmd.env_remove(&key); + } + } + cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); + 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(), + ) +} + +fn run_json(root: &Path, mock: &MockServer, args: &[&str]) -> Value { + let mut all = vec!["--json"]; + all.extend_from_slice(args); + let (code, stdout, stderr) = run(root, mock, &all); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + serde_json::from_str(stdout.trim()).unwrap_or_else(|e| panic!("{e}: {stdout}")) +} + +/// The packages whose hosted artifact the lockfile pins. +fn pinned(root: &Path) -> Vec { + let lock = std::fs::read_to_string(root.join("package-lock.json")).unwrap(); + ORDER + .iter() + .filter(|n| lock.contains(&hosted_url(n))) + .map(|n| n.to_string()) + .collect() +} + +fn names(list: &[&str]) -> Vec { + list.iter().map(|s| s.to_string()).collect() +} + +fn deferred_names(v: &Value) -> Vec { + v["rollout"]["deferred"] + .as_array() + .unwrap() + .iter() + .map(|d| { + d["purl"] + .as_str() + .unwrap() + .trim_start_matches("pkg:npm/") + .trim_end_matches("@1.0.0") + .to_string() + }) + .collect() +} + +fn counts(v: &Value) -> (u64, u64, u64, u64) { + let c = &v["rollout"]["counts"]; + ( + c["new"].as_u64().unwrap(), + c["deferred"].as_u64().unwrap(), + c["upgrade"].as_u64().unwrap(), + c["already"].as_u64().unwrap(), + ) +} + +#[tokio::test] +async fn hosted_cap_rolls_nine_packages_forward_three_per_run() { + let mock = MockServer::start().await; + mount_api(&mock, |_| Grant::Granted).await; + let tmp = tempfile::tempdir().unwrap(); + let names9: Vec<&str> = PACKAGES.iter().map(|(n, _)| *n).collect(); + write_project(tmp.path(), &names9); + let args = [ + "--mode", + "hosted", + "--max-new-patches", + "3", + "--patch-server-url", + HOST, + ]; + + // The dry run predicts exactly what the wet run does. + let lock_before = std::fs::read(tmp.path().join("package-lock.json")).unwrap(); + let mut dry_args = args.to_vec(); + dry_args.push("--dry-run"); + let dry = run_json(tmp.path(), &mock, &dry_args); + assert_eq!( + std::fs::read(tmp.path().join("package-lock.json")).unwrap(), + lock_before, + "a dry run writes nothing" + ); + + let mut previous_lock = lock_before; + for run_no in 0..3 { + let v = run_json(tmp.path(), &mock, &args); + if run_no == 0 { + assert_eq!(dry["rollout"], v["rollout"], "dry run == wet run"); + assert_eq!(dry["redirect"]["skipped"], v["redirect"]["skipped"]); + } + assert_eq!( + v["rollout"]["maxNewPatches"], + json!({ "value": 3, "source": "flag" }) + ); + let done = 3 * run_no as u64; + assert_eq!( + counts(&v), + (3, 6 - done, 0, done), + "run {}: {v}", + run_no + 1 + ); + assert_eq!( + pinned(tmp.path()), + names(&ORDER[..3 * (run_no + 1)]), + "run {} pins the next three, most severe first", + run_no + 1 + ); + assert_eq!(deferred_names(&v), names(&ORDER[3 * (run_no + 1)..])); + let ranks: Vec = v["rollout"]["deferred"] + .as_array() + .unwrap() + .iter() + .map(|d| d["rank"].as_u64().unwrap()) + .collect(); + assert_eq!(ranks, (4..4 + ranks.len() as u64).collect::>()); + let skipped: Vec<&str> = v["redirect"]["skipped"] + .as_array() + .unwrap() + .iter() + .map(|s| s["reason"].as_str().unwrap()) + .collect(); + assert!(skipped.iter().all(|r| *r == "rollout_deferred")); + assert_eq!(skipped.len() as u64, 6 - done); + assert_eq!(v["redirect"]["redirected"], 3 * (run_no as u64 + 1)); + previous_lock = std::fs::read(tmp.path().join("package-lock.json")).unwrap(); + } + + let v = run_json(tmp.path(), &mock, &args); + assert_eq!(counts(&v), (0, 0, 0, 9), "a fourth run adds nothing: {v}"); + assert_eq!( + std::fs::read(tmp.path().join("package-lock.json")).unwrap(), + previous_lock, + "a converged run is byte-stable" + ); +} + +#[tokio::test] +async fn hosted_converges_even_when_the_patch_server_is_not_configured() { + // Without --patch-server-url discovery does not recognize the test + // host's pins; the rollout still treats a lockfile that names the + // selected patch as recorded, so it never re-spends a slot on it. + let mock = MockServer::start().await; + mount_api(&mock, |_| Grant::Granted).await; + let tmp = tempfile::tempdir().unwrap(); + let names9: Vec<&str> = PACKAGES.iter().map(|(n, _)| *n).collect(); + write_project(tmp.path(), &names9); + for run_no in 1..=3 { + run_json( + tmp.path(), + &mock, + &["--mode", "hosted", "--max-new-patches", "3"], + ); + assert_eq!(pinned(tmp.path()), names(&ORDER[..3 * run_no])); + } + let v = run_json( + tmp.path(), + &mock, + &["--mode", "hosted", "--max-new-patches", "3"], + ); + assert_eq!(counts(&v), (0, 0, 0, 9)); +} + +#[tokio::test] +async fn hosted_ineligible_top_ranked_patches_hold_no_slot() { + let mock = MockServer::start().await; + mount_api(&mock, |name| match name { + "roll-e" => Grant::Withdrawn, + "roll-b" => Grant::BadPurl, + _ => Grant::Granted, + }) + .await; + let tmp = tempfile::tempdir().unwrap(); + let names9: Vec<&str> = PACKAGES.iter().map(|(n, _)| *n).collect(); + write_project(tmp.path(), &names9); + let v = run_json( + tmp.path(), + &mock, + &[ + "--mode", + "hosted", + "--max-new-patches", + "2", + "--patch-server-url", + HOST, + ], + ); + assert_eq!(pinned(tmp.path()), names(&["roll-g", "roll-c"]), "{v}"); + assert_eq!(counts(&v), (2, 5, 0, 0)); + assert_eq!( + deferred_names(&v), + names(&["roll-d", "roll-h", "roll-a", "roll-i", "roll-f"]) + ); + assert_eq!( + v["rollout"]["deferred"][0]["rank"], 3, + "ranks count eligible rows only" + ); + let reasons: Vec<(&str, &str)> = v["redirect"]["skipped"] + .as_array() + .unwrap() + .iter() + .map(|s| (s["purl"].as_str().unwrap(), s["reason"].as_str().unwrap())) + .collect(); + assert!( + reasons.contains(&("pkg:npm/roll-e@1.0.0", "withdrawn")), + "{reasons:?}" + ); + assert!(reasons.contains(&("garbage", "bad_purl")), "{reasons:?}"); +} + +#[tokio::test] +async fn a_failed_detail_lookup_admits_nothing_new_under_a_cap() { + let mock = MockServer::start().await; + // Mounted first, so it wins over the per-package mock below. + Mock::given(method("GET")) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.*roll-i(%40|@)1\\.0\\.0$" + ))) + .respond_with(ResponseTemplate::new(500)) + .mount(&mock) + .await; + mount_api(&mock, |_| Grant::Granted).await; + let tmp = tempfile::tempdir().unwrap(); + let names9: Vec<&str> = PACKAGES.iter().map(|(n, _)| *n).collect(); + write_project(tmp.path(), &names9); + let lock_before = std::fs::read(tmp.path().join("package-lock.json")).unwrap(); + let v = run_json( + tmp.path(), + &mock, + &[ + "--mode", + "hosted", + "--max-new-patches", + "3", + "--patch-server-url", + HOST, + ], + ); + assert_eq!(counts(&v), (0, 8, 0, 0), "{v}"); + assert_eq!( + std::fs::read(tmp.path().join("package-lock.json")).unwrap(), + lock_before + ); + let codes: Vec<&str> = v["warnings"] + .as_array() + .unwrap() + .iter() + .map(|w| w["code"].as_str().unwrap()) + .collect(); + assert!(codes.contains(&"rollout_incomplete_lookup"), "{codes:?}"); + assert!(codes.contains(&"patch_details_failed"), "{codes:?}"); +} + +#[tokio::test] +async fn agent_cap_rolls_forward_and_upgrades_ignore_the_cap() { + let mock = MockServer::start().await; + mount_api(&mock, |_| Grant::Granted).await; + let tmp = tempfile::tempdir().unwrap(); + let names9: Vec<&str> = PACKAGES.iter().map(|(n, _)| *n).collect(); + write_project(tmp.path(), &names9); + let recorded = |root: &Path| -> Vec { + let m: Value = serde_json::from_slice( + &std::fs::read(root.join(".socket/manifest.json")).unwrap_or_else(|_| b"{}".to_vec()), + ) + .unwrap(); + ORDER + .iter() + .filter(|n| m["patches"].get(purl(n)).is_some()) + .map(|n| n.to_string()) + .collect() + }; + let args = ["--mode", "agent", "--max-new-patches", "3"]; + let dry = { + let mut a = args.to_vec(); + a.push("--dry-run"); + run_json(tmp.path(), &mock, &a) + }; + for run_no in 1..=3 { + let v = run_json(tmp.path(), &mock, &args); + if run_no == 1 { + assert_eq!(dry["rollout"], v["rollout"], "dry run == wet run"); + let added: Vec<&str> = dry["apply"]["patches"] + .as_array() + .unwrap() + .iter() + .map(|p| p["purl"].as_str().unwrap()) + .collect(); + assert_eq!(added.len(), 3, "{dry}"); + } + assert_eq!(recorded(tmp.path()), names(&ORDER[..3 * run_no]), "{v}"); + for name in &ORDER[..3 * run_no] { + assert_eq!( + std::fs::read(tmp.path().join("node_modules").join(name).join("index.js")).unwrap(), + after(name) + ); + } + for name in &ORDER[3 * run_no..] { + assert_eq!( + std::fs::read(tmp.path().join("node_modules").join(name).join("index.js")).unwrap(), + before(name), + "a deferred package is not touched" + ); + } + } + let v = run_json(tmp.path(), &mock, &args); + assert_eq!(counts(&v), (0, 0, 0, 9)); + + // A newer patch for an applied package is an UPGRADE: it lands even + // with `--max-new-patches 0`, and is reported in `updates[]`. + mock.reset().await; + mount_api(&mock, |_| Grant::Granted).await; + let newer = "0000000a-1111-4111-8111-00000000000a"; + Mock::given(method("GET")) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.*roll-a(%40|@)1\\.0\\.0$" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "patches": [ + { "uuid": newer, "purl": purl("roll-a"), "publishedAt": "2026-06-01T00:00:00Z", + "description": "newer", "license": "MIT", "tier": "free", + "vulnerabilities": vulns("roll-a", &["low"]) }, + { "uuid": uuid("roll-a"), "purl": purl("roll-a"), "publishedAt": "2026-01-01T00:00:00Z", + "description": "roll-a", "license": "MIT", "tier": "free", + "vulnerabilities": vulns("roll-a", &["low"]) } + ], + "canAccessPaidPatches": false, + }))) + .with_priority(1) + .mount(&mock) + .await; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{newer}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "uuid": newer, "purl": purl("roll-a"), "publishedAt": "2026-06-01T00:00:00Z", + "files": { "package/index.js": { + "beforeHash": git_sha256(&before("roll-a")), + "afterHash": git_sha256(&after("roll-a")), + "blobContent": b64(&after("roll-a")), + }}, + "vulnerabilities": vulns("roll-a", &["low"]), + "description": "newer", "license": "MIT", "tier": "free", + }))) + .mount(&mock) + .await; + let v = run_json( + tmp.path(), + &mock, + &["--mode", "agent", "--max-new-patches", "0"], + ); + assert_eq!(counts(&v), (0, 0, 1, 8), "{v}"); + assert_eq!( + v["updates"], + json!([{ "purl": purl("roll-a"), "oldUuid": uuid("roll-a"), "newUuid": newer }]) + ); + let m: Value = + serde_json::from_slice(&std::fs::read(tmp.path().join(".socket/manifest.json")).unwrap()) + .unwrap(); + assert_eq!(m["patches"][purl("roll-a")]["uuid"], newer); +} + +#[tokio::test] +async fn vendored_cap_rolls_forward_three_per_run() { + let mock = MockServer::start().await; + mount_api(&mock, |_| Grant::Granted).await; + let tmp = tempfile::tempdir().unwrap(); + let names9: Vec<&str> = PACKAGES.iter().map(|(n, _)| *n).collect(); + write_project(tmp.path(), &names9); + let vendored = |root: &Path| -> Vec { + let state: Value = std::fs::read(root.join(".socket/vendor/state.json")) + .ok() + .map(|b| serde_json::from_slice(&b).unwrap()) + .unwrap_or(json!({})); + ORDER + .iter() + .filter(|n| state["entries"].get(purl(n)).is_some()) + .map(|n| n.to_string()) + .collect() + }; + let args = ["--mode", "vendored", "--max-new-patches", "3"]; + let dry = { + let mut a = args.to_vec(); + a.push("--dry-run"); + run_json(tmp.path(), &mock, &a) + }; + assert!( + !tmp.path().join(".socket").exists(), + "a dry run writes nothing" + ); + for run_no in 1..=3 { + let v = run_json(tmp.path(), &mock, &args); + if run_no == 1 { + assert_eq!(dry["rollout"], v["rollout"], "dry run == wet run"); + assert_eq!(dry["vendor"]["patches"].as_array().unwrap().len(), 3); + } + assert_eq!(vendored(tmp.path()), names(&ORDER[..3 * run_no]), "{v}"); + } + let lock = std::fs::read(tmp.path().join("package-lock.json")).unwrap(); + let v = run_json(tmp.path(), &mock, &args); + assert_eq!(counts(&v), (0, 0, 0, 9)); + assert_eq!( + std::fs::read(tmp.path().join("package-lock.json")).unwrap(), + lock + ); +} + +#[tokio::test] +async fn project_directories_share_one_budget_in_sorted_order() { + let mock = MockServer::start().await; + mount_api(&mock, |_| Grant::Granted).await; + let tmp = tempfile::tempdir().unwrap(); + // `a/` sorts first and spends two slots; `b/` gets the one left, and + // its copy of roll-b rides free (already admitted in `a/`). + write_project(&tmp.path().join("a"), &["roll-a", "roll-b"]); + write_project( + &tmp.path().join("b"), + &["roll-b", "roll-c", "roll-e", "roll-h"], + ); + let (code, stdout, stderr) = run( + tmp.path(), + &mock, + &[ + "--mode", + "hosted", + "--max-new-patches", + "3", + "--patch-server-url", + HOST, + "a", + "b", + ], + ); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + assert_eq!(pinned(&tmp.path().join("a")), names(&["roll-b", "roll-a"])); + assert_eq!(pinned(&tmp.path().join("b")), names(&["roll-e", "roll-b"])); + assert!( + stdout.contains( + "Rollout: 2 of 2 new patches applied (maxNewPatches=3 from --max-new-patches)" + ), + "{stdout}" + ); + assert!( + stdout.contains("Rollout: 2 of 4 new patches applied"), + "b/ admits roll-b free and roll-e with the last slot: {stdout}" + ); + assert!( + stdout.contains("Next up: roll-c@1.0.0 (high), roll-h@1.0.0 (medium)"), + "{stdout}" + ); +} + +#[tokio::test] +async fn a_malformed_env_cap_is_a_usage_error_and_the_flag_wins() { + let mock = MockServer::start().await; + mount_api(&mock, |_| Grant::Granted).await; + let tmp = tempfile::tempdir().unwrap(); + write_project(tmp.path(), &["roll-a", "roll-b"]); + let mut cmd = Command::new(binary()); + cmd.args(["scan", "--json", "--api-url"]) + .arg(mock.uri()) + .args(["--api-token", "t", "--org", ORG]) + .current_dir(tmp.path()); + for (key, _) in std::env::vars_os() { + if key.to_string_lossy().starts_with("SOCKET_") { + cmd.env_remove(&key); + } + } + cmd.env("SOCKET_TELEMETRY_DISABLED", "1") + .env("SOCKET_MAX_NEW_PATCHES", "lots"); + let out = cmd.output().unwrap(); + assert_eq!(out.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&out.stderr).contains("SOCKET_MAX_NEW_PATCHES")); + + cmd.env("SOCKET_MAX_NEW_PATCHES", "1"); + let out = cmd.output().unwrap(); + let v: Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!( + v["rollout"]["maxNewPatches"], + json!({ "value": 1, "source": "env" }) + ); + assert_eq!(v["rollout"]["counts"]["new"], 1); + + cmd.args(["--max-new-patches", "none"]); + let out = cmd.output().unwrap(); + let v: Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!( + v["rollout"]["maxNewPatches"], + json!({ "value": null, "source": "flag" }) + ); +} From f5db6be03d0523b848479953df1488c7e7cf74a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 14:16:02 +0000 Subject: [PATCH 09/22] Honor socket.yml patch policy in disk scans scan now reads the repo root's socket.yml before any write and applies its patches block in hosted, vendored and agent mode, --dry-run included: path filters on project roots (PATH-glob matches also get the built-in test/fixture ignores), ecosystem and package filters on crawled packages, and a severity floor on the patches a package may receive. An invalid or ambiguous file fails the run with exit 1 and an errorCode before any request. Packages that already carry a patch are never removed, upgraded or replaced by the policy: they are held and reported under policy.retained. A recorded patch below a new floor stays in place. patches.enabled: false reports what would be patched and writes nothing. New flags: --no-socket-yml / SOCKET_NO_SOCKET_YML and --min-severity / SOCKET_MIN_SEVERITY. Every successful scan --json result gains a top-level policy block. A PATH outside the repository root is a usage error. Claude-Session: https://claude.ai/code/session_01BKsyzefGhAnPkYmXCwq3H3 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/commands/scan/hosted.rs | 7 +- .../socket-patch-cli/src/commands/scan/mod.rs | 293 ++++--- .../src/commands/scan/policy.rs | 482 ++++++++++ .../src/commands/scan/socket_yml_args.rs | 58 ++ .../src/commands/scan/vendor_flow.rs | 9 +- .../socket-patch-cli/tests/cli_parse_scan.rs | 12 + .../tests/e2e_socket_yml_policy.rs | 821 ++++++++++++++++++ .../tests/in_process_cargo_apply.rs | 3 + .../tests/in_process_gem_apply.rs | 2 + .../tests/in_process_gem_multi_platform.rs | 1 + .../tests/in_process_pypi_apply.rs | 4 + .../tests/in_process_pypi_multi_release.rs | 1 + .../tests/in_process_python_envs.rs | 1 + .../tests/in_process_redirect.rs | 1 + .../tests/in_process_redirect_pdm.rs | 1 + .../tests/in_process_redirect_pipenv.rs | 1 + .../tests/in_process_redirect_pnpm.rs | 1 + .../tests/in_process_redirect_poetry.rs | 1 + .../in_process_remote_ecosystems_apply.rs | 1 + .../tests/in_process_rollback_hosted.rs | 1 + .../socket-patch-cli/tests/in_process_scan.rs | 1 + .../tests/in_process_vendor.rs | 1 + 22 files changed, 1593 insertions(+), 110 deletions(-) create mode 100644 crates/socket-patch-cli/src/commands/scan/policy.rs create mode 100644 crates/socket-patch-cli/src/commands/scan/socket_yml_args.rs create mode 100644 crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 6231254a..d8426e9f 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -1046,6 +1046,7 @@ pub(super) async fn run_redirect( api_client: &socket_patch_core::api::client::ApiClient, all_packages_with_patches: &[BatchPackagePatches], can_access_paid_patches: bool, + policy: &super::policy::ScanPolicy, // The classic scan object `run` builds for the `--json` path (`Some` in // JSON mode, `None` for human output). The redirect result is NESTED into // it so the hosted `--json` envelope stays schema-consistent with every @@ -1060,11 +1061,11 @@ pub(super) async fn run_redirect( npm_prior: Option<&crate::ecosystem_dispatch::NpmCrawlSnapshot>, ) -> i32 { // Same discovery/selection as `--apply`/`--vendor`. - let selected = match discover_selected( + let selected: Vec = match discover_selected( api_client, all_packages_with_patches, can_access_paid_patches, - &args.common, + policy, false, false, telemetry, @@ -1072,7 +1073,7 @@ pub(super) async fn run_redirect( ) .await { - Ok(s) => s, + Ok(offers) => offers.selected.into_values().collect(), // Hosted mode has no discovery envelope to fold the message into at // this point (it builds its `redirect` result further down). // `discover_selected` already printed the message to stderr; a diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 3bc5211b..aecfacda 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -33,11 +33,16 @@ use crate::commands::vex::{generate_vex_from_manifest_path, VexEmbedArgs}; use crate::ecosystem_dispatch::{crawl_ecosystems, crawl_ecosystems_with_npm}; use crate::ui::{self, plural, print_json, StatusLine}; -use super::get::{download_and_apply_patches_with, select_patches, DownloadParams, DownloadRun}; +use super::get::{download_and_apply_patches_with, DownloadParams, DownloadRun}; + +pub use self::socket_yml_args::{SocketYmlArgs, MIN_SEVERITY_ENV}; +use self::policy::{load_invocation_policy, InvocationPolicy, PolicyLoadError, ScanPolicy}; mod discovery; mod gc; pub(crate) mod hosted; +pub(crate) mod policy; +mod socket_yml_args; pub(crate) mod render; pub(crate) mod vendor_flow; @@ -372,6 +377,9 @@ pub struct ScanArgs { /// VEX makes the command exit non-zero. #[command(flatten)] pub vex: VexEmbedArgs, + + #[command(flatten)] + pub socket_yml: SocketYmlArgs, } pub(crate) use socket_patch_core::policy::package_spec_matches; @@ -504,9 +512,9 @@ async fn embed_vex_human( /// resolve the top-ranked accessible patch per PURL. Per-package search /// errors are skipped, but when EVERY query errors the empty set would be /// indistinguishable from a genuine "no patches" result, so that surfaces -/// as `Err(1)` with the failure on stderr. Selects with [`selection_args`]: -/// scan never prompts, so every run auto-selects the top-ranked patch (see -/// `api::ranking`) rather than erroring with `selection_required`. `Err` +/// as `Err(1)` with the failure on stderr. Selects with +/// [`select_accessible`]: scan never prompts, so every run auto-selects the +/// top-ranked patch the policy admits (see `api::ranking`). `Err` /// carries the exit code AND the message, since JSON callers must fold it /// into their single envelope (CLI_CONTRACT.md). `show_progress` / `warn` /// are the human-only knobs of [`fetch_patch_details`] (JSON callers pass @@ -518,12 +526,12 @@ async fn discover_selected( api_client: &socket_patch_core::api::client::ApiClient, packages: &[BatchPackagePatches], can_access_paid_patches: bool, - common: &GlobalArgs, + policy: &ScanPolicy, show_progress: bool, warn: bool, telemetry: &mut PendingTelemetry, json_warnings: Option<&mut serde_json::Value>, -) -> Result, (i32, String)> { +) -> Result { let (all_search_results, failures) = fetch_patch_details(api_client, packages, show_progress, warn).await; // The scan event's send overlapped the detail fetches; every caller's @@ -543,6 +551,7 @@ async fn discover_selected( // Some queries failed, some succeeded: a `--json` run has no stderr // warning (`warn` is human-only), so each failed package becomes a // run-level `warnings[]` entry — never a silent drop from the envelope. + let offers = select_accessible(all_search_results, can_access_paid_patches, policy); if let Some(result) = json_warnings { for (purl, e) in &failures { push_scan_json_warning( @@ -551,37 +560,24 @@ async fn discover_selected( &format!("could not fetch details for {purl}: {e}"), ); } + policy.fold_into_json(result); } - if all_search_results.is_empty() { - return Ok(Vec::new()); - } - if common.json { - // Pre-filter to accessible patches so `select_patches` takes the - // top-ranked one per PURL. - let accessible: Vec = all_search_results - .into_iter() - .filter(|p| can_access_paid_patches || p.tier == "free") - .collect(); - return select_patches(&accessible, true, &selection_args(common)) - .map_err(|code| (code, "patch selection failed".to_string())); - } - select_patches( - &all_search_results, - can_access_paid_patches, - &selection_args(common), - ) - .map_err(|code| (code, "patch selection failed".to_string())) + Ok(offers) } -/// `common` for `select_patches`: scan never prompts, so it always takes -/// the top-ranked patch, and with `json` off it never gets -/// `selection_required` (scan has no "re-run with the chosen UUID" path). -fn selection_args(common: &GlobalArgs) -> GlobalArgs { - GlobalArgs { - json: false, - yes: true, - ..common.clone() - } +/// The tier filter, then the policy's per-package selection (see +/// [`ScanPolicy::select`]): scan never prompts, so every package gets its +/// top-ranked admitted patch (see `api::ranking`). +fn select_accessible( + all_search_results: Vec, + can_access_paid_patches: bool, + policy: &ScanPolicy, +) -> socket_patch_core::policy::Offers { + let accessible: Vec = all_search_results + .into_iter() + .filter(|p| can_access_paid_patches || p.tier == "free") + .collect(); + policy.select(accessible) } /// Print the blank stdout line that opens a paragraph, once: `opened` @@ -1221,41 +1217,54 @@ pub async fn run(args: ScanArgs) -> i32 { // delivered, as with an inline send). The flush here is the // backstop that keeps every event ahead of the process exit. let mut telemetry = PendingTelemetry::new(); - let code = Box::pin(run_scan(args, &mut telemetry)).await; + let code = Box::pin(run_scan(args, &mut telemetry, None, true)).await; telemetry.flush().await; code } /// The project directories a hosted or vendored scan's PATHs name: each /// PATH is a directory, or a glob matching directories, relative to -/// `--cwd`. Sorted and deduplicated. -fn project_dirs(cwd: &Path, paths: &[String]) -> Result, String> { - let mut dirs: Vec = Vec::new(); +/// `--cwd`. Sorted and deduplicated; the flag says whether the user named +/// the directory literally (explicit roots skip the built-in default path +/// ignores; glob matches are discovered roots). +fn project_dirs(cwd: &Path, paths: &[String]) -> Result, String> { + let mut dirs: Vec<(PathBuf, bool)> = Vec::new(); for raw in paths { let joined = cwd.join(raw); if raw.contains(['*', '?', '[']) { let pattern = joined.to_string_lossy().into_owned(); let matches = glob::glob(&pattern).map_err(|e| format!("invalid path pattern `{raw}`: {e}"))?; let before = dirs.len(); - dirs.extend(matches.filter_map(Result::ok).filter(|p| p.is_dir())); + dirs.extend( + matches + .filter_map(Result::ok) + .filter(|p| p.is_dir()) + .map(|p| (p, false)), + ); if dirs.len() == before { return Err(format!("`{raw}` matches no directory")); } } else if joined.is_dir() { - dirs.push(joined); + dirs.push((joined, true)); } else { return Err(format!("`{raw}` is not a directory")); } } - dirs.sort(); - dirs.dedup(); + // A directory both named and matched counts as named. + dirs.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1))); + dirs.dedup_by(|later, earlier| later.0 == earlier.0); Ok(dirs) } /// Run a hosted or vendored scan once per project directory its PATHs /// name, as if each were `--cwd`. The exit code is the worst of the runs. -/// `--json` takes one directory, so stdout stays one document. -async fn run_project_dirs(args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { +/// `--json` takes one directory, so stdout stays one document. Every +/// directory must be inside the repository root the policy was read from. +async fn run_project_dirs( + args: ScanArgs, + telemetry: &mut PendingTelemetry, + invocation: &InvocationPolicy, +) -> i32 { let dirs = match project_dirs(&args.common.cwd, &args.paths) { Ok(dirs) => dirs, Err(message) => { @@ -1263,6 +1272,20 @@ async fn run_project_dirs(args: ScanArgs, telemetry: &mut PendingTelemetry) -> i return 2; } }; + if !args.common.is_global() { + for (dir, _) in &dirs { + let resolved = std::fs::canonicalize(dir).unwrap_or_else(|_| dir.clone()); + if !resolved.starts_with(&invocation.repo_root) { + eprintln!( + "Error: `{}` is outside the repository root {}: scan one repository per \ + invocation", + dir.display(), + invocation.repo_root.display() + ); + return 2; + } + } + } if args.common.json && dirs.len() > 1 { eprintln!( "Error: --json takes one project directory ({} given); run one scan per directory", @@ -1271,7 +1294,7 @@ async fn run_project_dirs(args: ScanArgs, telemetry: &mut PendingTelemetry) -> i return 2; } let mut code = 0; - for dir in &dirs { + for (dir, explicit) in &dirs { if dirs.len() > 1 && !args.common.silent { let shown = dir.strip_prefix(&args.common.cwd).unwrap_or(dir); println!("\n== {} ==", shown.display()); @@ -1279,12 +1302,29 @@ async fn run_project_dirs(args: ScanArgs, telemetry: &mut PendingTelemetry) -> i let mut child = args.clone(); child.paths.clear(); child.common.cwd = dir.clone(); - code = code.max(Box::pin(run_scan(child, telemetry)).await); + code = code.max(Box::pin(run_scan(child, telemetry, Some(invocation), *explicit)).await); } code } -async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { +/// Print a policy file that cannot be honored (fail closed, exit 1). +fn report_policy_error(err: &socket_patch_core::policy::PolicyError, args: &ScanArgs) -> i32 { + if args.common.json { + print_json(&policy::policy_error_json(err, &args.paths)); + } else { + eprintln!("Error ({}): {err}", err.code()); + } + 1 +} + +/// `invocation` is the policy a PATH-list parent already loaded (`None` +/// loads it here); `explicit` says whether the user named this root. +async fn run_scan( + mut args: ScanArgs, + telemetry: &mut PendingTelemetry, + invocation: Option<&InvocationPolicy>, + explicit: bool, +) -> i32 { apply_env_toggles(&args.common); // Fold the legacy mode booleans into `args.mode` (see @@ -1296,14 +1336,39 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { return 2; } + // The repo's socket.yml policy, read once per invocation before any + // write (an invalid file fails the run closed). + let loaded; + let invocation = match invocation { + Some(invocation) => invocation, + None => match load_invocation_policy(&args) { + Ok(i) => { + loaded = i; + &loaded + } + Err(PolicyLoadError::Usage(message)) => { + eprintln!("Error: {message}"); + return 2; + } + Err(PolicyLoadError::Policy(err)) => return report_policy_error(&err, &args), + }, + }; + // Hosted and vendored modes rewire a project's lockfiles, so their // PATHs name project directories: one scan per directory. if matches!(args.mode, Some(ScanMode::Hosted) | Some(ScanMode::Vendored)) && !args.paths.is_empty() { - return Box::pin(run_project_dirs(args, telemetry)).await; + return Box::pin(run_project_dirs(args, telemetry, invocation)).await; } + let mut policy = Box::new(ScanPolicy::for_root( + invocation, + &args.common.cwd, + explicit, + args.common.is_global(), + )); + // Positional PATH globs (see `ScanArgs::paths`). An unparseable glob // is a usage error, same exit-2 shape as the mode conflicts. let path_scope = match crate::path_scope::PathScope::parse(&args.paths) { @@ -1334,7 +1399,8 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { let apply = args.mode == Some(ScanMode::Agent); let vendor = args.mode == Some(ScanMode::Vendored); let hosted = args.mode == Some(ScanMode::Hosted); - let prune = args.prune || args.sync; + // `patches.enabled: false` writes nothing, the GC included. + let prune = (args.prune || args.sync) && policy.writes_allowed(); // Hosted mode runs no GC: say so once up front on the human path. The // `--json` path carries it in `redirect.warnings[]`. @@ -1451,6 +1517,34 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { .map(VendorState::purl_keys) .unwrap_or_default(); + // Read existing manifest once for update detection. + let existing_manifest = read_manifest(&manifest_path).await.ok().flatten(); + // Hosted mode records its patches ONLY in the lockfiles (v5 keeps no + // hosted ledger) and vendored mode ONLY in its ledger, so the hosted + // pins and the vendor ledger's purl→uuid records are folded into update + // detection (otherwise their `updates[]` would stay empty). The same + // merged view is the policy's recorded state (the retained set). + let hosted_pin_list: Vec = + if args.common.is_global() { + Vec::new() + } else { + socket_patch_core::patch::redirect::upstream::HostedPin::all( + &crate::commands::discover_wiring(&args.common, &args.common.cwd).await, + ) + }; + let redirect_state = (!args.common.is_global()) + .then(|| crate::commands::hosted_state_from_pins(&hosted_pin_list)); + let hosted_pins: Vec<(String, String)> = hosted_pin_list + .iter() + .map(|pin| (pin.purl.clone(), pin.uuid.clone())) + .collect(); + let update_manifest = merge_ledger_records_for_updates( + existing_manifest.as_ref(), + vendor_state.as_ref().ok(), + &hosted_pins, + ); + policy.set_recorded(update_manifest.as_deref()); + // Filter by --ecosystems if provided let filtered_crawled: Vec<_> = all_crawled .into_iter() @@ -1513,6 +1607,13 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { .collect() }; + // The socket.yml root/ecosystem/package filters, after the flags + // (which only narrow further) and after the prune-universe capture. + let filtered_crawled: Vec<_> = filtered_crawled + .into_iter() + .filter(|pkg| policy.admit_crawled(&pkg.purl)) + .collect(); + let all_purls: Vec = filtered_crawled.iter().map(|p| p.purl.clone()).collect(); let package_count = all_purls.len(); @@ -1522,6 +1623,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { for (code, detail) in &layout_refusals { eprintln!("Warning ({code}): {detail}"); } + policy.print_warnings(args.common.silent); // Hosted mode already printed its own prune-ignored warning. if prune && !hosted { eprintln!("{}", render::PRUNE_SKIPPED_EMPTY); @@ -1568,6 +1670,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { if !layout_refusals.is_empty() { result["warnings"] = layout_refusal_json(&layout_refusals); } + policy.fold_into_json(&mut result); // Hosted mode: a no-op `redirect` block keeps the envelope // schema-consistent with the ≥1-package path. if hosted { @@ -1610,6 +1713,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { &args.paths, ) ); + policy.print_human(args.common.silent, args.common.verbose); } return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; } @@ -1647,6 +1751,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { for (code, detail) in &layout_refusals { eprintln!("Warning ({code}): {detail}"); } + policy.print_warnings(args.common.silent); } // Query API in batches @@ -1848,32 +1953,8 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { telemetry_org.as_deref(), ); - // Read existing manifest once for update detection. - let existing_manifest = read_manifest(&manifest_path).await.ok().flatten(); - // Hosted mode records its patches ONLY in the lockfiles (v5 keeps no - // hosted ledger) and vendored mode ONLY in its ledger, so the hosted - // pins and the vendor ledger's purl→uuid records are folded into update - // detection (otherwise their `updates[]` would stay empty). - let hosted_pin_list: Vec = - if args.common.is_global() { - Vec::new() - } else { - socket_patch_core::patch::redirect::upstream::HostedPin::all( - &crate::commands::discover_wiring(&args.common, &args.common.cwd).await, - ) - }; - let redirect_state = (!args.common.is_global()) - .then(|| crate::commands::hosted_state_from_pins(&hosted_pin_list)); - let hosted_pins: Vec<(String, String)> = hosted_pin_list - .iter() - .map(|pin| (pin.purl.clone(), pin.uuid.clone())) - .collect(); - let update_manifest = merge_ledger_records_for_updates( - existing_manifest.as_ref(), - vendor_state.as_ref().ok(), - &hosted_pins, - ); let updates = detect_updates(update_manifest.as_deref(), &all_packages_with_patches); + policy.set_update_purls(updates.iter().map(|u| u.purl.as_str())); // The hosted-wiring probes below take `all_purls` (POST-filter: only // packages this run covered), unlike the PRE-filter `scanned_purls` @@ -1908,6 +1989,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { let detail = line.strip_prefix("Warning: ").unwrap_or(&line); push_scan_json_warning(&mut result, API_BATCH_FAILED, detail); } + policy.fold_into_json(&mut result); // Flag lockfile-only packages (additive; absent means installed). // `normalize_purl` bridges the API's percent-encoded spelling to the // supplement's literal form. @@ -1932,6 +2014,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { &api_client, &all_packages_with_patches, can_access_paid_patches, + &policy, Some(result), telemetry, npm_crawl.as_ref(), @@ -1960,11 +2043,11 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { // --- Apply path (if requested) ----------------------------------- if apply { - let selected = match discover_selected( + let selected: Vec = match discover_selected( &api_client, &all_packages_with_patches, can_access_paid_patches, - &args.common, + &policy, false, false, telemetry, @@ -1972,7 +2055,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { ) .await { - Ok(s) => s, + Ok(offers) => offers.selected.into_values().collect(), Err((code, message)) => { emit_discovery_error_json(&mut result, &message); return code; @@ -2084,6 +2167,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { use_public_proxy, &all_packages_with_patches, can_access_paid_patches, + &policy, &mut result, &manifest_path, &socket_dir, @@ -2140,7 +2224,9 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { // the embedded VEX. An early "nothing to apply" exit still runs the GC. let (args_ref, manifest_ref, socket_ref) = (&args, &manifest_path, &socket_dir); let (scanned_ref, vendored_ref) = (&scanned_purls, &vendored_purls); + let policy_ref: &ScanPolicy = &policy; let finish_human = move |code: i32| async move { + policy_ref.print_human(silent, verbose); if prune && !vendor && !hosted && code == 0 { gc::run_human_gc( &args_ref.common, @@ -2310,11 +2396,11 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { // engine (the same entry as `get --mode hosted`) — it must NOT fall // through to the apply/vendor branches. if hosted { - let selected = match discover_selected( + let selected: Vec = match discover_selected( &api_client, &all_packages_with_patches, can_access_paid_patches, - &args.common, + &policy, human, !silent, telemetry, @@ -2322,12 +2408,13 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { ) .await { - Ok(s) => s, + Ok(offers) => offers.selected.into_values().collect(), // `discover_selected` already printed the failure to stderr. Err((code, _)) => { return code; } }; + policy.print_human(silent, verbose); let pairs: Vec<(String, String)> = selected .iter() .map(|s| (s.purl.clone(), s.uuid.clone())) @@ -2359,14 +2446,13 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { // `resolve_mode_flags`) only reports, plus the `--prune` GC. let report_only = args.mode.is_none(); - // Scan always takes the top-ranked patch (see `selection_args`). - let mut select_common = selection_args(&args.common); - select_common.silent |= report_only; + // Scan always takes the top-ranked patch the policy admits. let selected: Vec = - match select_patches(&all_search_results, can_access_paid_patches, &select_common) { - Ok(s) => s, - Err(code) => return code, - }; + select_accessible(all_search_results, can_access_paid_patches, &policy) + .selected + .into_values() + .collect(); + policy.print_human(silent, verbose); // The skip / already-recorded lines below open their own paragraph // under the table's Summary: one blank line before the first of them. @@ -2628,14 +2714,27 @@ mod tests { std::fs::create_dir_all(tmp.path().join(d)).unwrap(); } std::fs::write(tmp.path().join("apps/README"), "").unwrap(); - let rel = |dirs: Vec| -> Vec { + let rel = |dirs: Vec<(PathBuf, bool)>| -> Vec<(String, bool)> { dirs.iter() - .map(|d| d.strip_prefix(tmp.path()).unwrap().to_string_lossy().replace('\\', "/")) + .map(|(d, explicit)| { + ( + d.strip_prefix(tmp.path()).unwrap().to_string_lossy().replace('\\', "/"), + *explicit, + ) + }) .collect() }; let got = project_dirs(tmp.path(), &["apps/*".into(), "libs/core".into(), "apps/web".into()]) .unwrap(); - assert_eq!(rel(got), ["apps/api", "apps/web", "libs/core"]); + // Named literally = explicit (also when a glob matches it too). + assert_eq!( + rel(got), + [ + ("apps/api".to_string(), false), + ("apps/web".to_string(), true), + ("libs/core".to_string(), true) + ] + ); assert!(project_dirs(tmp.path(), &["apps/README".into()]) .unwrap_err() .contains("is not a directory")); @@ -3004,20 +3103,6 @@ mod tests { ); } - #[test] - fn selection_args_never_prompts() { - for common in [ - GlobalArgs::default(), - GlobalArgs { - json: true, - ..GlobalArgs::default() - }, - ] { - let picked = selection_args(&common); - assert!(!picked.json && picked.yes, "scan always takes the top patch"); - } - } - #[test] fn takeover_detail_names_package_and_remediation() { let purls = vec!["pkg:npm/minimist@1.2.2".to_string()]; diff --git a/crates/socket-patch-cli/src/commands/scan/policy.rs b/crates/socket-patch-cli/src/commands/scan/policy.rs new file mode 100644 index 00000000..32f99d0c --- /dev/null +++ b/crates/socket-patch-cli/src/commands/scan/policy.rs @@ -0,0 +1,482 @@ +//! Disk `scan`'s side of the socket.yml patch policy: loading it once per +//! invocation, the per-root and per-package filters, the severity floor in +//! selection, the retained set, and the `policy` JSON block / human line. +//! The policy itself lives in `socket_patch_core::policy`. + +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use socket_patch_core::api::ranking::cmp_search_results; +use socket_patch_core::api::types::PatchSearchResult; +use socket_patch_core::manifest::schema::PatchManifest; +use socket_patch_core::policy::{ + find_repo_root_with_warnings, patch_severity_order, repo_relative_checked, sanitize, severity_name, + DiskPolicyFs, FilterReason, Offers, PolicyError, PolicySource, PolicyWarning, Root, SelectionPolicy, + PATCHES_DISABLED, +}; +use socket_patch_core::utils::purl::{normalize_purl, strip_purl_qualifiers}; + +use super::ScanArgs; +use crate::hosted_memory::roots::{marker_ecosystem, UNSUPPORTED_MARKERS}; + +/// Why the policy could not be loaded. +pub(crate) enum PolicyLoadError { + /// A malformed flag or env value: exit 2. + Usage(String), + /// A policy file that cannot be honored: exit 1, `errorCode`. + Policy(PolicyError), +} + +/// The canonical spelling filters and the recorded view compare on. +pub(crate) fn canon(purl: &str) -> String { + normalize_purl(strip_purl_qualifiers(purl)).into_owned() +} + +/// The invocation's policy: loaded once, shared by every project +/// directory a PATH list names. +pub(crate) struct InvocationPolicy { + pub policy: SelectionPolicy, + pub repo_root: PathBuf, + pub warnings: Vec, +} + +/// Load the policy for `args` (4.5): `--global` scans have no repo and read +/// no file; everything else reads the repo root's socket.yml. +pub(crate) fn load_invocation_policy(args: &ScanArgs) -> Result { + let overrides = args.socket_yml.overrides().map_err(PolicyLoadError::Usage)?; + let cwd = std::fs::canonicalize(&args.common.cwd).unwrap_or_else(|_| args.common.cwd.clone()); + if args.common.is_global() { + let policy = SelectionPolicy::load(&socket_patch_core::policy::MemoryPolicyFs::default(), &overrides) + .map_err(PolicyLoadError::Policy)? + .0; + return Ok(InvocationPolicy { + policy, + repo_root: cwd, + warnings: Vec::new(), + }); + } + let (repo_root, mut warnings) = find_repo_root_with_warnings(&cwd); + let (policy, load_warnings) = + SelectionPolicy::load(&DiskPolicyFs::new(&repo_root), &overrides).map_err(PolicyLoadError::Policy)?; + warnings.extend(load_warnings); + Ok(InvocationPolicy { + policy, + repo_root, + warnings, + }) +} + +/// The marker files of a disk project root: the same lock markers the +/// in-memory engine detects roots by, plus the maven/nuget markers disk +/// scans support. Manifests are not markers (so both engines agree). +pub(crate) fn dir_markers(dir: &Path) -> Vec { + let mut markers: Vec = std::fs::read_dir(dir) + .map(|entries| { + entries + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_ok_and(|t| t.is_file() || t.is_symlink())) + .filter_map(|e| e.file_name().into_string().ok()) + .filter(|name| { + marker_ecosystem(name).is_some() + || UNSUPPORTED_MARKERS + .iter() + .any(|(_, names)| names.contains(&name.as_str())) + }) + .collect() + }) + .unwrap_or_default(); + markers.sort(); + markers +} + +#[derive(Debug, Clone)] +struct FilteredEntry { + purl: Option, + uuid: Option, + reason: FilterReason, + severity: Option, +} + +#[derive(Debug, Clone)] +struct RetainedEntry { + purl: String, + recorded_uuid: String, + reason: FilterReason, +} + +#[derive(Default)] +struct Report { + filtered: Vec, + retained: Vec, + retained_purls: BTreeSet, + filtered_purls: HashSet, + update_purls: HashSet, + human_printed: bool, +} + +/// One project root's view of the invocation policy (disk scans run one +/// root per `run_scan`). +pub(crate) struct ScanPolicy { + pub policy: SelectionPolicy, + pub warnings: Vec, + /// Repo-relative root directory (`""` for the repo root). + pub project: String, + /// The root filter's verdict (`Ok` for global scans). + root_verdict: Result<(), FilterReason>, + /// Recorded patches of this root: canonical purl → uuid. + recorded: HashMap, + report: Mutex, +} + +impl ScanPolicy { + /// The policy for the project rooted at `root_dir`. + pub(crate) fn for_root(invocation: &InvocationPolicy, root_dir: &Path, explicit: bool, global: bool) -> Self { + let root_dir = std::fs::canonicalize(root_dir).unwrap_or_else(|_| root_dir.to_path_buf()); + let project = repo_relative_checked(&invocation.repo_root, &root_dir).unwrap_or_default(); + let root_verdict = if global { + Ok(()) + } else { + let markers = dir_markers(&root_dir); + invocation.policy.admits_root(&Root { + rel_dir: &project, + markers: &markers, + explicit, + }) + }; + let mut warnings = invocation.warnings.clone(); + if !invocation.policy.enabled() { + warnings.push(PolicyWarning { + code: PATCHES_DISABLED, + detail: "patches.enabled is false in socket.yml: report only, nothing is written \ + (existing patches stay in place; the --prune GC is skipped too)" + .to_string(), + }); + } + Self { + policy: invocation.policy.clone(), + warnings, + project, + root_verdict, + recorded: HashMap::new(), + report: Mutex::new(Report::default()), + } + } + + fn report(&self) -> std::sync::MutexGuard<'_, Report> { + self.report.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// Whether anything may be written this run. + pub(crate) fn writes_allowed(&self) -> bool { + self.policy.enabled() + } + + /// Set the merged recorded view (manifest > hosted pins > vendor ledger). + pub(crate) fn set_recorded(&mut self, merged: Option<&PatchManifest>) { + self.recorded = merged + .map(|m| { + m.patches + .iter() + .map(|(purl, record)| (canon(purl), record.uuid.clone())) + .collect() + }) + .unwrap_or_default(); + } + + fn recorded_uuid(&self, purl: &str) -> Option<&str> { + self.recorded.get(&canon(purl)).map(String::as_str) + } + + /// Step 3: the root, ecosystem and package filters. Returns whether the + /// package stays in the batch query. A recorded package the filters + /// exclude stays in the query (so `upgradeAvailable` can be reported) + /// but joins the retained set, which never reaches a writer. + pub(crate) fn admit_crawled(&self, purl: &str) -> bool { + let verdict = self.root_verdict.clone().and_then(|()| self.policy.admits_purl(purl)); + let reason = match verdict { + Ok(()) => return true, + Err(reason) => reason, + }; + let mut report = self.report(); + if let Some(uuid) = self.recorded_uuid(purl) { + let key = canon(purl); + if report.retained_purls.insert(key.clone()) { + report.retained.push(RetainedEntry { + purl: key, + recorded_uuid: uuid.to_string(), + reason, + }); + } + return true; + } + if self.root_verdict.is_err() { + if !report.filtered.iter().any(|f| f.purl.is_none()) { + report.filtered.push(FilteredEntry { + purl: None, + uuid: None, + reason, + severity: None, + }); + } + } else if report.filtered_purls.insert(canon(purl)) { + report.filtered.push(FilteredEntry { + purl: Some(canon(purl)), + uuid: None, + reason, + severity: None, + }); + } + false + } + + /// Record the purls with a newer patch (`updates[]`), for + /// `retained[].upgradeAvailable`. + pub(crate) fn set_update_purls<'a>(&self, purls: impl IntoIterator) { + self.report().update_purls = purls.into_iter().map(canon).collect(); + } + + /// Steps 5-6: group the tier-accessible offers, keep retained packages + /// out, apply the severity floor and pick one patch per package with + /// the canonical ranking. With no floor the result is exactly today's + /// top-ranked selection. The floor never moves a package off its + /// recorded patch unless an admitted patch outranks the recorded one, + /// and a recorded package with nothing above the floor keeps its patch. + pub(crate) fn select(&self, accessible: Vec) -> Offers { + let mut grouped: BTreeMap> = BTreeMap::new(); + { + let report = self.report(); + for offer in accessible { + if report.retained_purls.contains(&canon(&offer.purl)) { + continue; + } + grouped.entry(offer.purl.clone()).or_default().push(offer); + } + } + for group in grouped.values_mut() { + group.sort_by(cmp_search_results); + } + let mut offers = Offers { + unfiltered: BTreeMap::new(), + selected: BTreeMap::new(), + }; + let mut report = self.report(); + for (purl, group) in grouped { + let recorded = self.recorded_uuid(&purl).map(str::to_string); + if !self.policy.enabled() { + let reason = FilterReason::Disabled; + match recorded { + Some(uuid) => { + let key = canon(&purl); + if report.retained_purls.insert(key.clone()) { + report.retained.push(RetainedEntry { + purl: key, + recorded_uuid: uuid, + reason, + }); + } + } + None => report.filtered.push(FilteredEntry { + purl: Some(purl.clone()), + uuid: Some(group[0].uuid.clone()), + severity: Some(patch_severity_order(&group[0])), + reason, + }), + } + continue; + } + let floor_winner = group + .iter() + .position(|p| self.policy.admits_severity(patch_severity_order(p)).is_ok()); + let recorded_at = recorded + .as_deref() + .and_then(|uuid| group.iter().position(|p| p.uuid == uuid)); + let chosen = match (recorded.is_some(), floor_winner, recorded_at) { + // The recorded patch outranks every admitted one: keep it. + (true, Some(w), Some(r)) if r < w => Some(r), + (_, Some(w), _) => Some(w), + // Nothing above the floor: a recorded package keeps its patch. + (true, None, Some(r)) => Some(r), + (true, None, None) => None, + (false, None, _) => { + report.filtered.push(FilteredEntry { + purl: Some(purl.clone()), + uuid: Some(group[0].uuid.clone()), + severity: Some(patch_severity_order(&group[0])), + reason: self + .policy + .admits_severity(patch_severity_order(&group[0])) + .expect_err("no offer passed the floor"), + }); + None + } + }; + if let Some(i) = chosen { + offers.selected.insert(purl.clone(), group[i].clone()); + } + offers.unfiltered.insert(purl, group); + } + offers + } + + /// The top-level `policy` block (4.7). + pub(crate) fn json(&self) -> serde_json::Value { + let report = self.report(); + let (path, sha256) = match self.policy.source() { + PolicySource::File { path, sha256 } => (serde_json::json!(path), serde_json::json!(sha256)), + _ => (serde_json::Value::Null, serde_json::Value::Null), + }; + let (floor, floor_source) = self.policy.min_severity(); + let filtered: Vec = report + .filtered + .iter() + .map(|f| { + serde_json::json!({ + "purl": f.purl, + "uuid": f.uuid, + "project": self.project, + "reason": f.reason.code(), + "detail": f.reason.detail(), + }) + }) + .collect(); + let retained: Vec = report + .retained + .iter() + .map(|r| { + serde_json::json!({ + "purl": r.purl, + "project": self.project, + "recordedUuid": r.recorded_uuid, + "reason": r.reason.code(), + "detail": r.reason.detail(), + "upgradeAvailable": report.update_purls.contains(&r.purl), + }) + }) + .collect(); + serde_json::json!({ + "source": self.policy.source().as_str(), + "path": path, + "sha256": sha256, + "enabled": self.policy.enabled(), + "minSeverity": { + "value": floor.and_then(severity_name), + "source": floor_source.as_str(), + }, + "counts": { "filtered": filtered.len(), "retained": retained.len() }, + "filtered": filtered, + "retained": retained, + }) + } + + /// Put the `policy` block and the policy warnings on a scan `--json` + /// result (idempotent: the block is rebuilt, warnings added once). + pub(crate) fn fold_into_json(&self, result: &mut serde_json::Value) { + result["policy"] = self.json(); + let warnings = result + .as_object_mut() + .expect("scan JSON result is an object") + .entry("warnings") + .or_insert_with(|| serde_json::json!([])); + if let Some(arr) = warnings.as_array_mut() { + for w in &self.warnings { + let present = arr + .iter() + .any(|e| e["code"] == w.code && e["detail"] == w.detail.as_str()); + if !present { + arr.push(serde_json::json!({ "code": w.code, "detail": w.detail })); + } + } + if arr.is_empty() { + result.as_object_mut().map(|o| o.remove("warnings")); + } + } + } + + /// Print the policy warnings (stderr) once, human path. + pub(crate) fn print_warnings(&self, silent: bool) { + if silent { + return; + } + for w in &self.warnings { + eprintln!("Warning ({}): {}", w.code, w.detail); + } + } + + /// The human policy line (stdout), printed at most once per root: the + /// counts, then every filtered critical/high candidate by name (a + /// policy must not hide those silently), or every entry with + /// `--verbose`. + pub(crate) fn print_human(&self, silent: bool, verbose: bool) { + let mut report = self.report(); + if silent || std::mem::replace(&mut report.human_printed, true) { + return; + } + let filtered = report.filtered.len(); + let retained = report.retained.len(); + if filtered == 0 && retained == 0 && matches!(self.policy.source(), PolicySource::None) { + return; + } + let label = match self.policy.source() { + PolicySource::File { path, .. } => format!("Policy ({path})"), + PolicySource::Bypassed => "Policy (socket.yml ignored)".to_string(), + PolicySource::None => "Policy (built-in defaults)".to_string(), + }; + let mut line = format!( + "\n{label}: {} skipped by filters, {} held.", + filtered, + crate::ui::plural(retained, "patched package", "patched packages") + ); + if !self.policy.enabled() { + line.push_str(" Patching is disabled (patches.enabled: false)."); + } + println!("{line}"); + for f in &report.filtered { + let severe = f.severity.is_some_and(|s| s <= 1); + if !(verbose || severe) { + continue; + } + let what = match &f.purl { + Some(purl) => normalize_purl(purl).into_owned(), + None if self.project.is_empty() => "this project".to_string(), + None => format!("project {}", sanitize(&self.project)), + }; + let severity = f + .severity + .and_then(severity_name) + .map(|s| format!(" ({s})")) + .unwrap_or_default(); + println!(" skipped {what}{severity}: {}", f.reason.detail()); + } + if verbose { + for r in &report.retained { + println!( + " held {} at {}: {}", + normalize_purl(&r.purl), + r.recorded_uuid, + r.reason.detail() + ); + } + } + } +} + +/// The JSON error object for a policy file that cannot be honored: scan's +/// error shape plus `errorCode`. +pub(crate) fn policy_error_json(err: &PolicyError, paths: &[String]) -> serde_json::Value { + serde_json::json!({ + "status": "error", + "error": err.to_string(), + "errorCode": err.code(), + "scannedPackages": 0, + "lockfileOnlyPackages": 0, + "packagesWithPatches": 0, + "totalPatches": 0, + "freePatches": 0, + "paidPatches": 0, + "canAccessPaidPatches": false, + "packages": [], + "updates": [], + "paths": paths, + }) +} diff --git a/crates/socket-patch-cli/src/commands/scan/socket_yml_args.rs b/crates/socket-patch-cli/src/commands/scan/socket_yml_args.rs new file mode 100644 index 00000000..211167d0 --- /dev/null +++ b/crates/socket-patch-cli/src/commands/scan/socket_yml_args.rs @@ -0,0 +1,58 @@ +//! `scan`'s `socket.yml` patch-policy flags. + +use clap::Args; +use socket_patch_core::policy::{parse_min_severity, OverrideSource, PolicyOverrides}; + +/// Env binding of `--min-severity`, read by [`SocketYmlArgs::overrides`] +/// (not by clap) so the policy can say which layer set the floor. +pub const MIN_SEVERITY_ENV: &str = "SOCKET_MIN_SEVERITY"; + +#[derive(Args, Clone, Debug, Default)] +pub struct SocketYmlArgs { + /// Ignore the repository's socket.yml patch policy (its `patches` + /// block and `projectIgnorePaths`) for this run. The built-in + /// test/fixture directory ignores still apply + #[arg( + long = "no-socket-yml", + env = "SOCKET_NO_SOCKET_YML", + default_value_t = false, + value_parser = crate::args::parse_bool_flag, + )] + pub no_socket_yml: bool, + + /// Only patch packages whose patch fixes an advisory of at least this + /// severity: critical, high, medium (or moderate), low, or none for no + /// floor. Overrides `patches.minSeverity` in socket.yml. Patches of + /// unknown severity are skipped whenever a floor is set + /// [env: SOCKET_MIN_SEVERITY] + #[arg(long = "min-severity", value_name = "SEVERITY", value_parser = min_severity_value)] + pub min_severity: Option, +} + +fn min_severity_value(value: &str) -> Result { + parse_min_severity(value).map(|_| value.to_string()) +} + +impl SocketYmlArgs { + /// The trusted overrides: `--min-severity` beats `SOCKET_MIN_SEVERITY` + /// (an empty value is unset). `Err` is a usage error (exit 2). + pub fn overrides(&self) -> Result { + let min_severity = match self.min_severity.as_deref() { + Some(flag) => Some(( + parse_min_severity(flag).map_err(|e| format!("--min-severity: {e}"))?, + OverrideSource::Flag, + )), + None => match std::env::var(MIN_SEVERITY_ENV) { + Ok(value) if !value.trim().is_empty() => Some(( + parse_min_severity(&value).map_err(|e| format!("{MIN_SEVERITY_ENV}: {e}"))?, + OverrideSource::Env, + )), + _ => None, + }, + }; + Ok(PolicyOverrides { + bypass: self.no_socket_yml, + min_severity, + }) + } +} diff --git a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs index db4aa9bb..25b81acb 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -453,6 +453,7 @@ async fn run_vendor_json_path( use_public_proxy: bool, all_packages_with_patches: &[BatchPackagePatches], can_access_paid_patches: bool, + policy: &super::policy::ScanPolicy, result: &mut serde_json::Value, manifest_path: &Path, socket_dir: &Path, @@ -470,11 +471,11 @@ async fn run_vendor_json_path( // Same discovery as `--apply`. Vendored purls are NOT filtered here — // re-vendoring a stale uuid is the point of the flag (same-uuid re-runs // land on the backend's `already_vendored` skip). - let selected = match discover_selected( + let selected: Vec = match discover_selected( api_client, all_packages_with_patches, can_access_paid_patches, - &args.common, + policy, false, false, telemetry, @@ -482,7 +483,7 @@ async fn run_vendor_json_path( ) .await { - Ok(s) => s, + Ok(offers) => offers.selected.into_values().collect(), Err((code, message)) => { emit_discovery_error_json(result, &message); return code; @@ -781,6 +782,7 @@ pub(super) fn boxed_vendor_json_path<'a>( use_public_proxy: bool, all_packages_with_patches: &'a [BatchPackagePatches], can_access_paid_patches: bool, + policy: &'a super::policy::ScanPolicy, result: &'a mut serde_json::Value, manifest_path: &'a Path, socket_dir: &'a Path, @@ -798,6 +800,7 @@ pub(super) fn boxed_vendor_json_path<'a>( use_public_proxy, all_packages_with_patches, can_access_paid_patches, + policy, result, manifest_path, socket_dir, diff --git a/crates/socket-patch-cli/tests/cli_parse_scan.rs b/crates/socket-patch-cli/tests/cli_parse_scan.rs index 1ed6a66d..97aac035 100644 --- a/crates/socket-patch-cli/tests/cli_parse_scan.rs +++ b/crates/socket-patch-cli/tests/cli_parse_scan.rs @@ -527,6 +527,18 @@ fn scan_json_empty_cwd_emits_updates_key() { "warnings": [], "dryRun": false }, + // v5: the socket.yml patch policy block rides every successful + // scan (no file here: the built-in defaults). + "policy": { + "source": "none", + "path": null, + "sha256": null, + "enabled": true, + "minSeverity": { "value": null, "source": "default" }, + "counts": { "filtered": 0, "retained": 0 }, + "filtered": [], + "retained": [] + }, }); assert_eq!( v, diff --git a/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs b/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs new file mode 100644 index 00000000..56566739 --- /dev/null +++ b/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs @@ -0,0 +1,821 @@ +//! End-to-end tests for the socket.yml patch policy on disk scans: a +//! monorepo with several npm roots (plus a gem, for the ecosystem filter) +//! scanned through the real binary in hosted, agent and vendored mode +//! against a mock patch API that serves a small catalog. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use serde_json::{json, Value}; +use serial_test::serial; +use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; +use wiremock::matchers::{method, path, path_regex}; +use wiremock::{Mock, MockServer, Request, ResponseTemplate}; + +const ORG: &str = "test-org"; +const TOKEN_SEGMENT: &str = "55555555-5555-4555-8555-555555555555"; + +#[derive(Clone)] +struct Patch { + uuid: &'static str, + name: &'static str, + version: &'static str, + eco: &'static str, + severities: &'static [&'static str], + published: &'static str, +} + +impl Patch { + fn purl(&self) -> String { + format!("pkg:{}/{}@{}", self.eco, self.name, self.version) + } + + fn hosted_url(&self) -> String { + format!( + "http://patch.test/patch/npm/{n}/{v}/{TOKEN_SEGMENT}/{u}/{n}-{v}.tgz", + n = self.name, + v = self.version, + u = self.uuid + ) + } + + fn vulnerabilities(&self) -> Value { + let mut map = serde_json::Map::new(); + for (i, severity) in self.severities.iter().enumerate() { + map.insert( + format!("GHSA-{}-{i:04}", &self.uuid[..4]), + json!({"cves": [], "summary": "s", "severity": severity, "description": "d"}), + ); + } + Value::Object(map) + } + + fn batch_severity(&self) -> &'static str { + let rank = |s: &str| match s { + "critical" => 0, + "high" => 1, + "medium" => 2, + "low" => 3, + _ => 4, + }; + self.severities.iter().copied().min_by_key(|s| rank(s)).unwrap_or("unknown") + } +} + +const P_ALPHA: Patch = Patch { + uuid: "a1a1a1a1-0000-4000-8000-000000000001", + name: "alpha", + version: "1.0.0", + eco: "npm", + severities: &["critical"], + published: "2024-01-01T00:00:00Z", +}; +const P_BETA: Patch = Patch { + uuid: "b1b1b1b1-0000-4000-8000-000000000001", + name: "beta", + version: "1.0.0", + eco: "npm", + severities: &["low"], + published: "2024-01-01T00:00:00Z", +}; +const P_LEFTPAD: Patch = Patch { + uuid: "c1c1c1c1-0000-4000-8000-000000000001", + name: "left-pad", + version: "1.0.0", + eco: "npm", + severities: &["high"], + published: "2024-01-01T00:00:00Z", +}; +const P_GAMMA: Patch = Patch { + uuid: "d1d1d1d1-0000-4000-8000-000000000001", + name: "gamma", + version: "1.0.0", + eco: "npm", + severities: &["high"], + published: "2024-01-01T00:00:00Z", +}; +const P_DELTA: Patch = Patch { + uuid: "e1e1e1e1-0000-4000-8000-000000000001", + name: "delta", + version: "1.0.0", + eco: "npm", + severities: &["high"], + published: "2024-01-01T00:00:00Z", +}; +const P_RACK: Patch = Patch { + uuid: "f1f1f1f1-0000-4000-8000-000000000001", + name: "rack", + version: "1.0.0", + eco: "gem", + severities: &["high"], + published: "2024-01-01T00:00:00Z", +}; +/// A merged (two-advisory) low patch for alpha: ranks first while no floor +/// applies. +const P_ALPHA_MERGED_LOW: Patch = Patch { + uuid: "a2a2a2a2-0000-4000-8000-000000000002", + name: "alpha", + version: "1.0.0", + eco: "npm", + severities: &["low", "low"], + published: "2024-02-01T00:00:00Z", +}; +/// A newer merged patch for alpha (supersedes P_ALPHA). +const P_ALPHA_MERGED_NEW: Patch = Patch { + uuid: "a3a3a3a3-0000-4000-8000-000000000003", + name: "alpha", + version: "1.0.0", + eco: "npm", + severities: &["critical", "high"], + published: "2024-03-01T00:00:00Z", +}; + +fn catalog() -> Vec { + vec![P_ALPHA, P_BETA, P_LEFTPAD, P_GAMMA, P_DELTA, P_RACK] +} + +fn orig_index(name: &str) -> String { + format!("module.exports = () => '{name} orig';\n") +} + +fn patched_index(name: &str) -> String { + format!("module.exports = () => '{name} patched';\n") +} + +fn percent_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 3 <= bytes.len() { + if let Ok(b) = u8::from_str_radix(&s[i + 1..i + 3], 16) { + out.push(b); + i += 3; + continue; + } + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8(out).unwrap() +} + +/// Serve `patches` from every patches route scan uses. +async fn mount_api(server: &MockServer, patches: Vec) { + let by_purl = { + let mut m: BTreeMap> = BTreeMap::new(); + for p in &patches { + m.entry(p.purl()).or_default().push(p.clone()); + } + m + }; + let batch_map = by_purl.clone(); + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(move |req: &Request| { + let body: Value = serde_json::from_slice(&req.body).unwrap(); + let mut packages = Vec::new(); + for c in body["components"].as_array().unwrap() { + let purl = c["purl"].as_str().unwrap(); + if let Some(list) = batch_map.get(purl) { + let infos: Vec = list + .iter() + .map(|p| { + json!({ + "uuid": p.uuid, "purl": purl, "tier": "free", "cveIds": [], + "ghsaIds": p.vulnerabilities().as_object().unwrap().keys().collect::>(), + "severity": p.batch_severity(), "title": "fixture" + }) + }) + .collect(); + packages.push(json!({"purl": purl, "patches": infos})); + } + } + ResponseTemplate::new(200).set_body_json(json!({"packages": packages, "canAccessPaidPatches": false})) + }) + .mount(server) + .await; + let detail_map = by_purl.clone(); + Mock::given(method("GET")) + .and(path_regex(format!("^/v0/orgs/{ORG}/patches/by-package/.+$"))) + .respond_with(move |req: &Request| { + let raw = req.url.path().rsplit('/').next().unwrap(); + let purl = percent_decode(raw); + let list: Vec = detail_map + .get(&purl) + .into_iter() + .flatten() + .map(|p| { + json!({ + "uuid": p.uuid, "purl": purl, "publishedAt": p.published, + "description": "x", "license": "MIT", "tier": "free", + "vulnerabilities": p.vulnerabilities() + }) + }) + .collect(); + ResponseTemplate::new(200).set_body_json(json!({"patches": list, "canAccessPaidPatches": false})) + }) + .mount(server) + .await; + let by_uuid: BTreeMap = patches.iter().map(|p| (p.uuid.to_string(), p.clone())).collect(); + let refs = by_uuid.clone(); + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with(move |req: &Request| { + let body: Value = serde_json::from_slice(&req.body).unwrap(); + let mut results = serde_json::Map::new(); + for uuid in body["uuids"].as_array().unwrap() { + let uuid = uuid.as_str().unwrap(); + if let Some(p) = refs.get(uuid) { + results.insert( + uuid.to_string(), + json!({ + "status": "granted", "url": p.hosted_url(), "purl": p.purl(), + "artifacts": [{"kind": "tarball", "url": p.hosted_url(), + "integrity": {"sha512": format!("sha512-PATCHED{}==", &p.uuid[..8])}}], + "registryOverride": null + }), + ); + } + } + ResponseTemplate::new(200).set_body_json(json!({"results": results})) + }) + .mount(server) + .await; + let views = by_uuid; + Mock::given(method("GET")) + .and(path_regex(format!("^/v0/orgs/{ORG}/patches/view/.+$"))) + .respond_with(move |req: &Request| { + use base64::Engine as _; + let uuid = req.url.path().rsplit('/').next().unwrap(); + let Some(p) = views.get(uuid) else { + return ResponseTemplate::new(404); + }; + let before = compute_git_sha256_from_bytes(orig_index(p.name).as_bytes()); + let after_bytes = patched_index(p.name); + let after = compute_git_sha256_from_bytes(after_bytes.as_bytes()); + ResponseTemplate::new(200).set_body_json(json!({ + "uuid": p.uuid, "purl": p.purl(), "publishedAt": p.published, + "files": {"package/index.js": { + "beforeHash": before, "afterHash": after, + "blobContent": base64::engine::general_purpose::STANDARD.encode(after_bytes.as_bytes()) + }}, + "vulnerabilities": p.vulnerabilities(), + "description": "x", "license": "MIT", "tier": "free" + })) + }) + .mount(server) + .await; +} + +/// An npm project root: package.json, installed copies, a v3 lockfile. +fn write_npm_root(dir: &Path, deps: &[&str]) { + std::fs::create_dir_all(dir).unwrap(); + let dep_map: BTreeMap<&str, &str> = deps.iter().map(|d| (*d, "1.0.0")).collect(); + std::fs::write( + dir.join("package.json"), + serde_json::to_string_pretty(&json!({"name": "consumer", "version": "0.0.0", "dependencies": dep_map})) + .unwrap(), + ) + .unwrap(); + let mut packages = serde_json::Map::new(); + packages.insert( + String::new(), + json!({"name": "consumer", "version": "0.0.0", "dependencies": dep_map}), + ); + for name in deps { + let pkg = dir.join("node_modules").join(name); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write(pkg.join("package.json"), format!(r#"{{ "name": "{name}", "version": "1.0.0" }}"#)).unwrap(); + std::fs::write(pkg.join("index.js"), orig_index(name)).unwrap(); + packages.insert( + format!("node_modules/{name}"), + json!({ + "version": "1.0.0", + "resolved": format!("https://registry.npmjs.org/{name}/-/{name}-1.0.0.tgz"), + "integrity": "sha512-UPSTREAMupstream==" + }), + ); + } + let lock = json!({ + "name": "consumer", "version": "0.0.0", "lockfileVersion": 3, "requires": true, + "packages": packages + }); + std::fs::write(dir.join("package-lock.json"), serde_json::to_string_pretty(&lock).unwrap() + "\n").unwrap(); +} + +fn write_gem(dir: &Path, name: &str, version: &str) { + std::fs::create_dir_all(dir.join("vendor/bundle/ruby/3.0.0/gems").join(format!("{name}-{version}")).join("lib")) + .unwrap(); +} + +/// The monorepo: `services/web` (alpha, beta, left-pad + a gem), +/// `services/legacy` (gamma), `services/test` (delta). +struct Repo { + _tmp: tempfile::TempDir, + root: PathBuf, +} + +impl Repo { + fn new(socket_yml: Option<&str>) -> Self { + let tmp = tempfile::tempdir().unwrap(); + let root = std::fs::canonicalize(tmp.path()).unwrap().join("repo"); + std::fs::create_dir_all(root.join(".git")).unwrap(); + write_npm_root(&root.join("services/web"), &["alpha", "beta", "left-pad"]); + write_gem(&root.join("services/web"), "rack", "1.0.0"); + write_npm_root(&root.join("services/legacy"), &["gamma"]); + write_npm_root(&root.join("services/test"), &["delta"]); + if let Some(text) = socket_yml { + std::fs::write(root.join("socket.yml"), text).unwrap(); + } + Self { _tmp: tmp, root } + } + + fn dir(&self, rel: &str) -> PathBuf { + self.root.join(rel) + } + + fn lock(&self, rel: &str) -> String { + std::fs::read_to_string(self.dir(rel).join("package-lock.json")).unwrap() + } + + fn snapshot(&self) -> BTreeMap> { + fn walk(dir: &Path, root: &Path, out: &mut BTreeMap>) { + for entry in std::fs::read_dir(dir).unwrap().filter_map(Result::ok) { + let path = entry.path(); + if entry.file_type().unwrap().is_dir() { + walk(&path, root, out); + } else { + let rel = path.strip_prefix(root).unwrap().to_string_lossy().into_owned(); + out.insert(rel, std::fs::read(&path).unwrap()); + } + } + } + let mut out = BTreeMap::new(); + walk(&self.root, &self.root, &mut out); + out + } +} + +/// Run the binary with ambient `SOCKET_*` scrubbed; `(code, stdout, stderr)`. +fn run_cli(cwd: &Path, args: &[&str], env: &[(&str, &str)]) -> (i32, String, String) { + let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_socket-patch")); + cmd.args(args).current_dir(cwd); + for (key, _) in std::env::vars() { + if key.starts_with("SOCKET_") && key != "SOCKET_NO_CONFIG" { + cmd.env_remove(key); + } + } + cmd.env_remove("GIT_CEILING_DIRECTORIES").env_remove("VIRTUAL_ENV"); + cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); + // The fixture's hosted pins name this origin; it makes them recorded. + cmd.env("SOCKET_PATCH_SERVER_URL", "http://patch.test"); + let absent = cwd.join(".absent-npm-config"); + for var in [ + "NPM_CONFIG_USERCONFIG", + "npm_config_userconfig", + "NPM_CONFIG_GLOBALCONFIG", + "npm_config_globalconfig", + "PREFIX", + ] { + cmd.env(var, &absent); + } + cmd.env("NPM_CONFIG_ALLOW_REMOTE", "").env("npm_config_allow_remote", ""); + for (k, v) in env { + cmd.env(k, v); + } + let out = cmd.output().expect("spawn socket-patch"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +fn scan(cwd: &Path, api: &str, extra: &[&str], env: &[(&str, &str)]) -> (i32, String, String) { + let mut args = vec![ + "scan", + "--yes", + "--cwd", + cwd.to_str().unwrap(), + "--api-url", + api, + "--org", + ORG, + "--api-token", + "fake", + "--batch-size", + "100", + ]; + args.extend_from_slice(extra); + run_cli(cwd, &args, env) +} + +fn scan_json(cwd: &Path, api: &str, extra: &[&str], env: &[(&str, &str)]) -> (i32, Value) { + let mut args = vec!["--json"]; + args.extend_from_slice(extra); + let (code, stdout, stderr) = scan(cwd, api, &args, env); + let doc: Value = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("stdout must be JSON ({e})\nstdout=\n{stdout}\nstderr=\n{stderr}")); + (code, doc) +} + +fn filtered(doc: &Value) -> Vec<(Option, String)> { + doc["policy"]["filtered"] + .as_array() + .unwrap() + .iter() + .map(|f| (f["purl"].as_str().map(str::to_string), f["reason"].as_str().unwrap().to_string())) + .collect() +} + +fn filtered_reason<'a>(doc: &'a Value, purl: &str) -> &'a Value { + doc["policy"]["filtered"] + .as_array() + .unwrap() + .iter() + .find(|f| f["purl"] == purl) + .unwrap_or_else(|| panic!("{purl} not in policy.filtered: {:#}", doc["policy"])) +} + +fn warning_codes(doc: &Value) -> Vec { + doc["warnings"] + .as_array() + .map(|w| w.iter().filter_map(|e| e["code"].as_str().map(str::to_string)).collect()) + .unwrap_or_default() +} + +// --------------------------------------------------------------------------- +// Hosted +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn hosted_filters_by_ecosystem_package_and_severity() { + let server = MockServer::start().await; + mount_api(&server, catalog()).await; + let repo = Repo::new(Some( + "version: 2\npatches:\n ecosystems: [npm]\n ignorePackages: [\"pkg:npm/left-pad\"]\n minSeverity: high\n", + )); + let (code, doc) = scan_json(&repo.dir("services/web"), &server.uri(), &[], &[]); + assert_eq!(code, 0, "{doc:#}"); + + let lock = repo.lock("services/web"); + assert!(lock.contains(&P_ALPHA.hosted_url()), "alpha is patched:\n{lock}"); + assert!(!lock.contains(P_BETA.uuid), "beta is below the floor:\n{lock}"); + assert!(!lock.contains(P_LEFTPAD.uuid), "left-pad is ignored:\n{lock}"); + + let policy = &doc["policy"]; + assert_eq!(policy["source"], "file"); + assert_eq!(policy["path"], "socket.yml"); + assert_eq!(policy["sha256"].as_str().unwrap().len(), 64); + assert_eq!(policy["enabled"], true); + assert_eq!(policy["minSeverity"], json!({"value": "high", "source": "file"})); + let beta = filtered_reason(&doc, "pkg:npm/beta@1.0.0"); + assert_eq!(beta["reason"], "policy_severity"); + assert_eq!(beta["detail"], "low < high"); + assert_eq!(beta["uuid"], P_BETA.uuid); + assert_eq!(beta["project"], "services/web"); + let left_pad = filtered_reason(&doc, "pkg:npm/left-pad@1.0.0"); + assert_eq!(left_pad["reason"], "policy_package_ignored"); + assert_eq!(left_pad["uuid"], Value::Null, "filtered before any patch lookup"); + assert_eq!(left_pad["detail"], "pkg:npm/left-pad (patches.ignorePackages)"); + let rack = filtered_reason(&doc, "pkg:gem/rack@1.0.0"); + assert_eq!(rack["reason"], "policy_ecosystem"); + assert_eq!(policy["counts"]["filtered"], 3); + assert_eq!(policy["counts"]["retained"], 0); + // Packages filtered before lookup are never queried. + let reqs = server.received_requests().await.unwrap(); + for r in &reqs { + if r.url.path().ends_with("/patches/batch") { + let body = String::from_utf8_lossy(&r.body); + assert!(!body.contains("left-pad") && !body.contains("rack"), "{body}"); + } + } + assert_eq!(doc["redirect"]["redirected"], 1, "{:#}", doc["redirect"]); +} + +#[tokio::test] +#[serial] +async fn hosted_dry_run_makes_the_same_decisions_and_writes_nothing() { + let server = MockServer::start().await; + mount_api(&server, catalog()).await; + let repo = Repo::new(Some("version: 2\npatches:\n minSeverity: high\n ecosystems: [npm]\n")); + let before = repo.snapshot(); + let (code, doc) = scan_json(&repo.dir("services/web"), &server.uri(), &["--dry-run"], &[]); + assert_eq!(code, 0, "{doc:#}"); + assert_eq!(repo.snapshot(), before, "a dry run changes no bytes"); + assert_eq!(doc["redirect"]["redirected"], 2, "alpha and left-pad: {:#}", doc["redirect"]); + assert_eq!(filtered_reason(&doc, "pkg:npm/beta@1.0.0")["reason"], "policy_severity"); +} + +#[tokio::test] +#[serial] +async fn path_globs_apply_default_ignores_and_ignore_paths_human() { + let server = MockServer::start().await; + mount_api(&server, catalog()).await; + let repo = Repo::new(Some("version: 2\npatches:\n ignorePaths: [\"/services/legacy/\"]\n")); + let legacy = repo.lock("services/legacy"); + let test_lock = repo.lock("services/test"); + let (code, stdout, stderr) = scan(&repo.root, &server.uri(), &["services/*"], &[]); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!(repo.lock("services/web").contains(&P_ALPHA.hosted_url())); + assert_eq!(repo.lock("services/legacy"), legacy, "ignored by patches.ignorePaths"); + assert_eq!(repo.lock("services/test"), test_lock, "a discovered test/ root is a built-in ignore"); + assert!(stdout.contains("Policy (socket.yml)"), "{stdout}"); + + // Named literally, the test/ root is explicit: defaults do not apply. + let (code, stdout, stderr) = scan(&repo.root, &server.uri(), &["services/test"], &[]); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!(repo.lock("services/test").contains(&P_DELTA.hosted_url())); +} + +#[tokio::test] +#[serial] +async fn include_paths_limit_roots() { + let server = MockServer::start().await; + mount_api(&server, catalog()).await; + let repo = Repo::new(Some("version: 2\npatches:\n includePaths: [\"/services/legacy/\"]\n")); + let web = repo.lock("services/web"); + let (code, doc) = scan_json(&repo.dir("services/web"), &server.uri(), &[], &[]); + assert_eq!(code, 0, "{doc:#}"); + assert_eq!(repo.lock("services/web"), web); + assert_eq!( + filtered(&doc), + vec![(None, "policy_path_not_included".to_string())], + "a root filtered as a whole is one entry with purl null" + ); + let (code, doc) = scan_json(&repo.dir("services/legacy"), &server.uri(), &[], &[]); + assert_eq!(code, 0, "{doc:#}"); + assert!(repo.lock("services/legacy").contains(&P_GAMMA.hosted_url())); + assert_eq!(doc["policy"]["counts"]["filtered"], 0); +} + +#[tokio::test] +#[serial] +async fn invalid_file_fails_closed_before_any_request_or_write() { + let server = MockServer::start().await; + mount_api(&server, catalog()).await; + let repo = Repo::new(Some("version: 2\npatches:\n minSeverty: high\n")); + let before = repo.snapshot(); + let (code, doc) = scan_json(&repo.dir("services/web"), &server.uri(), &[], &[]); + assert_eq!(code, 1); + assert_eq!(doc["status"], "error"); + assert_eq!(doc["errorCode"], "socket_yml_invalid"); + let message = doc["error"].as_str().unwrap(); + assert!(message.contains("patches.minSeverty"), "{message}"); + assert!(message.contains("did you mean `minSeverity`"), "{message}"); + assert!(message.contains("--no-socket-yml"), "{message}"); + assert!(doc.get("policy").is_none()); + assert_eq!(repo.snapshot(), before); + assert!(server.received_requests().await.unwrap().is_empty(), "no request before the policy loads"); + + // Human output names the code on stderr, same exit code. + let (code, _, stderr) = scan(&repo.dir("services/web"), &server.uri(), &[], &[]); + assert_eq!(code, 1); + assert!(stderr.contains("socket_yml_invalid"), "{stderr}"); + + // --no-socket-yml (and its env var) skips the file. + let (code, doc) = scan_json(&repo.dir("services/web"), &server.uri(), &["--no-socket-yml", "--dry-run"], &[]); + assert_eq!(code, 0, "{doc:#}"); + assert_eq!(doc["policy"]["source"], "bypassed"); + let (code, doc) = scan_json(&repo.dir("services/web"), &server.uri(), &["--dry-run"], &[("SOCKET_NO_SOCKET_YML", "1")]); + assert_eq!(code, 0, "{doc:#}"); + assert_eq!(doc["policy"]["source"], "bypassed"); +} + +#[tokio::test] +#[serial] +async fn both_files_disagreeing_is_ambiguous() { + let server = MockServer::start().await; + mount_api(&server, catalog()).await; + let repo = Repo::new(Some("version: 2\npatches:\n maxNewPatches: 1\n")); + std::fs::write(repo.root.join("socket.yaml"), "version: 2\npatches:\n maxNewPatches: 2\n").unwrap(); + let (code, doc) = scan_json(&repo.dir("services/web"), &server.uri(), &[], &[]); + assert_eq!(code, 1); + assert_eq!(doc["errorCode"], "socket_yml_ambiguous"); +} + +#[tokio::test] +#[serial] +async fn severity_flag_and_env_override_the_file() { + let server = MockServer::start().await; + mount_api(&server, catalog()).await; + let repo = Repo::new(Some("version: 2\npatches:\n minSeverity: high\n")); + let web = repo.dir("services/web"); + let (code, doc) = scan_json(&web, &server.uri(), &["--dry-run", "--min-severity", "none"], &[]); + assert_eq!(code, 0, "{doc:#}"); + assert_eq!(doc["policy"]["minSeverity"], json!({"value": null, "source": "flag"})); + assert_eq!(doc["redirect"]["redirected"], 3, "beta too once the floor is lifted"); + + let (code, doc) = scan_json(&web, &server.uri(), &["--dry-run"], &[("SOCKET_MIN_SEVERITY", "critical")]); + assert_eq!(code, 0, "{doc:#}"); + assert_eq!(doc["policy"]["minSeverity"], json!({"value": "critical", "source": "env"})); + assert_eq!(doc["redirect"]["redirected"], 1); + + // The flag beats the env; an empty env value is unset. + let (_, doc) = scan_json(&web, &server.uri(), &["--dry-run", "--min-severity", "moderate"], &[("SOCKET_MIN_SEVERITY", "critical")]); + assert_eq!(doc["policy"]["minSeverity"], json!({"value": "medium", "source": "flag"})); + let (_, doc) = scan_json(&web, &server.uri(), &["--dry-run"], &[("SOCKET_MIN_SEVERITY", "")]); + assert_eq!(doc["policy"]["minSeverity"], json!({"value": "high", "source": "file"})); + + // Malformed values are usage errors. + let (code, _, stderr) = scan(&web, &server.uri(), &["--min-severity", "severe"], &[]); + assert_eq!(code, 2, "{stderr}"); + let (code, _, stderr) = scan(&web, &server.uri(), &[], &[("SOCKET_MIN_SEVERITY", "severe")]); + assert_eq!(code, 2, "{stderr}"); + assert!(stderr.contains("SOCKET_MIN_SEVERITY"), "{stderr}"); +} + +#[tokio::test] +#[serial] +async fn narrowing_after_a_hosted_patch_leaves_the_pin_byte_identical() { + let server = MockServer::start().await; + mount_api(&server, vec![P_ALPHA]).await; + let repo = Repo::new(None); + let web = repo.dir("services/web"); + let (code, doc) = scan_json(&web, &server.uri(), &[], &[]); + assert_eq!(code, 0, "{doc:#}"); + let pinned = repo.lock("services/web"); + assert!(pinned.contains(&P_ALPHA.hosted_url())); + + // A newer merged patch appears, and the repo now ignores alpha. + std::fs::write(repo.root.join("socket.yml"), "version: 2\npatches:\n ignorePackages: [alpha]\n").unwrap(); + server.reset().await; + mount_api(&server, vec![P_ALPHA, P_ALPHA_MERGED_NEW]).await; + let (code, doc) = scan_json(&web, &server.uri(), &[], &[]); + assert_eq!(code, 0, "{doc:#}"); + assert_eq!(repo.lock("services/web"), pinned, "retained: not upgraded, not removed"); + let retained = &doc["policy"]["retained"][0]; + assert_eq!(retained["purl"], "pkg:npm/alpha@1.0.0"); + assert_eq!(retained["recordedUuid"], P_ALPHA.uuid); + assert_eq!(retained["reason"], "policy_package_ignored"); + assert_eq!(retained["upgradeAvailable"], true); + assert_eq!(retained["project"], "services/web"); + assert_eq!(doc["policy"]["counts"]["retained"], 1); + + // Same with the whole root excluded, and with enabled: false. + for yml in [ + "version: 2\npatches:\n ignorePaths: [\"services/\"]\n", + "version: 2\npatches:\n enabled: false\n", + ] { + std::fs::write(repo.root.join("socket.yml"), yml).unwrap(); + let (code, doc) = scan_json(&web, &server.uri(), &[], &[]); + assert_eq!(code, 0, "{doc:#}"); + assert_eq!(repo.lock("services/web"), pinned, "{yml}"); + assert_eq!(doc["policy"]["retained"][0]["purl"], "pkg:npm/alpha@1.0.0", "{yml}: {:#}", doc["policy"]); + } +} + +#[tokio::test] +#[serial] +async fn enabled_false_reports_and_writes_nothing() { + let server = MockServer::start().await; + mount_api(&server, catalog()).await; + let repo = Repo::new(Some("version: 2\npatches:\n enabled: false\n")); + let before = repo.snapshot(); + let (code, doc) = scan_json(&repo.dir("services/web"), &server.uri(), &[], &[]); + assert_eq!(code, 0, "{doc:#}"); + assert_eq!(repo.snapshot(), before); + assert_eq!(doc["policy"]["enabled"], false); + assert!(warning_codes(&doc).contains(&"patches_disabled".to_string()), "{doc:#}"); + let reasons: Vec = filtered(&doc).into_iter().map(|(_, r)| r).collect(); + assert!(!reasons.is_empty() && reasons.iter().all(|r| r == "policy_disabled"), "{reasons:?}"); + assert_eq!(doc["redirect"]["redirected"], 0); +} + +#[tokio::test] +#[serial] +async fn recorded_merged_patch_below_a_new_floor_is_kept() { + let server = MockServer::start().await; + mount_api(&server, vec![P_ALPHA_MERGED_LOW, P_ALPHA]).await; + let repo = Repo::new(None); + let web = repo.dir("services/web"); + let (code, doc) = scan_json(&web, &server.uri(), &[], &[]); + assert_eq!(code, 0, "{doc:#}"); + let pinned = repo.lock("services/web"); + assert!(pinned.contains(&P_ALPHA_MERGED_LOW.hosted_url()), "merged ranks first:\n{pinned}"); + + std::fs::write(repo.root.join("socket.yml"), "version: 2\npatches:\n minSeverity: high\n").unwrap(); + let (code, doc) = scan_json(&web, &server.uri(), &[], &[]); + assert_eq!(code, 0, "{doc:#}"); + assert_eq!(repo.lock("services/web"), pinned, "the floor never replaces the recorded merged patch"); +} + +#[tokio::test] +#[serial] +async fn path_outside_the_repo_is_a_usage_error() { + let server = MockServer::start().await; + mount_api(&server, catalog()).await; + let repo = Repo::new(None); + let outside = repo.root.parent().unwrap().join("elsewhere"); + write_npm_root(&outside, &["alpha"]); + let (code, _, stderr) = scan(&repo.dir("services"), &server.uri(), &["web", "../../elsewhere"], &[]); + assert_eq!(code, 2, "{stderr}"); + assert!(stderr.contains("outside the repository root"), "{stderr}"); +} + +#[tokio::test] +#[serial] +async fn project_ignore_paths_is_honored_without_a_patches_block() { + let server = MockServer::start().await; + mount_api(&server, catalog()).await; + let repo = Repo::new(Some("version: 2\nprojectIgnorePaths:\n - \"services/legacy/**\"\n")); + let legacy = repo.lock("services/legacy"); + let (code, doc) = scan_json(&repo.dir("services/legacy"), &server.uri(), &[], &[]); + assert_eq!(code, 0, "{doc:#}"); + assert_eq!(repo.lock("services/legacy"), legacy); + let entry = &doc["policy"]["filtered"][0]; + assert_eq!(entry["reason"], "policy_path_excluded"); + assert_eq!(entry["detail"], "services/legacy/** (projectIgnorePaths)"); + + // A malformed projectIgnorePaths without a patches block only warns. + std::fs::write(repo.root.join("socket.yml"), "version: 2\nprojectIgnorePaths: {a: 1}\n").unwrap(); + let (code, doc) = scan_json(&repo.dir("services/legacy"), &server.uri(), &["--dry-run"], &[]); + assert_eq!(code, 0, "{doc:#}"); + assert!(warning_codes(&doc).contains(&"socket_yml_ignored_value".to_string()), "{doc:#}"); +} + +// --------------------------------------------------------------------------- +// Agent +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn agent_mode_applies_only_admitted_patches() { + let server = MockServer::start().await; + mount_api(&server, catalog()).await; + let repo = Repo::new(Some( + "version: 2\npatches:\n minSeverity: high\n ecosystems: [npm]\n ignorePackages: [\"pkg:npm/left-pad\"]\n", + )); + let web = repo.dir("services/web"); + let (code, doc) = scan_json(&web, &server.uri(), &["--mode", "agent", "--dry-run"], &[]); + assert_eq!(code, 0, "{doc:#}"); + let planned: Vec<&str> = doc["apply"]["patches"] + .as_array() + .unwrap() + .iter() + .map(|p| p["purl"].as_str().unwrap()) + .collect(); + assert_eq!(planned, ["pkg:npm/alpha@1.0.0"], "{doc:#}"); + + let (code, doc) = scan_json(&web, &server.uri(), &["--mode", "agent"], &[]); + assert_eq!(code, 0, "{doc:#}"); + let manifest: Value = + serde_json::from_str(&std::fs::read_to_string(web.join(".socket/manifest.json")).unwrap()).unwrap(); + let keys: Vec<&String> = manifest["patches"].as_object().unwrap().keys().collect(); + assert_eq!(keys, ["pkg:npm/alpha@1.0.0"]); + assert_eq!( + std::fs::read_to_string(web.join("node_modules/alpha/index.js")).unwrap(), + patched_index("alpha") + ); + assert_eq!(std::fs::read_to_string(web.join("node_modules/beta/index.js")).unwrap(), orig_index("beta")); +} + +#[tokio::test] +#[serial] +async fn agent_mode_retains_a_recorded_patch_the_policy_now_excludes() { + let server = MockServer::start().await; + mount_api(&server, vec![P_ALPHA]).await; + let repo = Repo::new(None); + let web = repo.dir("services/web"); + let (code, doc) = scan_json(&web, &server.uri(), &["--mode", "agent"], &[]); + assert_eq!(code, 0, "{doc:#}"); + let manifest_before = std::fs::read(web.join(".socket/manifest.json")).unwrap(); + let installed_before = std::fs::read(web.join("node_modules/alpha/index.js")).unwrap(); + + std::fs::write(repo.root.join("socket.yml"), "version: 2\npatches:\n ecosystems: [pypi]\n").unwrap(); + server.reset().await; + mount_api(&server, vec![P_ALPHA, P_ALPHA_MERGED_NEW]).await; + let (code, doc) = scan_json(&web, &server.uri(), &["--mode", "agent"], &[]); + assert_eq!(code, 0, "{doc:#}"); + assert_eq!(std::fs::read(web.join(".socket/manifest.json")).unwrap(), manifest_before); + assert_eq!(std::fs::read(web.join("node_modules/alpha/index.js")).unwrap(), installed_before); + assert_eq!(doc["policy"]["retained"][0]["reason"], "policy_ecosystem"); + assert_eq!(doc["policy"]["retained"][0]["upgradeAvailable"], true); +} + +// --------------------------------------------------------------------------- +// Vendored +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn vendored_dry_run_previews_only_admitted_patches() { + let server = MockServer::start().await; + mount_api(&server, catalog()).await; + let repo = Repo::new(Some("version: 2\npatches:\n packages: [\"pkg:npm/beta\", \"pkg:npm/left-pad\"]\n minSeverity: medium\n")); + let before = repo.snapshot(); + let (code, doc) = scan_json(&repo.dir("services/web"), &server.uri(), &["--mode", "vendored", "--dry-run"], &[]); + assert_eq!(code, 0, "{doc:#}"); + assert_eq!(repo.snapshot(), before); + let previewed: Vec<&str> = doc["vendor"]["patches"] + .as_array() + .unwrap_or_else(|| panic!("{doc:#}")) + .iter() + .filter_map(|p| p["purl"].as_str()) + .collect(); + assert_eq!(previewed, ["pkg:npm/left-pad@1.0.0"], "{doc:#}"); + assert_eq!(filtered_reason(&doc, "pkg:npm/alpha@1.0.0")["reason"], "policy_package_not_listed"); + assert_eq!(filtered_reason(&doc, "pkg:npm/beta@1.0.0")["reason"], "policy_severity"); +} 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 fce505b3..93889c00 100644 --- a/crates/socket-patch-cli/tests/in_process_cargo_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_cargo_apply.rs @@ -220,6 +220,7 @@ async fn cargo_fetch_scan_sync_patches_real_file() { make_writable(&lib_file); let args = ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { @@ -337,6 +338,7 @@ async fn cargo_apply_refuses_on_before_hash_mismatch() { make_writable(&lib_file); let args = ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { @@ -440,6 +442,7 @@ async fn cargo_crawler_finds_real_fetched_crate() { std::env::set_var("CARGO_HOME", &cargo_home); let args = ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { 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 ae9f45c4..2edae8ed 100644 --- a/crates/socket-patch-cli/tests/in_process_gem_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_gem_apply.rs @@ -199,6 +199,7 @@ async fn gem_install_scan_sync_patches_real_file() { .await; let args = ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { @@ -312,6 +313,7 @@ async fn gem_crawler_finds_real_installed_gem() { .await; let args = ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { 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 ecf70b37..dc0506a1 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 @@ -217,6 +217,7 @@ async fn mount_view( fn scan_args(cwd: &Path, api_url: String, all_releases: bool) -> ScanArgs { ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { 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 c5bafe10..c1836650 100644 --- a/crates/socket-patch-cli/tests/in_process_pypi_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_pypi_apply.rs @@ -248,6 +248,7 @@ async fn pypi_install_scan_sync_patches_real_file() { setup_pypi_apply_mock(&server, &before_hash, &after_hash, &patched).await; let mut args = ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { @@ -325,6 +326,7 @@ async fn pypi_scan_then_apply_force_patches_real_file() { // 1. scan --sync to write the manifest + blob. let scan_args = ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { @@ -435,6 +437,7 @@ async fn pypi_apply_dry_run_does_not_modify_file() { setup_pypi_apply_mock(&server, &before_hash, &after_hash, &patched).await; let scan_args = ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { @@ -565,6 +568,7 @@ async fn pypi_crawler_finds_real_installed_six() { .await; let args = ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { 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 18b866a9..31b1b836 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 @@ -291,6 +291,7 @@ async fn mount_view( fn scan_args(tmp: &Path, api_url: String, all_releases: bool) -> ScanArgs { ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { 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 95325a01..1146da46 100644 --- a/crates/socket-patch-cli/tests/in_process_python_envs.rs +++ b/crates/socket-patch-cli/tests/in_process_python_envs.rs @@ -114,6 +114,7 @@ async fn scan_scrubbed(args: ScanArgs) -> i32 { fn default_args(cwd: &Path, api_url: String) -> ScanArgs { ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index ef2c0309..e868f736 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -45,6 +45,7 @@ const GHSA: &str = "GHSA-rdir-aaaa-bbbb"; fn redirect_args(cwd: &Path, api_url: String) -> ScanArgs { ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs b/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs index 7eb92abf..3d102cc7 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs @@ -152,6 +152,7 @@ fn assert_no_ledger(root: &Path) { fn hosted_args(cwd: &Path, api_url: String, vex: Option<&Path>) -> ScanArgs { ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: global(cwd, api_url), diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs b/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs index b176e9a6..a1da858e 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs @@ -80,6 +80,7 @@ fn global(cwd: &Path, api_url: String) -> GlobalArgs { fn hosted_args(cwd: &Path, api_url: String, vex: Option<&Path>) -> ScanArgs { ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: global(cwd, api_url), diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs index 03da8fcd..3e077a1d 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs @@ -79,6 +79,7 @@ async fn rollback_hosted(cwd: &Path, server: &MockServer) -> i32 { /// folds into it). fn hosted_args(cwd: &Path, api_url: String) -> ScanArgs { ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { diff --git a/crates/socket-patch-cli/tests/in_process_redirect_poetry.rs b/crates/socket-patch-cli/tests/in_process_redirect_poetry.rs index 9ba3c5a9..86f2cc6b 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_poetry.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_poetry.rs @@ -121,6 +121,7 @@ fn assert_no_ledger(root: &Path) { fn hosted_args(cwd: &Path, api_url: String, vex: Option<&Path>) -> ScanArgs { ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: global(cwd, api_url), 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 094454bc..cf118165 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 @@ -71,6 +71,7 @@ async fn assert_discovered_purl(server: &MockServer, expected_purl: &str) { fn default_scan_args(cwd: &Path, eco: &str, api_url: String) -> ScanArgs { ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { diff --git a/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs b/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs index 5f6072e0..8cb3154e 100644 --- a/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs +++ b/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs @@ -70,6 +70,7 @@ const GEM_PATCH_REMOTE: &str = "http://patch.test/gems/t0k3nt0k3n/"; fn hosted_scan_args(cwd: &Path, api_url: String) -> ScanArgs { ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { diff --git a/crates/socket-patch-cli/tests/in_process_scan.rs b/crates/socket-patch-cli/tests/in_process_scan.rs index 859bb611..449bbe1c 100644 --- a/crates/socket-patch-cli/tests/in_process_scan.rs +++ b/crates/socket-patch-cli/tests/in_process_scan.rs @@ -19,6 +19,7 @@ const UUID: &str = "11111111-1111-4111-8111-111111111111"; fn default_args(cwd: &Path) -> ScanArgs { ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: socket_patch_cli::args::GlobalArgs { diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index 15c8aaf8..0095eab1 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -3246,6 +3246,7 @@ snapshots: /// `in_process_redirect_pnpm.rs` shape). fn hosted_args(cwd: &Path, api_url: String) -> ScanArgs { ScanArgs { + socket_yml: Default::default(), paths: Vec::new(), packages: Vec::new(), common: GlobalArgs { From c167b49372289c1d78382f544ae26112996e05b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 14:26:27 +0000 Subject: [PATCH 10/22] Cap new patches in the in-memory hosted engine The napi engine and hosted-bundle take maxNewPatches (a count or "none"), maxNewPatchesCap (a server ceiling that only tightens) and inFlightPatches (ranked first). One budget spans every project root: the engine classifies each root against its committed manifest, vendor ledger and the hosted pins its lockfiles name, plans once after every root's write-free checks, and rewrites a root again without its deferred rows. Results gain a session-level rollout block and ProjectResult.deferred; deferred rows also appear in skipped[] as rollout_deferred. Parity tests hold disk and memory to the same rollout and redirect blocks run after run until converged, and show memory's run-wide budget beside disk's per-directory one. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/commands/hosted_bundle.rs | 12 +- .../src/commands/scan/discovery.rs | 2 +- .../socket-patch-cli/src/commands/scan/mod.rs | 4 +- .../src/commands/scan/rollout.rs | 71 ++- .../src/hosted_memory/discover.rs | 38 +- .../src/hosted_memory/limits.rs | 16 + .../socket-patch-cli/src/hosted_memory/mod.rs | 219 ++++++++- .../src/hosted_memory/redirect.rs | 2 +- .../src/hosted_memory/select.rs | 6 +- .../src/hosted_memory/types.rs | 80 ++++ .../tests/hosted_memory_common/mod.rs | 78 ++- .../tests/hosted_memory_rollout.rs | 444 ++++++++++++++++++ crates/socket-patch-node/npm/index.d.ts | 13 +- crates/socket-patch-node/npm/test/smoke.mjs | 18 + 14 files changed, 940 insertions(+), 63 deletions(-) create mode 100644 crates/socket-patch-cli/tests/hosted_memory_rollout.rs diff --git a/crates/socket-patch-cli/src/commands/hosted_bundle.rs b/crates/socket-patch-cli/src/commands/hosted_bundle.rs index 392ac771..fc08b176 100644 --- a/crates/socket-patch-cli/src/commands/hosted_bundle.rs +++ b/crates/socket-patch-cli/src/commands/hosted_bundle.rs @@ -9,7 +9,8 @@ //! //! Stdin: `{"files": {path: text}, "binaryFiles"?: {path: base64}, //! "presentOnly"?: [path], "symlinks"?: [path], "projectRoots"?: [dir], -//! "pipenvMajor"?: n, "batchSize"?: n}`. Stdout: the engine result +//! "pipenvMajor"?: n, "batchSize"?: n, "maxNewPatches"?: n | "none", +//! "maxNewPatchesCap"?: n, "inFlightPatches"?: [purl]}`. Stdout: the engine result //! (`HostedScanResult`, binary contents base64), or //! `{"status":"error","error":{"code","message"}}` with exit 2 for bad //! credentials/bundle input, or exit 1 for an engine failure. @@ -53,6 +54,12 @@ struct Bundle { pipenv_major: Option, #[serde(default)] batch_size: Option, + #[serde(default)] + max_new_patches: Option, + #[serde(default)] + max_new_patches_cap: Option, + #[serde(default)] + in_flight_patches: Option>, } fn print_error(code: &str, message: &str) { @@ -121,6 +128,9 @@ pub async fn run(args: HostedBundleArgs) -> i32 { trust_lockfile_config: Some(!common.no_trust_lockfile_config), npm_allow_remote_config: Some(!common.no_npm_allow_remote_config), project_roots: bundle.project_roots.clone(), + max_new_patches: bundle.max_new_patches, + max_new_patches_cap: bundle.max_new_patches_cap, + in_flight_patches: bundle.in_flight_patches.clone(), ..HostedScanOptions::default() }; let input = match build_input(bundle, options) { diff --git a/crates/socket-patch-cli/src/commands/scan/discovery.rs b/crates/socket-patch-cli/src/commands/scan/discovery.rs index 642547aa..f8b01258 100644 --- a/crates/socket-patch-cli/src/commands/scan/discovery.rs +++ b/crates/socket-patch-cli/src/commands/scan/discovery.rs @@ -409,7 +409,7 @@ pub(super) async fn preverify_vendor_baselines( /// bridges the spellings); a legacy entry without an embedded record /// contributes its uuid alone. Borrows the manifest untouched when nothing /// else contributes. -pub(super) fn merge_ledger_records_for_updates<'a>( +pub(crate) fn merge_ledger_records_for_updates<'a>( manifest: Option<&'a PatchManifest>, vendor: Option<&VendorState>, hosted_pins: &[(String, String)], diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index f5ba4b39..336b0729 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -45,14 +45,14 @@ pub(crate) mod vendor_flow; use self::discovery::{ collect_vuln_ids, detect_updates, lockfile_only_contains, lockfile_supplement, - merge_ledger_records_for_updates, preverify_vendor_baselines, severity_order, + preverify_vendor_baselines, severity_order, vendored_ledger_supplement, LockfileSupplement, }; // Shared with `get --mode hosted|vendored` (commands::get): the advisory- // pinned entry into the hosted engine, the vendor step + its dry-run // preview, and the PnP layout-refusal warning mapping. `pub(crate)` // re-exports because the submodules themselves stay private to scan. -pub(crate) use self::discovery::unsupported_layout_warnings; +pub(crate) use self::discovery::{merge_ledger_records_for_updates, unsupported_layout_warnings}; use self::gc::gc_json; pub(crate) use self::hosted::boxed_run_redirect_selected; use self::hosted::run_redirect; diff --git a/crates/socket-patch-cli/src/commands/scan/rollout.rs b/crates/socket-patch-cli/src/commands/scan/rollout.rs index 4fa3374d..ae134c44 100644 --- a/crates/socket-patch-cli/src/commands/scan/rollout.rs +++ b/crates/socket-patch-cli/src/commands/scan/rollout.rs @@ -191,17 +191,44 @@ pub(crate) fn lookup_incomplete( .any(|purl| recorded.is_none_or(|m| recorded_uuids(m, purl).is_empty())) } +/// Every canonical-shaped uuid (`8-4-4-4-12` hex) `text` mentions, +/// lowercased, in one linear pass. +pub(crate) fn mentioned_uuids(text: &str, out: &mut HashSet) { + let bytes = text.as_bytes(); + if bytes.len() < 36 { + return; + } + let mut i = 0; + while i + 36 <= bytes.len() { + let window = &bytes[i..i + 36]; + let shaped = window.iter().enumerate().all(|(k, b)| match k { + 8 | 13 | 18 | 23 => *b == b'-', + _ => b.is_ascii_hexdigit(), + }); + if shaped { + out.insert(String::from_utf8_lossy(window).to_ascii_lowercase()); + i += 36; + } else { + i += 1; + } + } +} + /// Mark NEW rows whose selected uuid the project's lockfile texts already /// mention as ALREADY. A hosted pin on a patch server discovery does not /// recognize (an origin missing from `--patch-server-url`) would otherwise /// read as NEW on every run and hold its slot forever; patch uuids are /// unique, so a mention is a pin. pub(crate) fn mark_pinned(rows: &mut [Row], texts: &[&str]) { + if !rows.iter().any(|r| r.candidate.recorded.is_new()) { + return; + } + let mut mentioned = HashSet::new(); + for text in texts { + mentioned_uuids(text, &mut mentioned); + } for row in rows.iter_mut().filter(|r| r.candidate.recorded.is_new()) { - if texts - .iter() - .any(|t| t.contains(row.candidate.uuid.as_str())) - { + if mentioned.contains(&row.candidate.uuid.to_ascii_lowercase()) { row.candidate.recorded = Recorded::Same; } } @@ -731,6 +758,42 @@ mod tests { ); } + #[test] + fn mentioned_uuids_finds_every_canonical_shape_once() { + let mut out = HashSet::new(); + mentioned_uuids( + "https://h/p/22222222-2222-4222-8222-222222222222/AAAAAAAA-1111-4111-8111-00000000000A/x.tgz \ + not-a-uuid 1234 socket-patch-bbbbbbbb-1111-4111-8111-00000000000b", + &mut out, + ); + let mut got: Vec<&str> = out.iter().map(String::as_str).collect(); + got.sort(); + assert_eq!( + got, + [ + "22222222-2222-4222-8222-222222222222", + "aaaaaaaa-1111-4111-8111-00000000000a", + "bbbbbbbb-1111-4111-8111-00000000000b", + ] + ); + } + + #[test] + fn a_lock_naming_the_selected_uuid_marks_the_row_already() { + let results = vec![ + offer("pkg:npm/a@1", "aaaaaaaa-1111-4111-8111-00000000000a", "", &["high"]), + offer("pkg:npm/b@1", "bbbbbbbb-1111-4111-8111-00000000000b", "", &["high"]), + ]; + let offers = offers_from_results(&results, true); + let mut rows = classify(&offers, None, ""); + mark_pinned( + &mut rows, + &["resolved: https://x/AAAAAAAA-1111-4111-8111-00000000000A/a.tgz"], + ); + assert_eq!(rows[0].candidate.recorded, Recorded::Same); + assert_eq!(rows[1].candidate.recorded, Recorded::None); + } + #[test] fn stage_carries_the_budget_and_renders_the_block() { let configured = MaxNew { diff --git a/crates/socket-patch-cli/src/hosted_memory/discover.rs b/crates/socket-patch-cli/src/hosted_memory/discover.rs index 43572ece..fb59f23f 100644 --- a/crates/socket-patch-cli/src/hosted_memory/discover.rs +++ b/crates/socket-patch-cli/src/hosted_memory/discover.rs @@ -15,7 +15,7 @@ use std::time::Duration; use socket_patch_core::api::client::{ApiError, ApiFuture, PatchApi}; use socket_patch_core::api::ranking::cmp_search_results; use socket_patch_core::api::types::{ - BatchPackagePatches, PackageVendorResult, PatchResponse, PatchSearchResult, SearchResponse, + BatchPackagePatches, PackageVendorResult, PatchResponse, SearchResponse, }; use socket_patch_core::utils::purl::{normalize_purl, strip_purl_qualifiers}; @@ -280,29 +280,6 @@ pub(crate) async fn fetch_details( .collect() } -/// The disk `--json` selection over one root's merged detail results: -/// accessible patches only, then the top-ranked patch per purl, sorted by -/// purl, as `(purl, uuid)`. -pub(crate) fn select_top_ranked( - results: &[PatchSearchResult], - can_access_paid: bool, -) -> Vec<(String, String)> { - let mut by_purl: BTreeMap<&str, Vec<&PatchSearchResult>> = BTreeMap::new(); - for patch in results - .iter() - .filter(|p| can_access_paid || p.tier == "free") - { - by_purl.entry(patch.purl.as_str()).or_default().push(patch); - } - by_purl - .into_iter() - .filter_map(|(purl, mut group)| { - group.sort_by(|a, b| cmp_search_results(a, b)); - group.first().map(|p| (purl.to_string(), p.uuid.clone())) - }) - .collect() -} - /// Reference grants for every distinct uuid (`MAX_REFERENCE_BATCH` per /// request): the merged results, plus the uuids whose request failed. pub(crate) async fn fetch_references( @@ -389,6 +366,7 @@ pub(crate) async fn fetch_records( #[cfg(test)] mod tests { use super::*; + use socket_patch_core::api::types::PatchSearchResult; use std::sync::atomic::{AtomicUsize, Ordering}; #[tokio::test] @@ -440,13 +418,21 @@ mod tests { result("pkg:npm/a@1", "a-paid", "paid", "critical"), result("pkg:npm/a@1", "a-free", "free", "low"), ]; + // The engine selects through the disk flow's own seam. + let pairs = |paid: bool| -> Vec<(String, String)> { + crate::commands::scan::rollout::offers_from_results(&results, paid) + .selected + .into_iter() + .map(|(purl, p)| (purl, p.uuid)) + .collect() + }; assert_eq!( - select_top_ranked(&results, false), + pairs(false), vec![ ("pkg:npm/a@1".to_string(), "a-free".to_string()), ("pkg:npm/b@1".to_string(), "b-crit".to_string()) ] ); - assert_eq!(select_top_ranked(&results, true)[0].1, "a-paid"); + assert_eq!(pairs(true)[0].1, "a-paid"); } } diff --git a/crates/socket-patch-cli/src/hosted_memory/limits.rs b/crates/socket-patch-cli/src/hosted_memory/limits.rs index 4bcdb635..14e0aba3 100644 --- a/crates/socket-patch-cli/src/hosted_memory/limits.rs +++ b/crates/socket-patch-cli/src/hosted_memory/limits.rs @@ -27,6 +27,10 @@ pub(crate) struct ResolvedOptions { pub(crate) provider_concurrency: usize, pub(crate) request_timeout: std::time::Duration, pub(crate) limits: ResolvedLimits, + /// The run-wide cap on NEW patches (the option reports as `flag`). + pub(crate) max_new: socket_patch_core::rollout::MaxNew, + /// `inFlightPatches` as canonical base purls. + pub(crate) in_flight: std::collections::BTreeSet, } pub(crate) fn resolve_options(options: &HostedScanOptions) -> Result { @@ -103,6 +107,18 @@ pub(crate) fn resolve_options(options: &HostedScanOptions) -> Result, summary: ProjectSummary, packages: Vec, + /// Every accessible offer per purl, and the winner per purl. + offers: Offers, + /// Purls whose detail query failed. + failed_details: Vec, + /// The classified rows (§5.1) the run-wide rollout plan spends on. + rows: Vec, selected: Vec<(String, String)>, skipped: Vec, + deferred: Vec, error: Option, } @@ -351,6 +362,52 @@ fn unrooted_unsupported_warnings<'a>( } } +/// One root's recorded view (§5.1) in memory: its `.socket/manifest.json`, +/// the hosted pins its lockfiles name, and its vendor ledger — the disk +/// merge's precedence. A pin is a mention of an offered uuid for the purl +/// in one of the root's own files (a nested root's files are its own); a +/// pin to a patch the API no longer offers reads as NEW, which costs one +/// slot once instead of stalling. +fn memory_recorded( + project: &MemoryProject, + root: &str, + roots: &[String], + offers: &Offers, +) -> Option { + let manifest = project + .text(select::MANIFEST_REL) + .and_then(|text| serde_json::from_str(text).ok()); + let vendor = redirect::vendored_entries(project); + let nested: Vec = roots + .iter() + .filter(|other| other.as_str() != root) + .filter_map(|other| roots::strip_root(root, other).map(|rel| format!("{rel}/"))) + .filter(|rel| rel != "/") + .collect(); + let mut mentioned = std::collections::HashSet::new(); + for (path, entry) in project.entries() { + if path.starts_with(".socket/") || nested.iter().any(|n| path.starts_with(n.as_str())) { + continue; + } + if let MemoryEntry::Text(text) = entry { + mentioned_uuids(text, &mut mentioned); + } + } + let pins: Vec<(String, String)> = offers + .selected + .iter() + .filter_map(|(purl, selected)| { + let offered = offers.unfiltered.get(purl)?; + std::iter::once(selected) + .chain(offered.iter()) + .find(|p| mentioned.contains(&p.uuid.to_ascii_lowercase())) + .map(|p| (purl.clone(), p.uuid.clone())) + }) + .collect(); + crate::commands::scan::merge_ledger_records_for_updates(manifest.as_ref(), vendor.as_ref(), &pins) + .map(std::borrow::Cow::into_owned) +} + async fn engine( input: HostedScanInput, api: Arc, @@ -405,8 +462,12 @@ async fn engine( purls: Vec::new(), summary: ProjectSummary::default(), packages: Vec::new(), + offers: Offers::default(), + failed_details: Vec::new(), + rows: Vec::new(), selected: Vec::new(), skipped: Vec::new(), + deferred: Vec::new(), error: None, }) .collect(); @@ -457,6 +518,8 @@ async fn engine( .collect(); let batch = discover::batch_search(&provider, &root_purls, options.batch_size).await; let can_access_paid = batch.can_access_paid_patches; + // A package a failed batch hid could have been NEW (§5.2). + let mut batch_failed = false; for state in states.iter_mut().filter(|s| s.error.is_none()) { state.summary.can_access_paid_patches = can_access_paid; let Some(outcome) = batch.roots.get(&state.root) else { @@ -471,6 +534,7 @@ async fn engine( continue; } if outcome.failed_purls > 0 { + batch_failed = true; warnings.push(EngineWarning::new( "batch_failed", format!( @@ -511,8 +575,14 @@ async fn engine( for pkg in &state.packages { match details.get(&pkg.purl) { Some(Ok(response)) => results.extend(response.patches.iter().cloned()), - Some(Err(error)) => failures.push(error.clone()), - None => failures.push("patch details were not fetched".to_string()), + Some(Err(error)) => { + failures.push(error.clone()); + state.failed_details.push(pkg.purl.clone()); + } + None => { + failures.push("patch details were not fetched".to_string()); + state.failed_details.push(pkg.purl.clone()); + } } } if !failures.is_empty() && failures.len() == state.packages.len() { @@ -534,10 +604,38 @@ async fn engine( Some(&state.root), )); } - state.selected = discover::select_top_ranked(&results, can_access_paid); + state.offers = offers_from_results(&results, can_access_paid); } phases.mark("details"); + // Classify every root's selection against its recorded view (§5.1): + // the tree's manifest and vendor ledger, and the hosted pins its + // lockfiles name. ALREADY rows carry the recorded uuid, so a re-scan + // re-confirms a pin instead of swapping it. + let mut stage = Stage::new(options.max_new, None, std::path::Path::new("")); + let roots_by_path: Vec = states.iter().map(|s| s.root.clone()).collect(); + for state in states.iter_mut().filter(|s| s.error.is_none()) { + let Some(project) = state.project.as_ref() else { + continue; + }; + let recorded = memory_recorded(project, &state.root, &roots_by_path, &state.offers); + stage.incomplete |= lookup_incomplete( + recorded.as_ref(), + &state.failed_details, + batch_failed, + ); + let mut rows = classify(&state.offers, recorded.as_ref(), &state.root); + for row in &mut rows { + row.candidate.in_flight = options.in_flight.contains(&row.candidate.base_purl); + } + state.selected = rows + .iter() + .map(|r| (r.writer.purl.clone(), r.writer.uuid.clone())) + .collect(); + state.rows = rows; + } + phases.mark("classify"); + let uuids: BTreeSet = states .iter() .filter(|s| s.error.is_none()) @@ -548,12 +646,23 @@ async fn engine( } else { discover::fetch_references(&provider, &uuids).await }; + // Roots whose rows' eligibility is unknown: a reference failure that + // hit only NEW rows of a capped run defers them instead of failing the + // root (§5.2). + let mut unknown_roots: BTreeSet = BTreeSet::new(); for state in states.iter_mut().filter(|s| s.error.is_none()) { if let Some(error) = state .selected .iter() .find_map(|(_, uuid)| failed_refs.get(uuid)) { + if stage.capped() && state.rows.iter().all(|r| r.candidate.recorded.is_new()) { + stage.incomplete = true; + stage.reference_failed = Some(error.clone()); + unknown_roots.insert(state.root.clone()); + state.selected.clear(); + continue; + } state.fail( "reference_lookup_failed", format!("failed to resolve patch references: {error}"), @@ -588,26 +697,114 @@ async fn engine( }; phases.mark("plan"); - let stage = StageOptions { + let stage_options = StageOptions { dry_run: options.dry_run, pipenv_major: options.pipenv_major, trust_lockfile_config: options.trust_lockfile_config, npm_allow_remote_config: options.npm_allow_remote_config, }; let mut rewritten: Vec<(usize, Rewritten)> = Vec::new(); + let mut skipped_before: BTreeMap> = BTreeMap::new(); for (index, plan) in planned { checkpoint(&cancel).await?; - let skipped_before = plan.skipped.clone(); - match redirect::rewrite(plan, &wheel_metadata, stage) { + skipped_before.insert(index, plan.skipped.clone()); + match redirect::rewrite(plan, &wheel_metadata, stage_options) { Ok(done) => rewritten.push((index, done)), Err(Refused { error }) => { - states[index].skipped = skipped_before; + states[index].skipped = skipped_before[&index].clone(); states[index].error = Some(error); } } } phases.mark("rewrite"); + // The run-wide rollout plan (§5.2): one budget across every root, spent + // after each root's write-free checks (grants, takeover refusals, vlt, + // wheel metadata, the rewrite's confirmation probe). A root with + // deferred rows is rewritten again without them. + let confirmed: BTreeSet<(String, String)> = rewritten + .iter() + .flat_map(|(index, done)| { + let root = states[*index].root.clone(); + done.confirmed + .iter() + .map(move |(_, uuid)| (root.clone(), uuid.clone())) + }) + .collect(); + let all_rows: Vec = states + .iter() + .filter(|s| s.error.is_none()) + .flat_map(|s| s.rows.iter().cloned()) + .collect(); + stage.plan(&all_rows, |row| { + unknown_roots.contains(&row.candidate.project) + || confirmed.contains(&(row.candidate.project.clone(), row.writer.uuid.clone())) + }); + let deferred_rows: Vec<(socket_patch_core::rollout::Candidate, u32)> = + stage.plan.as_ref().map(|p| p.deferred.clone()).unwrap_or_default(); + if !deferred_rows.is_empty() { + for (row, rank) in &deferred_rows { + let Some(state) = states.iter_mut().find(|s| s.root == row.project) else { + continue; + }; + state.deferred.push(DeferredPatch { + purl: row.purl.clone(), + uuid: row.uuid.clone(), + severity: socket_patch_core::rollout::severity_label(row.severity_order).into(), + rank: *rank, + }); + } + let deferred_skip = |d: &DeferredPatch| SkippedPatch { + purl: d.purl.clone(), + uuid: d.uuid.clone(), + reason: ROLLOUT_DEFERRED.to_string(), + detail: Some(format!( + "rank {} in the rollout queue; a later scan adds it", + d.rank + )), + }; + let mut again: Vec<(usize, Rewritten)> = Vec::with_capacity(rewritten.len()); + for (index, done) in rewritten { + let root_deferred: BTreeSet = states[index] + .deferred + .iter() + .map(|d| d.uuid.clone()) + .collect(); + if root_deferred.is_empty() { + again.push((index, done)); + continue; + } + checkpoint(&cancel).await?; + let mut plan = done.planned; + plan.skipped = skipped_before.remove(&index).unwrap_or_default(); + plan.candidates + .retain(|c| !root_deferred.contains(&c.dep.patch_uuid)); + plan.skipped + .extend(states[index].deferred.iter().map(deferred_skip)); + let skipped_now = plan.skipped.clone(); + match redirect::rewrite(plan, &wheel_metadata, stage_options) { + Ok(done) => again.push((index, done)), + Err(Refused { error }) => { + states[index].skipped = skipped_now; + states[index].error = Some(error); + } + } + } + rewritten = again; + // Roots that never reached the rewrite (unknown eligibility) list + // their deferred rows as skipped too. + for state in states.iter_mut() { + if unknown_roots.contains(&state.root) { + let extra: Vec = state.deferred.iter().map(deferred_skip).collect(); + state.skipped.extend(extra); + } + } + } + for (code, detail) in stage.warnings() { + warnings.push(EngineWarning::new(code, detail, None)); + } + phases.mark("rollout"); + let record_uuids: BTreeSet = if options.dry_run { BTreeSet::new() } else { @@ -661,6 +858,7 @@ async fn engine( summary: state.summary.clone(), redirected: Vec::new(), skipped: state.skipped.clone(), + deferred: state.deferred.clone(), error: state.error.clone(), }); } @@ -695,6 +893,7 @@ async fn engine( changed_binary_files, deleted_files: Vec::new(), warnings, + rollout: stage.json(), stats, engine_version: engine_version(), }) @@ -790,6 +989,7 @@ fn finish_root( summary: state.summary.clone(), redirected: Vec::new(), skipped: planned.skipped, + deferred: state.deferred.clone(), error: Some(ProjectError { code: "conflicting_write".into(), message, @@ -838,6 +1038,7 @@ fn finish_root( .map(|(purl, uuid)| RedirectedPatch { purl, uuid }) .collect(), skipped: planned.skipped, + deferred: state.deferred.clone(), error: None, } } @@ -855,8 +1056,12 @@ mod tests { purls: Vec::new(), summary: ProjectSummary::default(), packages: Vec::new(), + offers: Offers::default(), + failed_details: Vec::new(), + rows: Vec::new(), selected: Vec::new(), skipped: Vec::new(), + deferred: Vec::new(), error: None, } } diff --git a/crates/socket-patch-cli/src/hosted_memory/redirect.rs b/crates/socket-patch-cli/src/hosted_memory/redirect.rs index 7848425c..24c8ca05 100644 --- a/crates/socket-patch-cli/src/hosted_memory/redirect.rs +++ b/crates/socket-patch-cli/src/hosted_memory/redirect.rs @@ -258,7 +258,7 @@ fn symlink_refusal(linked: &str) -> Refused { /// The vendored ledger's entries (the disk `vendor::load_state` parse, /// including its legacy `{mode}`-only shape); `None` when absent or /// unreadable. -fn vendored_entries(project: &MemoryProject) -> Option { +pub(crate) fn vendored_entries(project: &MemoryProject) -> Option { let bytes: Vec = match project.get(VENDOR_STATE_REL)? { MemoryEntry::Text(text) => text.as_bytes().to_vec(), MemoryEntry::Binary(bytes) => bytes.to_vec(), diff --git a/crates/socket-patch-cli/src/hosted_memory/select.rs b/crates/socket-patch-cli/src/hosted_memory/select.rs index b7d939af..149638c7 100644 --- a/crates/socket-patch-cli/src/hosted_memory/select.rs +++ b/crates/socket-patch-cli/src/hosted_memory/select.rs @@ -35,8 +35,12 @@ pub(crate) const VENDOR_STATE_REL: &str = ".socket/vendor/state.json"; /// Rush's repo-state file (presence feeds the stale-hash warning). pub(crate) const RUSH_REPO_STATE_REL: &str = "common/config/rush/repo-state.json"; +/// The agent-mode manifest: part of the recorded view the rollout cap +/// classifies against (a package it records is not NEW). +pub(crate) const MANIFEST_REL: &str = ".socket/manifest.json"; + /// Root-relative text files read beyond `REDIRECT_CANDIDATE_FILES`. -const EXTRA_TEXT_FILES: [&str; 3] = [PNPM_WORKSPACE_REL, NPMRC_REL, VENDOR_STATE_REL]; +const EXTRA_TEXT_FILES: [&str; 4] = [PNPM_WORKSPACE_REL, NPMRC_REL, VENDOR_STATE_REL, MANIFEST_REL]; /// The one directory name the disk Cargo member walk never enters (it /// follows `members`, `exclude`, path dependencies and `[patch]` paths diff --git a/crates/socket-patch-cli/src/hosted_memory/types.rs b/crates/socket-patch-cli/src/hosted_memory/types.rs index ed8e3a84..b75c2817 100644 --- a/crates/socket-patch-cli/src/hosted_memory/types.rs +++ b/crates/socket-patch-cli/src/hosted_memory/types.rs @@ -100,6 +100,68 @@ pub struct HostedScanOptions { pub request_timeout_ms: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub limits: Option, + /// The run-wide cap on NEW patches (`scan --max-new-patches`); absent + /// or `"none"` is unlimited, 0 admits upgrades only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_new_patches: Option, + /// A server ceiling applied on top of `maxNewPatches`, `"none"` + /// included: it can tighten the cap, never loosen it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_new_patches_cap: Option, + /// Base purls already proposed in an open rollout PR: ranked first, so + /// a newly published patch never displaces one under review. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub in_flight_patches: Option>, +} + +/// `maxNewPatches`: a count, or `"none"` (`None`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MaxNewPatchesOption(pub Option); + +impl Serialize for MaxNewPatchesOption { + fn serialize(&self, s: S) -> Result { + match self.0 { + Some(n) => s.serialize_u32(n), + None => s.serialize_str("none"), + } + } +} + +impl<'de> Deserialize<'de> for MaxNewPatchesOption { + fn deserialize>(d: D) -> Result { + struct Visitor; + impl serde::de::Visitor<'_> for Visitor { + type Value = MaxNewPatchesOption; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a patch count (0 to 4294967295) or \"none\"") + } + fn visit_u64(self, v: u64) -> Result { + u32::try_from(v) + .map(|n| MaxNewPatchesOption(Some(n))) + .map_err(|_| E::custom("maxNewPatches exceeds 4294967295")) + } + fn visit_i64(self, v: i64) -> Result { + u64::try_from(v) + .map_err(|_| E::custom("maxNewPatches must not be negative")) + .and_then(|v| self.visit_u64(v)) + } + fn visit_f64(self, v: f64) -> Result { + if v.fract() == 0.0 && (0.0..=f64::from(u32::MAX)).contains(&v) { + Ok(MaxNewPatchesOption(Some(v as u32))) + } else { + Err(E::custom("maxNewPatches must be a whole number of patches")) + } + } + fn visit_str(self, v: &str) -> Result { + if v == "none" { + Ok(MaxNewPatchesOption(None)) + } else { + Err(E::custom(format!("maxNewPatches must be a number or \"none\", not `{v}`"))) + } + } + } + d.deserialize_any(Visitor) + } } pub const DEFAULT_BATCH_SIZE: u32 = 100; @@ -228,10 +290,26 @@ pub struct ProjectResult { pub summary: ProjectSummary, pub redirected: Vec, pub skipped: Vec, + /// NEW patches over the run-wide `maxNewPatches` budget, in rank order + /// (also in `skipped[]` as `rollout_deferred`). + #[serde(default)] + pub deferred: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub error: Option, } +/// One deferred NEW patch. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeferredPatch { + pub purl: String, + pub uuid: String, + /// `critical` … `unknown`. + pub severity: String, + /// 1-based rank among the run's eligible NEW base purls. + pub rank: u32, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ChangedFile { @@ -276,6 +354,8 @@ pub struct HostedScanOutput { pub changed_binary_files: Vec, pub deleted_files: Vec, pub warnings: Vec, + /// The session-level `rollout` block, the CLI `--json` shape. + pub rollout: serde_json::Value, pub stats: EngineStats, pub engine_version: String, } diff --git a/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs b/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs index c0e60e47..25d41065 100644 --- a/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs +++ b/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs @@ -312,6 +312,64 @@ pub struct DiskRun { /// node / pipenv / gem subprocesses), `HOME` and the language caches at /// empty directories, no socket-cli config, no telemetry. pub fn run_disk(server: &MockServer, files: &BTreeMap>, dry_run: bool) -> DiskRun { + run_disk_with(server, files, dry_run, &[]) +} + +/// A human (non-`--json`) disk scan with `args`: exit code, stdout, and the +/// files it changed. +pub fn run_disk_args( + server: &MockServer, + files: &BTreeMap>, + args: &[&str], +) -> (i32, String, BTreeMap>) { + let (project, _home, mut cmd) = disk_command(server, files); + cmd.args(args); + let output = cmd.output().expect("spawn socket-patch"); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let after = read_tree(project.path()); + let changed = after + .into_iter() + .filter(|(rel, bytes)| files.get(rel) != Some(bytes)) + .collect(); + (output.status.code().unwrap_or(-1), stdout, changed) +} + +/// [`run_disk`] with extra `scan --json` arguments. +pub fn run_disk_with( + server: &MockServer, + files: &BTreeMap>, + dry_run: bool, + extra: &[&str], +) -> DiskRun { + let (project, _home, mut cmd) = disk_command(server, files); + cmd.arg("--json"); + if dry_run { + cmd.arg("--dry-run"); + } + cmd.args(extra); + let output = cmd.output().expect("spawn socket-patch"); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let envelope: Value = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("disk --json output is not JSON ({e}):\n{stdout}\n{stderr}")); + let after = read_tree(project.path()); + let changed = after + .into_iter() + .filter(|(rel, bytes)| files.get(rel) != Some(bytes)) + .collect(); + DiskRun { + envelope, + changed, + stderr, + } +} + +/// The scrubbed `scan --mode hosted` command over a copy of `files` (the +/// returned tempdirs, the project and `HOME`, must outlive the run). +fn disk_command( + server: &MockServer, + files: &BTreeMap>, +) -> (tempfile::TempDir, tempfile::TempDir, std::process::Command) { let project = tempfile::tempdir().unwrap(); let home = tempfile::tempdir().unwrap(); for (rel, bytes) in files { @@ -353,7 +411,6 @@ pub fn run_disk(server: &MockServer, files: &BTreeMap>, dry_run: "scan", "--mode", "hosted", - "--json", "--yes", "--cwd", project.path().to_str().unwrap(), @@ -364,24 +421,7 @@ pub fn run_disk(server: &MockServer, files: &BTreeMap>, dry_run: "--api-url", &server.uri(), ]); - if dry_run { - cmd.arg("--dry-run"); - } - let output = cmd.output().expect("spawn socket-patch"); - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - let envelope: Value = serde_json::from_str(&stdout) - .unwrap_or_else(|e| panic!("disk --json output is not JSON ({e}):\n{stdout}\n{stderr}")); - let after = read_tree(project.path()); - let changed = after - .into_iter() - .filter(|(rel, bytes)| files.get(rel) != Some(bytes)) - .collect(); - DiskRun { - envelope, - changed, - stderr, - } + (project, home, cmd) } /// The engine's changed files (text and binary) as bytes. diff --git a/crates/socket-patch-cli/tests/hosted_memory_rollout.rs b/crates/socket-patch-cli/tests/hosted_memory_rollout.rs new file mode 100644 index 00000000..1d5847c2 --- /dev/null +++ b/crates/socket-patch-cli/tests/hosted_memory_rollout.rs @@ -0,0 +1,444 @@ +//! The staged rollout (`maxNewPatches`) in the in-memory hosted engine, +//! held to the disk `scan --mode hosted --json --max-new-patches` run: one +//! root admits and defers the same rows run after run until converged; two +//! roots show memory's run-wide budget next to disk's per-directory one +//! (they differ by design, §5.2); a committed manifest, vendor ledger or +//! lockfile pin counts as recorded, never NEW. + +use std::collections::BTreeMap; + +use serde_json::{json, Value}; +use socket_patch_cli::hosted_memory::{HostedScanOptions, HostedScanOutput, MaxNewPatchesOption}; +use wiremock::matchers::{method, path, path_regex}; +use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; + +#[path = "hosted_memory_common/mod.rs"] +mod common; + +use common::{build_input, run_disk_args, run_disk_with, run_engine, ORG}; + +const TOKEN: &str = "22222222-2222-4222-8222-222222222222"; + +/// `(name, severities)`: one advisory per severity. +const PACKAGES: [(&str, &[&str]); 5] = [ + ("mem-a", &["low"]), + ("mem-b", &["critical"]), + ("mem-c", &["high"]), + ("mem-d", &["medium"]), + ("mem-e", &["critical", "high"]), +]; + +/// Most severe first: severity, then advisory count, then name. +const ORDER: [&str; 5] = ["mem-e", "mem-b", "mem-c", "mem-d", "mem-a"]; + +fn uuid(name: &str) -> String { + let n = PACKAGES.iter().position(|(p, _)| *p == name).unwrap() + 1; + format!("{n:08x}-3333-4333-8333-{n:012x}") +} + +fn purl(name: &str) -> String { + format!("pkg:npm/{name}@1.0.0") +} + +fn url(name: &str) -> String { + format!( + "https://patch.socket.dev/patch/npm/{name}/1.0.0/{TOKEN}/{}/{name}-1.0.0.tgz", + uuid(name) + ) +} + +fn name_of(purl: &str) -> Option<&'static str> { + PACKAGES + .iter() + .map(|(n, _)| *n) + .find(|n| purl.contains(&format!("{n}@")) || purl.contains(&format!("{n}%40"))) +} + +fn vulns(name: &str) -> Value { + let sevs = PACKAGES.iter().find(|(n, _)| *n == name).unwrap().1; + let mut map = serde_json::Map::new(); + for (i, sev) in sevs.iter().enumerate() { + map.insert( + format!("GHSA-{name}-{i}"), + json!({ "cves": [], "summary": "s", "severity": sev, "description": "d" }), + ); + } + Value::Object(map) +} + +struct Batch; +impl Respond for Batch { + fn respond(&self, request: &Request) -> ResponseTemplate { + let body: Value = serde_json::from_slice(&request.body).unwrap_or(Value::Null); + let packages: Vec = body["components"] + .as_array() + .into_iter() + .flatten() + .filter_map(|c| { + let p = c["purl"].as_str()?; + let name = name_of(p)?; + Some(json!({ "purl": p, "patches": [{ + "uuid": uuid(name), "purl": p, "tier": "free", "cveIds": [], + "ghsaIds": [format!("GHSA-{name}-0")], "severity": "high", "title": name + }]})) + }) + .collect(); + ResponseTemplate::new(200) + .set_body_json(json!({ "packages": packages, "canAccessPaidPatches": false })) + } +} + +struct ByPackage; +impl Respond for ByPackage { + fn respond(&self, request: &Request) -> ResponseTemplate { + let patches: Vec = name_of(request.url.path()) + .map(|name| { + vec![json!({ + "uuid": uuid(name), "purl": purl(name), + "publishedAt": "2024-01-01T00:00:00Z", + "description": name, "license": "MIT", "tier": "free", + "vulnerabilities": vulns(name), + })] + }) + .unwrap_or_default(); + ResponseTemplate::new(200) + .set_body_json(json!({ "patches": patches, "canAccessPaidPatches": false })) + } +} + +struct References; +impl Respond for References { + fn respond(&self, request: &Request) -> ResponseTemplate { + let body: Value = serde_json::from_slice(&request.body).unwrap_or(Value::Null); + let mut results = serde_json::Map::new(); + for requested in body["uuids"].as_array().into_iter().flatten() { + let Some(requested) = requested.as_str() else { + continue; + }; + if let Some((name, _)) = PACKAGES.iter().find(|(n, _)| uuid(n) == requested) { + results.insert( + requested.to_string(), + json!({ + "status": "granted", "url": url(name), "purl": null, + "artifacts": [{ "kind": "tarball", "url": url(name), + "integrity": { "sha512": format!("sha512-PATCHED{name}==") } }], + "registryOverride": null + }), + ); + } + } + ResponseTemplate::new(200).set_body_json(json!({ "results": results })) + } +} + +struct View; +impl Respond for View { + fn respond(&self, request: &Request) -> ResponseTemplate { + let requested = request.url.path().rsplit('/').next().unwrap_or(""); + match PACKAGES.iter().find(|(n, _)| uuid(n) == requested) { + Some((name, _)) => ResponseTemplate::new(200).set_body_json(json!({ + "uuid": uuid(name), "purl": purl(name), "publishedAt": "2024-01-01T00:00:00Z", + "files": { "package/index.js": { "beforeHash": "a".repeat(64), "afterHash": "b".repeat(64) } }, + "vulnerabilities": vulns(name), + "description": name, "license": "MIT", "tier": "free" + })), + None => ResponseTemplate::new(404), + } + } +} + +async fn mount(server: &MockServer) { + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(Batch) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) + .respond_with(ByPackage) + .mount(server) + .await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with(References) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path_regex(format!("^/v0/orgs/{ORG}/patches/view/.+$"))) + .respond_with(View) + .mount(server) + .await; +} + +/// A v3 package-lock at `dir` locking `names` (no install needed: both +/// engines read the lock). +fn lock(files: &mut BTreeMap>, dir: &str, names: &[&str]) { + let prefix = if dir.is_empty() { + String::new() + } else { + format!("{dir}/") + }; + let deps: serde_json::Map = names + .iter() + .map(|n| (n.to_string(), json!("1.0.0"))) + .collect(); + let mut packages = serde_json::Map::new(); + packages.insert( + String::new(), + json!({ "name": "c", "version": "0.0.0", "dependencies": deps }), + ); + for n in names { + packages.insert( + format!("node_modules/{n}"), + json!({ + "version": "1.0.0", + "resolved": format!("https://registry.npmjs.org/{n}/-/{n}-1.0.0.tgz"), + "integrity": "sha512-UPSTREAM==" + }), + ); + } + let mut text = serde_json::to_vec_pretty(&json!({ + "name": "c", "version": "0.0.0", "lockfileVersion": 3, "requires": true, + "packages": packages + })) + .unwrap(); + text.push(b'\n'); + files.insert(format!("{prefix}package-lock.json"), text); + files.insert( + format!("{prefix}package.json"), + serde_json::to_vec(&json!({ "name": "c", "version": "0.0.0", "dependencies": deps })) + .unwrap(), + ); +} + +fn options(cap: Option) -> HostedScanOptions { + HostedScanOptions { + org_slug: ORG.to_string(), + max_new_patches: cap.map(|n| MaxNewPatchesOption(Some(n))), + ..HostedScanOptions::default() + } +} + +async fn memory( + server: &MockServer, + files: &BTreeMap>, + o: HostedScanOptions, +) -> HostedScanOutput { + run_engine(server, build_input(files, &[], o)).await +} + +/// `files` with the engine's changed files applied: the next run's input. +fn apply(files: &BTreeMap>, out: &HostedScanOutput) -> BTreeMap> { + let mut next = files.clone(); + for f in &out.changed_files { + next.insert(f.path.clone(), f.content.clone().into_bytes()); + } + next +} + +fn pinned(files: &BTreeMap>, lock_path: &str) -> Vec<&'static str> { + let text = String::from_utf8_lossy(&files[lock_path]).into_owned(); + ORDER + .iter() + .copied() + .filter(|n| text.contains(&uuid(n))) + .collect() +} + +#[tokio::test] +async fn one_root_disk_and_memory_admit_and_defer_the_same_rows_until_converged() { + let server = MockServer::start().await; + mount(&server).await; + let mut files = BTreeMap::new(); + let names: Vec<&str> = PACKAGES.iter().map(|(n, _)| *n).collect(); + lock(&mut files, "", &names); + + for (run, expected) in [&ORDER[..2], &ORDER[..4], &ORDER[..5], &ORDER[..5]] + .iter() + .enumerate() + { + let disk = run_disk_with(&server, &files, false, &["--max-new-patches", "2"]); + let mem = memory(&server, &files, options(Some(2))).await; + let project = &mem.projects[0]; + assert!(project.error.is_none(), "{:?}", project.error); + assert_eq!( + mem.rollout, + disk.envelope["rollout"], + "run {}: the rollout blocks agree\nstderr: {}", + run + 1, + disk.stderr + ); + assert_eq!( + project.redirect, + disk.envelope["redirect"], + "run {}: the redirect blocks agree", + run + 1 + ); + let next = apply(&files, &mem); + for (rel, bytes) in &disk.changed { + assert_eq!( + String::from_utf8_lossy(&next[rel]), + String::from_utf8_lossy(bytes), + "run {}: {rel}", + run + 1 + ); + } + assert_eq!(pinned(&next, "package-lock.json"), expected.to_vec()); + let deferred: Vec<&str> = project.deferred.iter().map(|d| d.purl.as_str()).collect(); + let want: Vec = ORDER[expected.len()..].iter().map(|n| purl(n)).collect(); + assert_eq!( + deferred, + want.iter().map(String::as_str).collect::>() + ); + let skipped: Vec<&str> = project + .skipped + .iter() + .filter(|s| s.reason == "rollout_deferred") + .map(|s| s.purl.as_str()) + .collect(); + assert_eq!(skipped, deferred, "deferred rows are mirrored in skipped[]"); + files = next; + } + assert_eq!( + memory(&server, &files, options(Some(2))).await.rollout["counts"], + json!({ "new": 0, "deferred": 0, "upgrade": 0, "already": 5 }) + ); +} + +#[tokio::test] +async fn two_roots_spend_one_budget_in_memory_and_one_per_directory_on_disk() { + let server = MockServer::start().await; + mount(&server).await; + let mut files = BTreeMap::new(); + lock(&mut files, "a", &["mem-a", "mem-b"]); + lock(&mut files, "b", &["mem-c", "mem-d", "mem-e"]); + + // Memory: one run-wide queue, e (b/), b (a/) first. + let mem = memory(&server, &files, options(Some(2))).await; + assert_eq!( + mem.rollout["counts"], + json!({ "new": 2, "deferred": 3, "upgrade": 0, "already": 0 }) + ); + let next = apply(&files, &mem); + assert_eq!(pinned(&next, "a/package-lock.json"), ["mem-b"]); + assert_eq!(pinned(&next, "b/package-lock.json"), ["mem-e"]); + let ranks: Vec<(String, u32)> = mem + .projects + .iter() + .flat_map(|p| p.deferred.iter().map(|d| (d.purl.clone(), d.rank))) + .collect(); + assert_eq!( + ranks, + [(purl("mem-a"), 5), (purl("mem-c"), 3), (purl("mem-d"), 4)], + "ranks are run-wide" + ); + + // Disk: the directories spend the budget in sorted order, so `a/` + // takes both slots before `b/` is visited. + let (code, stdout, changed) = + run_disk_args(&server, &files, &["--max-new-patches", "2", "a", "b"]); + assert_eq!(code, 0, "{stdout}"); + let mut disk_files = files.clone(); + disk_files.extend(changed); + assert_eq!( + pinned(&disk_files, "a/package-lock.json"), + ["mem-b", "mem-a"] + ); + assert!( + pinned(&disk_files, "b/package-lock.json").is_empty(), + "{stdout}" + ); +} + +#[tokio::test] +async fn memory_counts_a_committed_manifest_vendor_entry_or_pin_as_recorded() { + let server = MockServer::start().await; + mount(&server).await; + let mut files = BTreeMap::new(); + let names: Vec<&str> = PACKAGES.iter().map(|(n, _)| *n).collect(); + lock(&mut files, "", &names); + // mem-e is recorded by the agent manifest, mem-b by the vendor ledger. + files.insert( + ".socket/manifest.json".into(), + serde_json::to_vec(&json!({ "patches": { purl("mem-e"): { + "uuid": uuid("mem-e"), "exportedAt": "", "files": {}, "vulnerabilities": {}, + "description": "", "license": "", "tier": "free" + }}})) + .unwrap(), + ); + files.insert( + ".socket/vendor/state.json".into(), + serde_json::to_vec(&json!({ + "version": 1, + "entries": { purl("mem-b"): { + "ecosystem": "npm", "uuid": uuid("mem-b"), "basePurl": purl("mem-b"), + "artifact": { "path": format!(".socket/vendor/npm/{}/mem-b-1.0.0.tgz", uuid("mem-b")) }, + "wiring": [] + }} + })) + .unwrap(), + ); + let mem = memory(&server, &files, options(Some(1))).await; + let counts = &mem.rollout["counts"]; + assert_eq!(counts["new"], 1, "{:#}", mem.rollout); + assert_eq!(counts["already"], 2, "{:#}", mem.rollout); + let next = apply(&files, &mem); + assert!( + pinned(&next, "package-lock.json").contains(&"mem-c"), + "{:#}", + mem.rollout + ); +} + +#[tokio::test] +async fn memory_in_flight_patches_go_first_and_the_server_cap_tightens() { + let server = MockServer::start().await; + mount(&server).await; + let mut files = BTreeMap::new(); + let names: Vec<&str> = PACKAGES.iter().map(|(n, _)| *n).collect(); + lock(&mut files, "", &names); + let mem = memory( + &server, + &files, + HostedScanOptions { + max_new_patches: Some(MaxNewPatchesOption(None)), + max_new_patches_cap: Some(1), + in_flight_patches: Some(vec![purl("mem-a")]), + ..options(None) + }, + ) + .await; + assert_eq!( + mem.rollout["maxNewPatches"], + json!({ "value": 1, "source": "cap" }) + ); + assert_eq!( + pinned(&apply(&files, &mem), "package-lock.json"), + ["mem-a"], + "the in-flight patch keeps its slot" + ); +} + +#[test] +fn the_max_new_patches_option_takes_a_count_or_none() { + let parse = |v: Value| { + serde_json::from_value::(json!({ "orgSlug": "o", "maxNewPatches": v })) + }; + assert_eq!( + parse(json!(3)).unwrap().max_new_patches, + Some(MaxNewPatchesOption(Some(3))) + ); + assert_eq!( + parse(json!("none")).unwrap().max_new_patches, + Some(MaxNewPatchesOption(None)) + ); + for bad in [json!(-1), json!(1.5), json!("all"), json!(4294967296u64)] { + assert!(parse(bad.clone()).is_err(), "{bad}"); + } + assert_eq!( + serde_json::to_value(MaxNewPatchesOption(None)).unwrap(), + json!("none") + ); +} diff --git a/crates/socket-patch-node/npm/index.d.ts b/crates/socket-patch-node/npm/index.d.ts index e5fc61dc..c2d20e28 100644 --- a/crates/socket-patch-node/npm/index.d.ts +++ b/crates/socket-patch-node/npm/index.d.ts @@ -48,6 +48,9 @@ export interface HostedScanSessionOptions { providerConcurrency?: number // default 8 requestTimeoutMs?: number // per provider call, default 60000 limits?: HostedScanLimits + maxNewPatches?: number | 'none' // run-wide cap on NEW patches, most severe first; 0 = upgrades only; absent/'none' = unlimited + maxNewPatchesCap?: number // server ceiling: tightens maxNewPatches (including 'none'), never loosens it + inFlightPatches?: string[] // base purls already in the open rollout PR: ranked first } export class HostedScanSession { constructor(options: HostedScanSessionOptions, provider: PatchProvider) @@ -63,15 +66,23 @@ export interface ProjectResult { redirect: Record // same shape as CLI `--json` `redirect` block summary: { scannedPackages: number; packagesWithPatches: number; totalPatches: number; freePatches: number; paidPatches: number; canAccessPaidPatches: boolean } redirected: { purl: string; uuid: string }[] - skipped: { purl: string; uuid: string; reason: string; detail?: string }[] + skipped: { purl: string; uuid: string; reason: string; detail?: string }[] // deferred rows also appear here as `rollout_deferred` + deferred: DeferredPatch[] // NEW patches over the maxNewPatches budget, rank order error?: { code: string; message: string } // project-level failure (e.g. corrupt_ledger, patch_lookup_failed) } +export interface DeferredPatch { purl: string; uuid: string; severity: 'critical' | 'high' | 'medium' | 'low' | 'unknown'; rank: number } +export interface RolloutBlock { // same shape as CLI `scan --json` `rollout` + maxNewPatches: { value: number | null; source: 'flag' | 'env' | 'file' | 'default' | 'cap' } + counts: { new: number; deferred: number; upgrade: number; already: number } + deferred: { purl: string; uuids: string[]; severity: string; advisoryCount: number; projects: string[]; rank: number }[] +} export interface HostedScanResult { projects: ProjectResult[] changedFiles: { path: string; content: string }[] // repo-relative, sorted, only byte-changed, includes ledgers (wet runs only) changedBinaryFiles: { path: string; content: Buffer }[] deletedFiles: string[] warnings: EngineWarning[] + rollout: RolloutBlock // session-level: one budget across every project stats: { projects: number; filesInput: number; bytesInput: number; packagesScanned: number; packagesWithPatches: number; patchesSelected: number; patchesRedirected: number; filesChanged: number; providerCalls: Record; phaseMs: Record } engineVersion: string } diff --git a/crates/socket-patch-node/npm/test/smoke.mjs b/crates/socket-patch-node/npm/test/smoke.mjs index 826932ca..0009f955 100644 --- a/crates/socket-patch-node/npm/test/smoke.mjs +++ b/crates/socket-patch-node/npm/test/smoke.mjs @@ -234,6 +234,24 @@ test('dry run previews the lockfile without patch fetches', async () => { assert.equal(calls.fetchPatch, 0) }) +test('maxNewPatches defers new patches and reports the rollout block', async () => { + const { provider } = fakeProvider() + const selection = addon.selectHostedScanPaths(tree) + const session = new addon.HostedScanSession({ orgSlug: 'test-org', maxNewPatches: 0 }, provider) + streamSelection(session, selection) + const result = await session.finish() + const [project] = result.projects + assert.deepEqual(project.redirected, []) + assert.deepEqual( + project.deferred.map((d) => [d.purl, d.rank]), + [['pkg:npm/left-pad@1.3.0', 1]], + ) + assert.ok(project.skipped.some((s) => s.reason === 'rollout_deferred')) + assert.deepEqual(result.rollout.maxNewPatches, { value: 0, source: 'flag' }) + assert.deepEqual(result.rollout.counts, { new: 0, deferred: 1, upgrade: 0, already: 0 }) + assert.deepEqual(result.changedFiles, []) +}) + test('provider failures become project errors, never rejections', async () => { for (const searchPatchesBatch of [ async () => ({ ok: false, error: { kind: 'unauthorized', message: 'token revoked' } }), From 7eebde4d3054be1f97ba503d1f30a80de2cb1507 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 14:28:54 +0000 Subject: [PATCH 11/22] Document the per-run cap on new patches CLI_CONTRACT.md gains the limit's semantics (classification, eligibility, unit, budget scope, order, convergence), the flag and env rows, the rollout block, rollout_deferred and a jq recipe; "Which patch gets selected" now describes the by-package supersession and the separate cross-package order. README adds a gradual-rollout task and the flag row; CHANGELOG adds both entries. The design doc records the gaps B decided. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 27 +++++++++ README.md | 40 ++++++++++++- crates/socket-patch-cli/CLI_CONTRACT.md | 79 +++++++++++++++++++++++-- docs/design/staged-rollout.md | 47 +++++++++++++++ 4 files changed, 186 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f92db53..5b272721 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -562,6 +562,24 @@ into the new version's section — see docs/releasing.md. ### Added +- **`scan --max-new-patches ` rolls patches out gradually** + (env `SOCKET_MAX_NEW_PATCHES`; socket.yml `patches.maxNewPatches`). Each + run adds at most N patches to packages that had none, most severe first + (then by how many advisories a patch fixes), and defers the rest to the + next run; upgrades of packages that are already patched are never + capped, and `0` means upgrades only. Repeated scans on an unchanged repo + add the same packages in the same order and stop once everything is + patched. A patch that cannot land (not granted, refused by a preflight, + nothing in the lockfile to pin) never holds a slot, and a failed lookup + admits nothing new that run (`rollout_incomplete_lookup`). The project + directories of one scan share the budget. Works in hosted, vendored and + agent mode, `--dry-run` included; `scan --json` gains a top-level + `rollout` block (`maxNewPatches`, `counts`, ranked `deferred[]`) and + hosted mode lists deferred rows in `redirect.skipped[]` as + `rollout_deferred`. The in-memory engine (napi, `hosted-bundle`) takes + `maxNewPatches`, `maxNewPatchesCap` and `inFlightPatches`, spends one + budget across every project root, and reports a session `rollout` block + and `ProjectResult.deferred[]`. - **`scan --package `** (repeatable or comma-separated, env `SOCKET_SCAN_PACKAGES`) scopes a scan to the named packages: a name (`lodash`, `@scope/pkg`, `group:artifact`) or a purl with or without its @@ -1786,6 +1804,15 @@ into the new version's section — see docs/releasing.md. ### Changed +- **`scan` keeps a patch you already have unless the new one supersedes + it.** A package whose recorded patch (agent manifest, hosted lockfile + pin or vendor ledger) still ranks level with the top offer on every + meaningful rung (merged state, severity, a later publish date) keeps + its recorded patch instead of switching on the tier or uuid tiebreak, + so re-running `scan` never swaps patches. `updates[]` and the + `[UPDATE]` marker now use the per-package records the selection itself + uses, so they list exactly the upgrades the run applies; a JSON + report-only run still reads the batch records. - **The npm crawl skips tagged cache directories.** The walk that finds workspace `node_modules` trees no longer descends into a directory that carries a [Cache Directory Tagging](https://bford.info/cachedir/) diff --git a/README.md b/README.md index 645bf414..b990bb68 100644 --- a/README.md +++ b/README.md @@ -257,7 +257,13 @@ the same way everywhere, from the patches your account can download: "Newest" is when the patch was published, not the package version. When a better patch appears for a package you already patched, the JSON `updates[]` array lists it and the -next `scan` in the same mode takes it. +next `scan` in the same mode takes it. A patch that only wins on the tier or UUID +tiebreak never replaces one you already have, so re-running `scan` never swaps patches. + +This order picks the patch *for* a package. When a capped scan +([`--max-new-patches`](#add-a-few-new-patches-per-run)) has to choose *which packages* +get their first patch, it takes the most severe first, then the ones fixing the most +advisories. ### State in `.socket/` @@ -413,6 +419,34 @@ socket-patch scan 'services/*' # directory glo hosted and vendored mode each PATH is a project directory, scanned as if it were `--cwd` under an `== ==` header; the worst exit code wins. +### Add a few new patches per run + +```bash +socket-patch scan --max-new-patches 5 # at most 5 packages get their first patch +socket-patch scan --max-new-patches 0 # only upgrade patches you already have +socket-patch scan --max-new-patches none # no cap this run (overrides socket.yml) +``` + +A capped scan patches the most critical packages first: by the severity of the patch, +then by how many advisories it fixes. Upgrades of packages that are already patched are +never capped. Commit the result and run `scan` again to add the next batch; repeated +runs on an unchanged repo add the same packages in the same order and stop once +everything is patched. `--dry-run` shows exactly what the run would add and defer, and +`--json` reports it under `rollout` (`jq '.rollout.counts.deferred'`). With several +project directories (`scan apps/*`) the cap is shared, visited in sorted order. + +To drip patches in through a PR bot, set the cap once in the repo's `socket.yml`: + +```yaml +# Weekly drip with the depscan autopatch PR +version: 2 +patches: + maxNewPatches: 5 # the PR keeps the same 5 until merged, then the next 5 +``` + +A cap only advances when the scan's changes are committed (or merged by a PR bot). In a +CI job that scans without committing, set no cap. + ### Patch one specific CVE or advisory ```bash @@ -649,6 +683,7 @@ socket-patch scan [PATHS]... [options] | `--package ` | `SOCKET_SCAN_PACKAGES` | Only scan these packages: a name (`lodash`, `@scope/pkg`, `requests`; case-insensitive) or a purl with or without its version (`pkg:npm/lodash`, `pkg:pypi/requests@2.31.0`). Repeat the flag or separate with commas. | | `--prune` | — | Agent-mode garbage collection after the scan: remove manifest entries for packages no longer present in the crawl (installed trees + lockfiles — a wiped `node_modules` alone doesn't prune lockfile-listed entries) and delete orphan blob/diff/package-archive files. [Vendored](#vendor) packages are exempt from the crawl-based prune, but a vendored entry whose dependency has left the lockfile is reverted. Ignored, with a `redirect_prune_ignored` warning, in hosted mode; without a mode the scan is report-only. | | `--sync` | — | Shorthand for `--mode agent --prune`: the one-flag agent-mode auto-update run. | +| `--max-new-patches ` | `SOCKET_MAX_NEW_PATCHES` | Add at most N patches to packages that have none yet, most severe first; the rest are deferred to the next scan and listed in the output. Upgrades of already-patched packages are not capped. `0` adds no new patches, `none` lifts a cap set in `socket.yml` (`patches.maxNewPatches`). See [Add a few new patches per run](#add-a-few-new-patches-per-run). | | `--batch-size ` | `SOCKET_BATCH_SIZE` | Packages per API request (default: `500` on the authenticated API, `100` on the public proxy). A request whose body would exceed 256 KiB is split into smaller ones. | | `--all-releases` | `SOCKET_ALL_RELEASES` | Store patches for every release/distribution variant, not just the installed one — PyPI wheel/sdist, RubyGems platform, Maven classifier. Makes the manifest portable across environments (e.g. cross-platform CI caches). | | `--vex ` | `SOCKET_VEX` | On a successful scan, also write an OpenVEX 0.2.0 document to this path. See [Inline VEX](#inline-vex-on-apply--scan--vendor). | @@ -676,6 +711,9 @@ socket-patch scan --package lodash # Two projects of a monorepo socket-patch scan apps/web apps/api +# Roll out gradually: at most 5 new patches, most critical first +socket-patch scan --max-new-patches 5 + # Vendored mode: build + commit every patched dependency socket-patch scan --json --mode vendored diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index f5e53273..d3b80fbc 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -87,6 +87,7 @@ Beyond the globals above, each subcommand defines a small set of local arguments | `scan` | `--package ` (repeatable or comma-separated) | `SOCKET_SCAN_PACKAGES` | (v5.0) Only scan these packages: a name (`lodash`, `@scope/pkg`, `requests`, `group:artifact`; matched against the full name or its last segment, case-insensitively) or a purl with or without a version (`pkg:npm/lodash` matches every version, `pkg:pypi/requests@2.31.0` only that one). Qualifiers are ignored. Filters the crawl like `--ecosystems`, after the prune universe is captured, so `--prune` still judges the full crawl | | `scan` | `--vendor` / `--detached` | — | Vendor every patched dependency instead of applying in place (`--vendor` == `--mode vendored`; conflicts with `--apply`/`--sync`, combines with `--prune`). Vendored mode is manifest-free (v5.0): the vendor ledger embeds the patch records and `.socket/manifest.json` is never written. `--detached` — the former opt-in for exactly that — is **hidden** and retained for compatibility as a no-op; it is still a usage error (exit 2) without vendored mode in either spelling | | `scan` | `--batch-size` | `SOCKET_BATCH_SIZE` | API batch chunk size. Unset (v5.0): `500` on the authenticated API (the server's per-request maximum), `100` on the public proxy; a given value applies on either endpoint (`0` is floored to `1`). A chunk whose request body would exceed 256 KiB (the public proxy's body cap) is split into consecutive smaller chunks, deterministically (greedy, in crawl order). A mid-run downgrade to the proxy keeps the chunks already formed | +| `scan` | `--max-new-patches ` | `SOCKET_MAX_NEW_PATCHES` | (v5.0) Per-run cap on NEW patches (packages with no recorded patch in the project), most severe first; the rest are deferred to the next scan. `0` admits upgrades only, `none` (case-insensitive) is unlimited, absent is unlimited unless socket.yml sets `patches.maxNewPatches`. Precedence: flag > env > socket.yml > unlimited. The env value is read at run time (the `rollout` block reports `flag` vs `env`): empty is unset, malformed is a usage error (exit 2, before any network access). Upgrades and already-applied patches are never capped. See "Per-run limit on new patches" below | | `get`, `scan` | `--all-releases` | `SOCKET_ALL_RELEASES` | Download patches for every release/distribution variant of a matched package — PyPI wheel/sdist (`artifact_id`), RubyGems (`platform`), Maven (`classifier`) — not just the one(s) matching the locally-installed distribution. On `scan` this makes the stored manifest portable across environments (e.g. cross-platform CI caches). On `get` (v3.6) it ALSO disables the coarse installed-**version** narrowing of CVE/GHSA fan-outs (see "get --mode and installed narrowing"): every found version's patch is fetched, installed or not | | `get` | positional `identifier`; `--id` / `--cve` / `--ghsa` / `--package` (`-p`); `--save-only` (alias `--no-apply`); `--one-off` (hidden from `--help`: always fails "not yet implemented"); `--mode ` | `SOCKET_SAVE_ONLY`, `SOCKET_ONE_OFF` | Patch lookup + consumption mode (v3.6). `--mode` reuses scan's value enum (same hidden value aliases `host`/`redirect`/`vendor`; deliberately no env binding, matching scan). Default (v5.0): `hosted`, like scan; `agent` (save + apply in place) when `--save-only` or `--global`/`--global-prefix` is given. An explicit `--save-only` conflicts with `--mode hosted\|vendored` — rejected with **exit 1** via get's established self-enforced-conflict style (unlike scan's exit-2 mode conflicts; see the exit-code table) | | `remove` | positional `identifier`; `--skip-rollback`; `--preserve-state` (v5.0) | `SOCKET_SKIP_ROLLBACK`, `SOCKET_PRESERVE_STATE` | Manifest entry removal. `--preserve-state` is the single-patch twin of `rollback --preserve-state`: restore the tree and unwind the identifier's vendored/hosted wiring, but keep the manifest entry, the vendored artifact + ledger entry, and skip all GC. Combining it with `--skip-rollback` is a self-enforced usage error (exit 2): one flag keeps the tree and drops the state, the other restores the tree and keeps the state — together they select the do-nothing quadrant ("the combination would be a no-op: nothing would change"). The conflict fires whether either flag is spelled on the command line or sourced from its env var | @@ -176,6 +177,49 @@ The hidden alias `--no-apply` on `get --save-only` is **part of the contract** **Python stale-install guard**: after a hosted redirect, `scan` / `get` use the Python crawler to inspect every matching installed package, including Poetry's out-of-tree virtualenvs and `--global-prefix`. A readable file that differs from the patch's `afterHash` emits `redirect_pypi_stale_install` in JSON `redirect.warnings[]` and human stderr. The probe changes no installed files, re-runs on idempotent scans, and falls back to persisted patch records when fresh record fetching fails. Missing/unreadable files alone do not prove staleness; lock-only checkouts stay quiet. Dry runs skip the probe. Same-run VEX excludes positively stale Python packages (qualifier-insensitive), even with `--vex-no-verify` or a healthy copy in another interpreter; if nothing remains to attest, the command exits 1 with `no_applicable_patches`. Reinstall from the rewritten lock in the affected interpreter and verify with `socket-patch vex`. +### Per-run limit on new patches (`scan --max-new-patches`, v5.0) + +`scan --max-new-patches ` (env `SOCKET_MAX_NEW_PATCHES`, socket.yml `patches.maxNewPatches`) paces a rollout: each run adds at most N patches to packages that had none, the most critical first, and defers the rest to the next run. It applies to `scan` in hosted, vendored and agent mode, wet and `--dry-run`, and to the in-memory engine (napi `maxNewPatches`, `hosted-bundle`); `get` is explicit intent and ignores it. Design: `docs/design/staged-rollout.md` §5. + +**Classification.** After per-package selection, each selected `(project, purl)` row is compared with the project's **recorded view** — the merged manifest > hosted lockfile pins > vendor ledger that `updates[]` reads (§5.1): + +| Class | Rule | Capped | The writer gets | +|---|---|---|---| +| ALREADY | the recorded uuid is the selected one, or the selection does not supersede it | no | the **recorded** uuid (re-confirmed idempotently, never swapped) | +| UPGRADE | the selection supersedes the recorded uuid (`ranking::search_result_supersedes`), or the recorded uuid is no longer offered | no | the selected uuid | +| NEW | nothing recorded for the base purl in this project | **yes** | the selected uuid, if admitted | + +Supersession is judged on the by-package records the selection itself uses, so a scan with or without a cap never replaces an applied patch with an equal sibling (the tier and uuid tiebreaks and a missing date never count). When the lockfiles of a project pin a purl to several uuids, the recorded uuid is the selected one if it is among them, else the smallest. A hosted pin on a patch server that discovery does not recognize (an origin missing from `--patch-server-url`) still counts as ALREADY when a lockfile names the selected uuid, so the cap can never stall on it. + +**Eligibility.** A NEW row is eligible only if every check the mode can decide without writing passes: the tier filter; the agent partition (vendor-owned and not-installed packages); the vendored Bun / vlt preflight; a `granted` hosted reference with a usable purl and url; the vlt artifact preflight; the vendored-to-hosted takeover refusals; wheel metadata; and a hosted rewrite whose confirmation probe shows a lockfile edit that pins it. Ineligible rows keep their own skip reasons and never hold a slot. References are fetched for every row before the budget is spent, and `--dry-run` fetches them too, so a dry run makes exactly the wet run's decisions. + +**Unit and order.** The budget counts distinct **base purls** (ecosystem + name + version, qualifiers stripped, percent-decoded; qualifier twins are one package): admitting one admits all of its eligible rows and costs one slot. Eligible NEW base purls are ranked by, ascending: in-flight first (in-memory `inFlightPatches` only); severity of the selected patch (critical, high, medium, low, unknown — the worst advisory it fixes); advisory count, descending; ecosystem name; base purl (bytewise); uuid. The order is total and has no time-dependent key (publish dates still pick the patch within a package, never the package order). + +**Budget scope.** Disk: one budget per invocation. The project directories a hosted or vendored scan's PATHs name are visited in sorted order; each spends what is left in rank order, and a base purl admitted in an earlier directory is admitted free in a later one. `scan --json` takes one directory, so a CI job per directory gets N per directory. In memory: one budget across every project root (roots are collected, planned once, then applied). The two therefore rank a multi-root repo differently, by design. + +**Incomplete data.** With a finite cap, a failed batch query, or a failed detail query for a package with no recorded patch, admits no NEW row that run (they are all deferred) and adds warning `rollout_incomplete_lookup`: a missing package must not let lower-ranked ones take its slot. ALREADY and UPGRADE rows proceed as usual. A failed hosted reference lookup that could only affect NEW rows of a capped run is warning `rollout_reference_failed` (the rows are deferred) instead of a run failure. + +**Convergence.** The limit is stateless: run k lands the top N, run k+1 finds them recorded and lands the next N, so M waiting patches take at most ceil(M/N) *committed* runs. A newly published or re-scored more severe patch moves ahead of the queue (intended: most critical first), and low-severity patches can wait while more severe ones keep arriving. A CI job that does not commit the scan's changes never advances: there a cap means "only the top N, every run" — commit the changes (or use a PR bot), or set no cap in such jobs. A write failure after admission still spends its slot (no backfill within a run). Known limits: a dependency that moves to a new version is NEW again (its hosted pin stays with the old lock entry); a qualifier twin that lands on a later run joins its package as ALREADY / UPGRADE, uncapped. + +**Output.** Every successful `scan --json` result carries an additive top-level `rollout` block (MINOR), zero counts when the run planned nothing (report-only and empty scans): + +```json +"rollout": { + "maxNewPatches": {"value": 5, "source": "flag"}, + "counts": {"new": 5, "deferred": 9, "upgrade": 1, "already": 12}, + "deferred": [ + {"purl": "pkg:npm/minimist@1.2.5", "uuids": ["…"], "severity": "critical", + "advisoryCount": 1, "projects": [""], "rank": 6} + ] +} +``` + +`maxNewPatches.value` is `null` for unlimited; `source` is `flag`, `env`, `file`, `default` or `cap` (the in-memory `maxNewPatchesCap` tightened it; the in-memory `maxNewPatches` option reports `flag`). `counts.new` / `counts.deferred` count base purls, `counts.upgrade` / `counts.already` count `(project, purl)` rows. `deferred[]` is in rank order: `purl` is the base purl, `uuids` the distinct selected uuids across its rows, `projects` the repo-relative project directories (`""` is the scanned directory), `rank` 1-based among eligible NEW base purls. Deferred rows are never written, downloaded or vendored: hosted mode mirrors each into `redirect.skipped[]` as `{purl, uuid, reason: "rollout_deferred", detail}`, the in-memory engine lists them in `ProjectResult.deferred[]` (`{purl, uuid, severity, rank}`) and `skipped[]`, and agent / vendored mode leave them out of `apply.patches[]` / `vendor`. Warnings (`rollout_incomplete_lookup`, `rollout_reference_failed`) go to the top-level `warnings[]`. Exit codes are unchanged: deferring is not a failure. + +Human output adds, when a cap is set, `Rollout: 3 of 9 new patches applied (maxNewPatches=3 from --max-new-patches); 0 upgrades, 0 already applied.` and next steps naming the deferred patches (`6 new patches deferred; commit these changes and run scan again to apply the next 3.`, `Next up: minimist@1.2.5 (critical), …`). + +CI recipe: `socket-patch scan --json --max-new-patches 5 | jq '.rollout.counts.deferred'`. + ### Embedded VEX (`apply --vex` / `scan --vex` / `vendor --vex`) `--vex ` folds OpenVEX 0.2.0 generation into `apply`, `scan`, and `vendor`: on a successful run the command writes the document to `` using the same engine as the standalone `vex` command. The `--vex-*` flags mirror `vex`'s `--product` / `--no-verify` / `--doc-id` / `--compact` knobs (namespaced to avoid colliding with the host command), and reuse the standalone env vars (`SOCKET_VEX_PRODUCT`, etc.). They are inert unless `--vex` is set. @@ -1082,6 +1126,7 @@ Empty string means unset at every layer: exported-but-empty flag-bound vars are | `SOCKET_FORCE` | `apply --force` / `-f`, `vendor --force` / `-f`, `--update --force` | `false` | Local to `apply`, `vendor` and `--update`. | | `SOCKET_PATCH_VERSION` | `--update ` | (latest) | Local to `--update`; the same pin `install.sh` and the gem launcher honor. Not one of the deprecated legacy `SOCKET_PATCH_*` trio. | | `SOCKET_BATCH_SIZE` | `scan --batch-size` | `500` authenticated / `100` proxy | Local to `scan`. | +| `SOCKET_MAX_NEW_PATCHES` | `scan --max-new-patches` | (unlimited) | Local to `scan` (v5.0): a count or `none`; empty is unset, malformed exits 2. | | `SOCKET_SCAN_PACKAGES` | `scan --package` | (none) | Local to `scan` (v5.0); comma-separated names or purls. | | `SOCKET_SAVE_ONLY` | `get --save-only` | `false` | Local to `get`. | | `SOCKET_ONE_OFF` | `get --one-off` / `rollback --one-off` | `false` | Local to `get`/`rollback`. Both are **not yet implemented**: the flag parses (boolishly, empty-tolerant) and the command fails up front with a "not yet implemented" error, before any network or disk activity (on `rollback`, with no identifier-shaped target it instead fails "requires an identifier", equally up front). | @@ -1530,10 +1575,24 @@ excluded before ranking for callers whose `canAccessPaidPatches` is false, so the winner is the best patch the account can download. `scan`'s `[UPDATE]` marker and `updates[]` use the same order -(`ranking::batch_supersedes`): a candidate supersedes the applied patch -only on a meaningful rung — merged over unmerged, higher severity between -unmerged patches, or a real, strictly later publish date. The tier and -uuid tiebreaks and a missing date never count. +(`ranking::search_result_supersedes`, v5.0): a candidate supersedes the +applied patch only on a meaningful rung — merged over unmerged, higher +severity between unmerged patches, or a real, strictly later publish +date. The tier and uuid tiebreaks and a missing date never count. Every +mode that fetches the by-package records (hosted, vendored, agent, and +every human run with a downloadable patch) judges this on those records, +so `updates[]` lists exactly the UPGRADE rows the run acts on; a package +the by-package lookup returns no offer for, and a JSON report-only run +(which fetches no by-package records), fall back to the batch records +(`ranking::batch_supersedes`). When the selection does not supersede the +recorded patch, scan keeps the recorded one (v5.0): a re-scan never swaps +an applied patch for an equal sibling. + +This order picks one patch **per package**. Which packages a capped scan +patches first is a separate, cross-package order (`rollout::rollout_cmp`, +see "Per-run limit on new patches"): severity of the selected patch, then +advisory count, then ecosystem, base purl and uuid — never the publish +date. #### Merge state is inferred, not reported @@ -1571,8 +1630,8 @@ supplies it (the public-proxy fallback path fills it in from the per-package results). > **Known gap — batch responses without `publishedAt`.** `scan`'s -> discovery (`packages[]`, the table, `updates[]`) is built from the -> **batch** endpoint, whose response shape currently omits `publishedAt`; +> discovery (`packages[]`, and `updates[]` on a JSON report-only run) is +> built from the **batch** endpoint, whose response shape currently omits `publishedAt`; > the selection that `--apply` performs is built from the **by-package** > endpoint, which carries it. The two diverge wherever the date decides — > between merged patches, or between unmerged patches of equal severity — @@ -1591,6 +1650,14 @@ per-package results). ### `jq` recipes for PR-comment bots +Deferred by the per-run cap (`scan --max-new-patches`), most urgent first: + +```bash +socket-patch scan --json --max-new-patches 5 | jq -r ' + .rollout.deferred[] | "\(.rank). \(.purl) (\(.severity))" +' +``` + Applied + updated patches (envelope shape): ```bash diff --git a/docs/design/staged-rollout.md b/docs/design/staged-rollout.md index cbe8a507..f7655388 100644 --- a/docs/design/staged-rollout.md +++ b/docs/design/staged-rollout.md @@ -1025,3 +1025,50 @@ change), README (recipe R5, `--max-new-patches`), CHANGELOG the docs, to keep validators from drifting. - Recognizing a dependency version bump of an already-patched package as exempt from the cap (needs state hosted mode does not keep). + +## 11. Implementation notes (work item B) + +Where the plan left a gap, work item B made the smallest decision that +keeps its rules intact: + +- **`updates[]` without by-package data.** A `--json` report-only run + (`--prune` / `--global` with no mode) fetches no by-package records, so + its `updates[]` stays on the batch rungs (`batch_supersedes`). In every + other run `updates[]` is the UPGRADE rows, plus the batch-derived entry + for a package the by-package lookup returned no offer for (nothing was + selected there to disagree with). +- **Report-only and empty scans** carry the `rollout` block with zero + counts: they plan nothing. +- **Unrecognized hosted pins.** Discovery only treats URLs on + `patch.socket.dev` or a `--patch-server-url` origin as hosted pins. A + scan against another server without that flag would read every pin as + NEW and re-spend the same N slots forever. The hosted gate therefore + also counts a NEW row as ALREADY when a lockfile names its selected + uuid (uuids are unique; one linear scan per file). +- **In-memory recorded view.** The engine has no disk discovery, so a + root's hosted pins are the offered uuids of each purl that its own + files mention (nested roots' files excluded), merged with the tree's + `.socket/manifest.json` (now selected by `selectHostedScanPaths`) and + `.socket/vendor/state.json` at the disk precedence. A pin to a patch + the API no longer offers reads as NEW: it costs one slot once, and the + next run sees the new pin. +- **Option source.** The in-memory `maxNewPatches` option reports + `source: "flag"` (it is the caller's explicit layer); `maxNewPatchesCap` + reports `cap` when it tightens the value. +- **Key 6 across a base purl's rows.** Rows are sorted with + `rollout_cmp` and a base purl takes the rank of its first row, which is + the "minimum key over its rows" of 5.2; the uuid key only orders rows of + one base purl, which are admitted or deferred together. +- **Reference failures.** On disk the reference lookup is one call; when + it fails in a capped run whose rows are all NEW, the rows are deferred + with `rollout_reference_failed` and `rollout_incomplete_lookup` instead + of failing the run. In memory the same rule applies per root. +- **Human output.** The `Rollout:` line prints only when a cap is set. + Hosted mode appends the deferred lines to its existing next steps; + agent and vendored mode print them under a `Next steps:` heading. +- **Lock.** A wet hosted run whose only candidates are NEW rows it cannot + admit (budget 0, or incomplete data) takes no apply lock and writes + nothing, `.socket/` included. +- **socket.yml layer.** B resolves the cap as flag > env > file > + unlimited through `resolve_max_new`; the file value is passed once A + loads the policy in `scan` (9.3). From ae6fa061521eae4ef8c5784b6250a095caa01039 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 14:31:33 +0000 Subject: [PATCH 12/22] Apply socket.yml policy in the in-memory engine selectHostedScanPaths now streams the root socket.yml/socket.yaml and returns them as policyPaths; it drops test and fixture trees through the policy's built-in default ignores instead of a hard-coded segment list (structural excludes like node_modules and vendor stay fixed). The session reads the policy before any root is processed: path filters run before the project limit, ecosystem and package filters on each root's packages, and the severity floor before selection. A listed policy file that arrives without content, or an invalid one, yields policyError with no root processed and no file changed. New options noSocketYml, minSeverity and policyPaths; the result gains a policy block. hosted-bundle and index.d.ts carry the new fields. get now warns policy_bypassed when the repo's socket.yml would have skipped the package it patches. Claude-Session: https://claude.ai/code/session_01BKsyzefGhAnPkYmXCwq3H3 Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/src/commands/get.rs | 4 +- .../src/commands/hosted_bundle.rs | 12 +- .../src/commands/scan/hosted.rs | 1 + .../src/commands/scan/policy.rs | 196 +++++++++++++----- .../src/hosted_memory/limits.rs | 26 +++ .../socket-patch-cli/src/hosted_memory/mod.rs | 196 +++++++++++++++++- .../src/hosted_memory/roots.rs | 87 ++++++-- .../src/hosted_memory/select.rs | 12 ++ .../src/hosted_memory/types.rs | 32 +++ .../socket-patch-cli/tests/cli_parse_scan.rs | 77 +++++++ .../tests/e2e_socket_yml_policy.rs | 42 ++++ .../tests/hosted_memory_common/mod.rs | 24 ++- .../tests/hosted_memory_parity.rs | 182 ++++++++++++++++ crates/socket-patch-core/src/policy/mod.rs | 7 + crates/socket-patch-core/src/policy/tests.rs | 15 ++ crates/socket-patch-node/npm/index.d.ts | 19 +- 16 files changed, 855 insertions(+), 77 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index e6a2c1ad..05d7cfab 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -3080,7 +3080,7 @@ pub async fn run(args: GetArgs) -> i32 { // included, so the listing can still show an installed package's paid // fix as `[PAID] (no access)`; selection, the skip records and the // JSON envelope only ever see the accessible share. - let (accessible, listed, narrow_skips, narrow_warnings) = if narrowing_exempt { + let (accessible, listed, narrow_skips, mut narrow_warnings) = if narrowing_exempt { let listed: Vec = search_response.patches.clone(); (accessible, listed, Vec::new(), Vec::new()) } else { @@ -3101,6 +3101,8 @@ pub async fn run(args: GetArgs) -> i32 { .collect(); (kept_accessible, narrowing.kept, skips, narrowing.warnings) }; + // `get` bypasses the repo's socket.yml policy, but says so. + narrow_warnings.extend(super::scan::policy::policy_bypass_warnings(&args.common, &accessible)); // Layout refusals print even when informational output is quieted only // by --json (stderr; the envelope carries them too) — but --silent // mutes them like scan does. diff --git a/crates/socket-patch-cli/src/commands/hosted_bundle.rs b/crates/socket-patch-cli/src/commands/hosted_bundle.rs index 392ac771..b1950f5c 100644 --- a/crates/socket-patch-cli/src/commands/hosted_bundle.rs +++ b/crates/socket-patch-cli/src/commands/hosted_bundle.rs @@ -9,7 +9,8 @@ //! //! Stdin: `{"files": {path: text}, "binaryFiles"?: {path: base64}, //! "presentOnly"?: [path], "symlinks"?: [path], "projectRoots"?: [dir], -//! "pipenvMajor"?: n, "batchSize"?: n}`. Stdout: the engine result +//! "pipenvMajor"?: n, "batchSize"?: n, "noSocketYml"?: bool, +//! "minSeverity"?: severity, "policyPaths"?: [path]}`. Stdout: the engine result //! (`HostedScanResult`, binary contents base64), or //! `{"status":"error","error":{"code","message"}}` with exit 2 for bad //! credentials/bundle input, or exit 1 for an engine failure. @@ -53,6 +54,12 @@ struct Bundle { pipenv_major: Option, #[serde(default)] batch_size: Option, + #[serde(default)] + no_socket_yml: Option, + #[serde(default)] + min_severity: Option, + #[serde(default)] + policy_paths: Option>, } fn print_error(code: &str, message: &str) { @@ -121,6 +128,9 @@ pub async fn run(args: HostedBundleArgs) -> i32 { trust_lockfile_config: Some(!common.no_trust_lockfile_config), npm_allow_remote_config: Some(!common.no_npm_allow_remote_config), project_roots: bundle.project_roots.clone(), + no_socket_yml: bundle.no_socket_yml, + min_severity: bundle.min_severity.clone(), + policy_paths: bundle.policy_paths.clone(), ..HostedScanOptions::default() }; let input = match build_input(bundle, options) { diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index d8426e9f..2304fedb 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -1041,6 +1041,7 @@ fn gem_sha_key(purl: &str) -> (String, String) { /// then rewrite ONLY those dependencies' lockfile/registry-config entries to /// point at the hosted vendored patches (the byte-identical counterpart of the /// GitHub-app registry mode). No artifact bytes land in the repo. +#[allow(clippy::too_many_arguments)] pub(super) async fn run_redirect( args: &ScanArgs, api_client: &socket_patch_core::api::client::ApiClient, diff --git a/crates/socket-patch-cli/src/commands/scan/policy.rs b/crates/socket-patch-cli/src/commands/scan/policy.rs index 32f99d0c..97dbc5de 100644 --- a/crates/socket-patch-cli/src/commands/scan/policy.rs +++ b/crates/socket-patch-cli/src/commands/scan/policy.rs @@ -90,19 +90,77 @@ pub(crate) fn dir_markers(dir: &Path) -> Vec { markers } +/// One `policy.filtered[]` entry. #[derive(Debug, Clone)] -struct FilteredEntry { - purl: Option, - uuid: Option, - reason: FilterReason, - severity: Option, +pub(crate) struct FilteredEntry { + pub purl: Option, + pub uuid: Option, + pub project: String, + pub reason: FilterReason, + /// The would-be patch's severity order, when a patch was looked up. + pub severity: Option, } +/// One `policy.retained[]` entry. #[derive(Debug, Clone)] -struct RetainedEntry { - purl: String, - recorded_uuid: String, - reason: FilterReason, +pub(crate) struct RetainedEntry { + pub purl: String, + pub project: String, + pub recorded_uuid: String, + pub reason: FilterReason, + pub upgrade_available: bool, +} + +/// The top-level `policy` block (4.7), shared by disk scans and the +/// in-memory engine. +pub(crate) fn policy_block( + policy: &SelectionPolicy, + filtered: &[FilteredEntry], + retained: &[RetainedEntry], +) -> serde_json::Value { + let (path, sha256) = match policy.source() { + PolicySource::File { path, sha256 } => (serde_json::json!(path), serde_json::json!(sha256)), + _ => (serde_json::Value::Null, serde_json::Value::Null), + }; + let (floor, floor_source) = policy.min_severity(); + let filtered: Vec = filtered + .iter() + .map(|f| { + serde_json::json!({ + "purl": f.purl, + "uuid": f.uuid, + "project": f.project, + "reason": f.reason.code(), + "detail": f.reason.detail(), + }) + }) + .collect(); + let retained: Vec = retained + .iter() + .map(|r| { + serde_json::json!({ + "purl": r.purl, + "project": r.project, + "recordedUuid": r.recorded_uuid, + "reason": r.reason.code(), + "detail": r.reason.detail(), + "upgradeAvailable": r.upgrade_available, + }) + }) + .collect(); + serde_json::json!({ + "source": policy.source().as_str(), + "path": path, + "sha256": sha256, + "enabled": policy.enabled(), + "minSeverity": { + "value": floor.and_then(severity_name), + "source": floor_source.as_str(), + }, + "counts": { "filtered": filtered.len(), "retained": retained.len() }, + "filtered": filtered, + "retained": retained, + }) } #[derive(Default)] @@ -204,8 +262,10 @@ impl ScanPolicy { if report.retained_purls.insert(key.clone()) { report.retained.push(RetainedEntry { purl: key, + project: self.project.clone(), recorded_uuid: uuid.to_string(), reason, + upgrade_available: false, }); } return true; @@ -215,6 +275,7 @@ impl ScanPolicy { report.filtered.push(FilteredEntry { purl: None, uuid: None, + project: self.project.clone(), reason, severity: None, }); @@ -223,6 +284,7 @@ impl ScanPolicy { report.filtered.push(FilteredEntry { purl: Some(canon(purl)), uuid: None, + project: self.project.clone(), reason, severity: None, }); @@ -271,14 +333,17 @@ impl ScanPolicy { if report.retained_purls.insert(key.clone()) { report.retained.push(RetainedEntry { purl: key, + project: self.project.clone(), recorded_uuid: uuid, reason, + upgrade_available: false, }); } } None => report.filtered.push(FilteredEntry { purl: Some(purl.clone()), uuid: Some(group[0].uuid.clone()), + project: self.project.clone(), severity: Some(patch_severity_order(&group[0])), reason, }), @@ -302,6 +367,7 @@ impl ScanPolicy { report.filtered.push(FilteredEntry { purl: Some(purl.clone()), uuid: Some(group[0].uuid.clone()), + project: self.project.clone(), severity: Some(patch_severity_order(&group[0])), reason: self .policy @@ -322,51 +388,15 @@ impl ScanPolicy { /// The top-level `policy` block (4.7). pub(crate) fn json(&self) -> serde_json::Value { let report = self.report(); - let (path, sha256) = match self.policy.source() { - PolicySource::File { path, sha256 } => (serde_json::json!(path), serde_json::json!(sha256)), - _ => (serde_json::Value::Null, serde_json::Value::Null), - }; - let (floor, floor_source) = self.policy.min_severity(); - let filtered: Vec = report - .filtered - .iter() - .map(|f| { - serde_json::json!({ - "purl": f.purl, - "uuid": f.uuid, - "project": self.project, - "reason": f.reason.code(), - "detail": f.reason.detail(), - }) - }) - .collect(); - let retained: Vec = report + let retained: Vec = report .retained .iter() - .map(|r| { - serde_json::json!({ - "purl": r.purl, - "project": self.project, - "recordedUuid": r.recorded_uuid, - "reason": r.reason.code(), - "detail": r.reason.detail(), - "upgradeAvailable": report.update_purls.contains(&r.purl), - }) + .map(|r| RetainedEntry { + upgrade_available: report.update_purls.contains(&r.purl), + ..r.clone() }) .collect(); - serde_json::json!({ - "source": self.policy.source().as_str(), - "path": path, - "sha256": sha256, - "enabled": self.policy.enabled(), - "minSeverity": { - "value": floor.and_then(severity_name), - "source": floor_source.as_str(), - }, - "counts": { "filtered": filtered.len(), "retained": retained.len() }, - "filtered": filtered, - "retained": retained, - }) + policy_block(&self.policy, &report.filtered, &retained) } /// Put the `policy` block and the policy warnings on a scan `--json` @@ -480,3 +510,69 @@ pub(crate) fn policy_error_json(err: &PolicyError, paths: &[String]) -> serde_js "paths": paths, }) } + +/// `get`'s `policy_bypassed` warnings: `get` is explicit intent, so it +/// ignores the policy, but says when the repo's socket.yml would have +/// filtered what it is about to patch. Never fails: an unreadable or +/// invalid file just yields no warning. +pub(crate) fn policy_bypass_warnings( + common: &crate::args::GlobalArgs, + patches: &[PatchSearchResult], +) -> Vec<(String, String)> { + if common.is_global() || patches.is_empty() { + return Vec::new(); + } + let cwd = std::fs::canonicalize(&common.cwd).unwrap_or_else(|_| common.cwd.clone()); + let (repo_root, _) = find_repo_root_with_warnings(&cwd); + let Ok((policy, _)) = SelectionPolicy::load( + &DiskPolicyFs::new(&repo_root), + &socket_patch_core::policy::PolicyOverrides::default(), + ) else { + return Vec::new(); + }; + if !matches!(policy.source(), PolicySource::File { .. }) { + return Vec::new(); + } + let project = repo_relative_checked(&repo_root, &cwd).unwrap_or_default(); + let markers = dir_markers(&cwd); + let root_verdict = policy.admits_root(&Root { + rel_dir: &project, + markers: &markers, + explicit: true, + }); + let mut by_purl: BTreeMap<&str, Vec<&PatchSearchResult>> = BTreeMap::new(); + for patch in patches { + by_purl.entry(patch.purl.as_str()).or_default().push(patch); + } + let mut out = Vec::new(); + for (purl, mut group) in by_purl { + group.sort_by(|a, b| cmp_search_results(a, b)); + let verdict = if !policy.enabled() { + Err(FilterReason::Disabled) + } else { + root_verdict.clone().and_then(|()| policy.admits_purl(purl)).and_then(|()| { + // The floor only hides a package when none of its patches pass. + match group + .iter() + .map(|p| policy.admits_severity(patch_severity_order(p))) + .find(Result::is_ok) + { + Some(ok) => ok, + None => policy.admits_severity(patch_severity_order(group[0])), + } + }) + }; + if let Err(reason) = verdict { + out.push(( + socket_patch_core::policy::POLICY_BYPASSED.to_string(), + format!( + "{} would be skipped by socket.yml ({}: {}); get patches it anyway", + normalize_purl(purl), + reason.code(), + reason.detail() + ), + )); + } + } + out +} diff --git a/crates/socket-patch-cli/src/hosted_memory/limits.rs b/crates/socket-patch-cli/src/hosted_memory/limits.rs index 4bcdb635..aebe45f4 100644 --- a/crates/socket-patch-cli/src/hosted_memory/limits.rs +++ b/crates/socket-patch-cli/src/hosted_memory/limits.rs @@ -27,6 +27,8 @@ pub(crate) struct ResolvedOptions { pub(crate) provider_concurrency: usize, pub(crate) request_timeout: std::time::Duration, pub(crate) limits: ResolvedLimits, + pub(crate) policy_overrides: socket_patch_core::policy::PolicyOverrides, + pub(crate) policy_paths: Vec, } pub(crate) fn resolve_options(options: &HostedScanOptions) -> Result { @@ -54,6 +56,28 @@ pub(crate) fn resolve_options(options: &HostedScanOptions) -> Result None, + Some(value) => Some(( + socket_patch_core::policy::parse_min_severity(value) + .map_err(|e| EngineError::invalid("invalid_min_severity", format!("minSeverity: {e}")))?, + socket_patch_core::policy::OverrideSource::Flag, + )), + }; + let policy_overrides = socket_patch_core::policy::PolicyOverrides { + bypass: options.no_socket_yml.unwrap_or(false), + min_severity, + }; + let mut policy_paths: Vec = Vec::new(); + for path in options.policy_paths.iter().flatten() { + if !socket_patch_core::policy::POLICY_FILE_NAMES.contains(&path.as_str()) { + return Err(EngineError::invalid( + "invalid_policy_path", + format!("policyPaths entry `{path}` is not a root socket.yml or socket.yaml"), + )); + } + policy_paths.push(path.clone()); + } let project_roots = match &options.project_roots { Some(roots) => { let mut out: Vec = Vec::with_capacity(roots.len()); @@ -103,6 +127,8 @@ pub(crate) fn resolve_options(options: &HostedScanOptions) -> Result+"`; the sha comes from the /// `SOCKET_PATCH_GIT_SHA` build-time variable. @@ -113,6 +119,8 @@ struct RootState { packages: Vec, selected: Vec<(String, String)>, skipped: Vec, + /// Candidates the socket.yml policy withheld (`policy_*` reasons). + policy_skipped: Vec, error: Option, } @@ -328,6 +336,7 @@ fn unrooted_unsupported_warnings<'a>( || dir .split('/') .any(|seg| roots::EXCLUDED_ROOT_SEGMENTS.contains(&seg)) + || roots::default_ignored_dir(dir) { continue; } @@ -372,10 +381,58 @@ async fn engine( let ecosystems = options.ecosystems.as_deref(); let provider = Provider::new(api, options.request_timeout, options.provider_concurrency); + // The repo's socket.yml policy, before any root is processed: a file + // that cannot be honored fails the whole session closed. + let (policy, policy_warnings) = + match SelectionPolicy::load(&memory_policy_fs(&files, &options.policy_paths), &options.policy_overrides) { + Ok(loaded) => loaded, + Err(error) => { + return Ok(policy_error_output(&error, warnings, files_input, bytes_input)); + } + }; + for w in policy_warnings { + warnings.push(EngineWarning::new(w.code, w.detail, None)); + } + if !policy.enabled() { + warnings.push(EngineWarning::new( + PATCHES_DISABLED, + "patches.enabled is false in socket.yml: report only, nothing is written", + None, + )); + } + let mut policy_filtered: Vec = Vec::new(); + let root_list: Vec = match &options.project_roots { Some(roots) => roots.clone(), None => roots::detect_roots(files.keys().map(String::as_str), ecosystems).0, }; + // The full policy (paths from the file too) judges every root before + // the project limit; roots named in `projectRoots` are explicit. + let explicit_roots = options.project_roots.is_some(); + let detected_roots = root_list.clone(); + let root_list: Vec = root_list + .into_iter() + .filter(|root| { + let markers = roots::root_markers(root, files.keys().map(String::as_str)); + match policy.admits_root(&Root { + rel_dir: root, + markers: &markers, + explicit: explicit_roots, + }) { + Ok(()) => true, + Err(reason) => { + policy_filtered.push(FilteredEntry { + purl: None, + uuid: None, + project: root.clone(), + reason, + severity: None, + }); + false + } + } + }) + .collect(); if root_list.len() as u64 > options.limits.max_projects { return Err(EngineError::limit( "max_projects", @@ -388,7 +445,7 @@ async fn engine( } unrooted_unsupported_warnings( files.keys().map(String::as_str), - &root_list, + &detected_roots, ecosystems, &mut warnings, ); @@ -407,6 +464,7 @@ async fn engine( packages: Vec::new(), selected: Vec::new(), skipped: Vec::new(), + policy_skipped: Vec::new(), error: None, }) .collect(); @@ -430,7 +488,20 @@ async fn engine( .filter_map(|e| discover::supplement_purl(&e.purl)) .filter(|p| ecosystem_allowed(ecosystems, p)) .collect(); - state.purls = purls.into_iter().collect(); + let mut admitted: Vec = Vec::with_capacity(purls.len()); + for purl in purls { + match policy.admits_purl(&purl) { + Ok(()) => admitted.push(purl), + Err(reason) => policy_filtered.push(FilteredEntry { + purl: Some(purl), + uuid: None, + project: state.root.clone(), + reason, + severity: None, + }), + } + } + state.purls = admitted; state.summary.scanned_packages = state.purls.len() as u64; } let union_purls: BTreeSet<&str> = states @@ -534,7 +605,14 @@ async fn engine( Some(&state.root), )); } - state.selected = discover::select_top_ranked(&results, can_access_paid); + state.selected = select_with_policy( + &policy, + results, + can_access_paid, + &state.root, + &mut policy_filtered, + &mut state.policy_skipped, + ); } phases.mark("details"); @@ -628,7 +706,7 @@ async fn engine( let mut results: BTreeMap = BTreeMap::new(); for (index, done) in rewritten { let state = &mut states[index]; - let result = finish_root( + let mut result = finish_root( state, done, &records, @@ -637,6 +715,7 @@ async fn engine( &mut changed_binary, &mut warnings, ); + result.skipped.extend(state.policy_skipped.iter().cloned()); results.insert(index, result); } let mut projects: Vec = Vec::with_capacity(states.len()); @@ -655,12 +734,14 @@ async fn engine( options.dry_run, ), }; + let mut skipped = state.skipped.clone(); + skipped.extend(state.policy_skipped.iter().cloned()); projects.push(ProjectResult { root: state.root.clone(), redirect, summary: state.summary.clone(), redirected: Vec::new(), - skipped: state.skipped.clone(), + skipped, error: state.error.clone(), }); } @@ -697,9 +778,113 @@ async fn engine( warnings, stats, engine_version: engine_version(), + policy: Some(policy_block(&policy, &policy_filtered, &[])), + policy_error: None, }) } +/// The root policy files as the session received them. A path selection +/// listed but the host never sent is present without content (never +/// absent: it may narrow the scan). +fn memory_policy_fs(files: &BTreeMap, listed: &[String]) -> MemoryPolicyFs { + let mut fs = MemoryPolicyFs::default(); + for name in POLICY_FILE_NAMES { + let file = match files.get(name).map(|f| &f.entry) { + Some(MemoryEntry::Text(text)) => RootFile::Present(text.as_bytes().to_vec()), + Some(MemoryEntry::Binary(bytes)) => RootFile::Present(bytes.to_vec()), + Some(_) => RootFile::PresentWithoutContent, + None if listed.iter().any(|l| l == name) => RootFile::PresentWithoutContent, + None => continue, + }; + fs.files.insert(name.to_string(), file); + fs.root_names.push(name.to_string()); + } + fs +} + +/// The session result for a policy file that cannot be honored: no root +/// processed, no file changed. +fn policy_error_output( + error: &socket_patch_core::policy::PolicyError, + warnings: Vec, + files_input: u64, + bytes_input: u64, +) -> HostedScanOutput { + HostedScanOutput { + projects: Vec::new(), + changed_files: Vec::new(), + changed_binary_files: Vec::new(), + deleted_files: Vec::new(), + warnings, + stats: EngineStats { + files_input, + bytes_input, + ..EngineStats::default() + }, + engine_version: engine_version(), + policy: None, + policy_error: Some(PolicyErrorInfo { + code: error.code().to_string(), + detail: error.to_string(), + }), + } +} + +/// The tier filter, the severity floor and the per-package ranking (the +/// disk `ScanPolicy::select` without a recorded view, which the in-memory +/// engine does not read yet). With `patches.enabled: false` nothing is +/// selected and every candidate is reported `policy_disabled`. +fn select_with_policy( + policy: &SelectionPolicy, + results: Vec, + can_access_paid: bool, + root: &str, + filtered: &mut Vec, + skipped: &mut Vec, +) -> Vec<(String, String)> { + let accessible: Vec = results + .into_iter() + .filter(|p| can_access_paid || p.tier == "free") + .collect(); + let (admitted, dropped) = if policy.enabled() { + policy.floor_filter(accessible) + } else { + ( + Vec::new(), + accessible + .into_iter() + .map(|p| (p, FilterReason::Disabled)) + .collect(), + ) + }; + let selected = discover::select_top_ranked(&admitted, true); + let chosen: BTreeSet<&str> = selected.iter().map(|(purl, _)| purl.as_str()).collect(); + let mut by_purl: BTreeMap> = BTreeMap::new(); + for (patch, reason) in dropped { + if !chosen.contains(patch.purl.as_str()) { + by_purl.entry(patch.purl.clone()).or_default().push((patch, reason)); + } + } + for (purl, mut group) in by_purl { + group.sort_by(|a, b| socket_patch_core::api::ranking::cmp_search_results(&a.0, &b.0)); + let (winner, reason) = group.swap_remove(0); + skipped.push(SkippedPatch { + purl: purl.clone(), + uuid: winner.uuid.clone(), + reason: reason.code().to_string(), + detail: Some(reason.detail()), + }); + filtered.push(FilteredEntry { + purl: Some(purl), + uuid: Some(winner.uuid.clone()), + project: root.to_string(), + severity: Some(patch_severity_order(&winner)), + reason, + }); + } + selected +} + /// Records → the project's result and changed files. fn finish_root( state: &mut RootState, @@ -857,6 +1042,7 @@ mod tests { packages: Vec::new(), selected: Vec::new(), skipped: Vec::new(), + policy_skipped: Vec::new(), error: None, } } diff --git a/crates/socket-patch-cli/src/hosted_memory/roots.rs b/crates/socket-patch-cli/src/hosted_memory/roots.rs index 002cac15..91cac49b 100644 --- a/crates/socket-patch-cli/src/hosted_memory/roots.rs +++ b/crates/socket-patch-cli/src/hosted_memory/roots.rs @@ -52,19 +52,40 @@ pub(crate) const UNSUPPORTED_MARKERS: [(&str, &[&str]); 2] = [ ]; /// Directory names whose subtrees never hold a project root: installed -/// trees, VCS and tool state, vendored dependencies, and test fixtures. -pub(crate) const EXCLUDED_ROOT_SEGMENTS: [&str; 10] = [ - "node_modules", - ".git", - ".socket", - ".yarn", - "vendor", - "test", - "tests", - "fixtures", - "__fixtures__", - "testdata", -]; +/// trees, VCS and tool state, and vendored dependencies. Structural, so no +/// policy can negate them. (Test and fixture trees are the socket.yml +/// policy's overridable built-in ignores: [`default_ignored_dir`].) +pub(crate) const EXCLUDED_ROOT_SEGMENTS: [&str; 5] = ["node_modules", ".git", ".socket", ".yarn", "vendor"]; + +/// Whether `dir` (repo-relative) is under a built-in default ignore of the +/// socket.yml policy (`test/`, `tests/`, `fixtures/`, …, any case). +pub(crate) fn default_ignored_dir(dir: &str) -> bool { + !dir.is_empty() + && socket_patch_core::policy::builtin_defaults() + .admits_root(&socket_patch_core::policy::Root { + rel_dir: dir, + markers: &[], + explicit: false, + }) + .is_err() +} + +/// The marker basenames of `root` among `paths` (the files the policy's +/// path filters test for that root). +pub(crate) fn root_markers<'a>(root: &str, paths: impl IntoIterator) -> Vec { + let mut out: Vec = paths + .into_iter() + .filter_map(|path| { + let (dir, base) = split_path(path); + let marker = marker_ecosystem(base).is_some() + || UNSUPPORTED_MARKERS.iter().any(|(_, names)| names.contains(&base)); + (dir == root && marker).then(|| base.to_string()) + }) + .collect(); + out.sort(); + out.dedup(); + out +} /// The ecosystem a root marker basename belongs to. pub(crate) fn marker_ecosystem(base: &str) -> Option<&'static str> { @@ -151,6 +172,19 @@ pub(crate) fn detect_roots<'a>( .collect(); let mut roots: Vec = Vec::new(); for dir in markers.keys() { + let marker_names: Vec = marker_paths + .get(dir) + .into_iter() + .flatten() + .map(|p| split_path(p).1.to_string()) + .collect(); + let default_ignored = socket_patch_core::policy::builtin_defaults() + .admits_root(&socket_patch_core::policy::Root { + rel_dir: dir, + markers: &marker_names, + explicit: false, + }) + .is_err(); let rush_internal = rush_roots.iter().any(|r| { let internal = |sub: &str| join_root(r, sub); *dir == internal("common/config/rush") @@ -158,7 +192,9 @@ pub(crate) fn detect_roots<'a>( || *dir == internal("common/temp") || dir.starts_with(&format!("{}/", internal("common/temp"))) }); - let reason = if rush_internal { + let reason = if default_ignored { + Some("policy_path_excluded") + } else if rush_internal { Some("rush_internal") } else { None @@ -217,7 +253,28 @@ mod tests { ); assert_eq!(found, vec!["docs"]); assert_eq!(ignored.len(), 4); - assert!(ignored.iter().all(|i| i.reason == "excluded_dir")); + let reason = |path: &str| ignored.iter().find(|i| i.path == path).unwrap().reason.clone(); + assert_eq!(reason("node_modules/x/package-lock.json"), "excluded_dir"); + assert_eq!(reason("a/vendor/b/composer.lock"), "excluded_dir"); + assert_eq!(reason(".socket/vendor/npm/package-lock.json"), "excluded_dir"); + // Test/fixture trees are the policy's overridable built-in ignores. + assert_eq!(reason("test/fixtures/yarn.lock"), "policy_path_excluded"); + } + + #[test] + fn default_ignores_are_case_insensitive_and_marker_based() { + let (found, _) = detect_roots( + ["Tests/app/yarn.lock", "e2e/testdata/go.mod", "apps/testing/package-lock.json"], + None, + ); + assert_eq!(found, vec!["apps/testing"]); + assert!(default_ignored_dir("a/__fixtures__")); + assert!(!default_ignored_dir("")); + assert!(!default_ignored_dir("apps/testing")); + assert_eq!( + root_markers("a", ["a/yarn.lock", "a/package.json", "a/b/yarn.lock", "a/pom.xml"]), + vec!["pom.xml".to_string(), "yarn.lock".to_string()] + ); } #[test] diff --git a/crates/socket-patch-cli/src/hosted_memory/select.rs b/crates/socket-patch-cli/src/hosted_memory/select.rs index b7d939af..cae005f6 100644 --- a/crates/socket-patch-cli/src/hosted_memory/select.rs +++ b/crates/socket-patch-cli/src/hosted_memory/select.rs @@ -255,14 +255,26 @@ pub fn select_paths(entries: &[TreeEntryInput], options: &SelectOptions) -> Path && !dir .split('/') .any(|seg| EXCLUDED_ROOT_SEGMENTS.contains(&seg)) + && !super::roots::default_ignored_dir(dir) }); if let Some(path) = first { needs.entry(path.clone()).or_insert(Need::Present); } } + // The repo-root policy files: always streamed when listed (a symlinked + // one lands in `symlinks`, and the session then fails closed on it). + let mut policy_paths: Vec = Vec::new(); + for name in socket_patch_core::policy::POLICY_FILE_NAMES { + if blobs.contains_key(name) { + needs.entry(name.to_string()).or_insert(Need::Text); + policy_paths.push(name.to_string()); + } + } + let mut selection = PathSelection { roots, + policy_paths, ..PathSelection::default() }; for (path, need) in needs { diff --git a/crates/socket-patch-cli/src/hosted_memory/types.rs b/crates/socket-patch-cli/src/hosted_memory/types.rs index ed8e3a84..04b07971 100644 --- a/crates/socket-patch-cli/src/hosted_memory/types.rs +++ b/crates/socket-patch-cli/src/hosted_memory/types.rs @@ -100,6 +100,17 @@ pub struct HostedScanOptions { pub request_timeout_ms: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub limits: Option, + /// Ignore the repo's socket.yml (`--no-socket-yml`); default false. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub no_socket_yml: Option, + /// `critical|high|medium|moderate|low|none`; beats the file's + /// `patches.minSeverity` (`--min-severity`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub min_severity: Option, + /// The `policyPaths` path selection returned: each must arrive with + /// content, or the session fails with `policyError`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy_paths: Option>, } pub const DEFAULT_BATCH_SIZE: u32 = 100; @@ -278,6 +289,23 @@ pub struct HostedScanOutput { pub warnings: Vec, pub stats: EngineStats, pub engine_version: String, + /// The session-level `policy` block (the CLI's `policy` JSON shape); + /// absent on a `policyError`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy: Option, + /// A socket.yml that cannot be honored: no root was processed and no + /// file changed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy_error: Option, +} + +/// `HostedScanResult.policyError`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PolicyErrorInfo { + /// `socket_yml_invalid` or `socket_yml_ambiguous`. + pub code: String, + pub detail: String, } /// `TreeEntryInput`. @@ -321,6 +349,10 @@ pub struct PathSelection { pub ignored_count: u64, /// At most [`super::select::IGNORED_SAMPLE_MAX`] entries. pub ignored_sample: Vec, + /// The root socket.yml / socket.yaml the tree lists (pass them back as + /// the session's `policyPaths`). + #[serde(default)] + pub policy_paths: Vec, } /// Engine failure (`finish()` rejection codes). diff --git a/crates/socket-patch-cli/tests/cli_parse_scan.rs b/crates/socket-patch-cli/tests/cli_parse_scan.rs index 97aac035..9d591160 100644 --- a/crates/socket-patch-cli/tests/cli_parse_scan.rs +++ b/crates/socket-patch-cli/tests/cli_parse_scan.rs @@ -45,6 +45,8 @@ const SCAN_ENV_VARS: &[&str] = &[ "SOCKET_JSON", "SOCKET_LOCK_TIMEOUT", "SOCKET_MANIFEST_PATH", + "SOCKET_MIN_SEVERITY", + "SOCKET_NO_SOCKET_YML", "SOCKET_NO_TRUST_LOCKFILE_CONFIG", "SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG", "SOCKET_NO_VLT_INSTALL_CLEANUP", @@ -994,3 +996,78 @@ fn no_vlt_install_cleanup_flag_and_env_parse() { _ => panic!("expected Scan"), } } + +/// Parse `scan` under a clean env plus `env`, restoring it afterwards. +fn parse_scan_with_env(extra: &[&str], env: &[(&str, &str)]) -> Result { + with_clean_env(|| { + for (k, v) in env { + std::env::set_var(k, v); + } + let mut argv = vec!["socket-patch", "scan"]; + argv.extend_from_slice(extra); + let cli = Cli::try_parse_from(&argv); + for (k, _) in env { + std::env::remove_var(k); + } + cli.map(|c| match c.command { + Commands::Scan(a) => a, + _ => panic!("expected Scan"), + }) + }) +} + +/// `--no-socket-yml` / `SOCKET_NO_SOCKET_YML`: a bool with the repo-wide +/// vocabulary; empty is unset; garbage is a parse error. +#[test] +#[serial_test::serial] +fn no_socket_yml_flag_and_env() { + assert!(!parse_scan(&[]).socket_yml.no_socket_yml); + assert!(parse_scan(&["--no-socket-yml"]).socket_yml.no_socket_yml); + for (value, expected) in [("1", true), ("true", true), ("0", false), ("", false)] { + let args = parse_scan_with_env(&[], &[("SOCKET_NO_SOCKET_YML", value)]).expect("parse"); + assert_eq!(args.socket_yml.no_socket_yml, expected, "{value:?}"); + } + assert!(parse_scan_with_env(&[], &[("SOCKET_NO_SOCKET_YML", "garbage")]).is_err()); +} + +/// `--min-severity` / `SOCKET_MIN_SEVERITY`: the flag beats the env, the +/// layer is recorded, `none` lifts the floor, empty env is unset, and a +/// malformed value is a usage error (the flag at parse time, the env when +/// the overrides are resolved; scan exits 2 either way). +#[test] +#[serial_test::serial] +fn min_severity_flag_and_env() { + use socket_patch_core::policy::OverrideSource; + let overrides = |extra: &[&str], env: &[(&str, &str)]| { + let args = parse_scan_with_env(extra, env).expect("parse"); + with_clean_env(|| { + for (k, v) in env { + std::env::set_var(k, v); + } + let out = args.socket_yml.overrides(); + for (k, _) in env { + std::env::remove_var(k); + } + out + }) + }; + assert_eq!(parse_scan(&[]).socket_yml.min_severity, None); + assert_eq!(overrides(&[], &[]).unwrap().min_severity, None); + assert_eq!( + overrides(&["--min-severity", "High"], &[]).unwrap().min_severity, + Some((Some(1), OverrideSource::Flag)) + ); + assert_eq!( + overrides(&["--min-severity", "none"], &[("SOCKET_MIN_SEVERITY", "critical")]).unwrap().min_severity, + Some((None, OverrideSource::Flag)) + ); + assert_eq!( + overrides(&[], &[("SOCKET_MIN_SEVERITY", "moderate")]).unwrap().min_severity, + Some((Some(2), OverrideSource::Env)) + ); + assert_eq!(overrides(&[], &[("SOCKET_MIN_SEVERITY", "")]).unwrap().min_severity, None); + assert!(overrides(&[], &[("SOCKET_MIN_SEVERITY", "severe")]).is_err()); + assert!(try_parse_scan(&["--min-severity", "severe"]).is_err()); + assert!(overrides(&["--no-socket-yml"], &[]).unwrap().bypass); +} + diff --git a/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs b/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs index 56566739..02e054b5 100644 --- a/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs +++ b/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs @@ -819,3 +819,45 @@ async fn vendored_dry_run_previews_only_admitted_patches() { assert_eq!(filtered_reason(&doc, "pkg:npm/alpha@1.0.0")["reason"], "policy_package_not_listed"); assert_eq!(filtered_reason(&doc, "pkg:npm/beta@1.0.0")["reason"], "policy_severity"); } + +// --------------------------------------------------------------------------- +// get +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn get_bypasses_the_policy_with_a_warning() { + let server = MockServer::start().await; + mount_api(&server, catalog()).await; + let repo = Repo::new(Some("version: 2\npatches:\n ignorePackages: [alpha]\n")); + let web = repo.dir("services/web"); + let args = [ + "get", + "pkg:npm/alpha@1.0.0", + "--json", + "--yes", + "--dry-run", + "--cwd", + web.to_str().unwrap(), + "--api-url", + &server.uri(), + "--org", + ORG, + "--api-token", + "fake", + ]; + let (code, stdout, stderr) = run_cli(&web, &args, &[]); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); + let doc: Value = serde_json::from_str(&stdout).unwrap(); + let warnings: Vec<&str> = doc["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect(); + assert!( + warnings.iter().any(|w| w.starts_with("(policy_bypassed)") && w.contains("alpha")), + "{doc:#}" + ); + + // An invalid file never fails `get`; it only drops the warning. + std::fs::write(repo.root.join("socket.yml"), "version: 2\npatches: [\n").unwrap(); + let (code, stdout, stderr) = run_cli(&web, &args, &[]); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!(!stdout.contains("policy_bypassed"), "{stdout}"); +} diff --git a/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs b/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs index c0e60e47..72ba3b97 100644 --- a/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs +++ b/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs @@ -312,13 +312,29 @@ pub struct DiskRun { /// node / pipenv / gem subprocesses), `HOME` and the language caches at /// empty directories, no socket-cli config, no telemetry. pub fn run_disk(server: &MockServer, files: &BTreeMap>, dry_run: bool) -> DiskRun { - let project = tempfile::tempdir().unwrap(); + run_disk_in(server, files, "", dry_run) +} + +/// [`run_disk`] with `--cwd` at the repo-relative `cwd_rel` of a checkout +/// (a `.git` directory marks the repo root, so a root `socket.yml` +/// applies); `changed` stays relative to the repo root. +pub fn run_disk_in( + server: &MockServer, + files: &BTreeMap>, + cwd_rel: &str, + dry_run: bool, +) -> DiskRun { + let checkout = tempfile::tempdir().unwrap(); let home = tempfile::tempdir().unwrap(); for (rel, bytes) in files { - let path = project.path().join(rel); + let path = checkout.path().join(rel); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(&path, bytes).unwrap(); } + if !cwd_rel.is_empty() { + std::fs::create_dir_all(checkout.path().join(".git")).unwrap(); + } + let cwd = checkout.path().join(cwd_rel); let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_socket-patch")); cmd.env_clear(); for keep in [ @@ -356,7 +372,7 @@ pub fn run_disk(server: &MockServer, files: &BTreeMap>, dry_run: "--json", "--yes", "--cwd", - project.path().to_str().unwrap(), + cwd.to_str().unwrap(), "--org", ORG, "--api-token", @@ -372,7 +388,7 @@ pub fn run_disk(server: &MockServer, files: &BTreeMap>, dry_run: let stderr = String::from_utf8_lossy(&output.stderr).to_string(); let envelope: Value = serde_json::from_str(&stdout) .unwrap_or_else(|e| panic!("disk --json output is not JSON ({e}):\n{stdout}\n{stderr}")); - let after = read_tree(project.path()); + let after = read_tree(checkout.path()); let changed = after .into_iter() .filter(|(rel, bytes)| files.get(rel) != Some(bytes)) diff --git a/crates/socket-patch-cli/tests/hosted_memory_parity.rs b/crates/socket-patch-cli/tests/hosted_memory_parity.rs index 5876fe19..c26764f0 100644 --- a/crates/socket-patch-cli/tests/hosted_memory_parity.rs +++ b/crates/socket-patch-cli/tests/hosted_memory_parity.rs @@ -745,3 +745,185 @@ async fn excluded_nested_cargo_project_is_its_own_root_through_selection() { memory.warnings ); } + +/// A three-root repo with a root socket.yml: two copies of the npm +/// fixture and the cargo fixture. +fn policy_repo(socket_yml: &str) -> (Vec, BTreeMap>) { + let npm = fixtures_root().join("redirect/npm/package-lock-v3/basic"); + let cargo = fixtures_root().join("redirect/cargo/cargo/basic"); + let mut patches = patches_from_overrides(&npm.join("overrides.json"), None); + patches.extend(patches_from_overrides(&cargo.join("overrides.json"), None)); + let mut repo: BTreeMap> = BTreeMap::new(); + for (root, dir) in [("apps/web", &npm), ("apps/legacy", &npm), ("services/api", &cargo)] { + for (rel, bytes) in fixture_files(&dir.join("input")) { + repo.insert(format!("{root}/{rel}"), bytes); + } + } + repo.insert("socket.yml".to_string(), socket_yml.as_bytes().to_vec()); + (patches, repo) +} + +fn policy_options() -> socket_patch_cli::hosted_memory::HostedScanOptions { + let mut opts = options(false); + opts.policy_paths = Some(vec!["socket.yml".to_string()]); + opts +} + +/// `(project, purl, reason)` of a `policy.filtered[]` list. +fn filtered_set(policy: &Value) -> std::collections::BTreeSet<(String, Option, String)> { + policy["filtered"] + .as_array() + .unwrap() + .iter() + .map(|f| { + ( + f["project"].as_str().unwrap().to_string(), + f["purl"].as_str().map(str::to_string), + f["reason"].as_str().unwrap().to_string(), + ) + }) + .collect() +} + +#[tokio::test] +async fn parity_socket_yml_filters_the_same_roots_and_packages() { + let (patches, repo) = policy_repo( + "version: 2\npatches:\n ignorePaths: [\"/apps/legacy/\"]\n ignorePackages: [\"pkg:cargo/serde\"]\n", + ); + let server = MockServer::start().await; + mount_api(&server, &patches).await; + let memory = run_engine(&server, build_input(&repo, &[], policy_options())).await; + assert!(memory.policy_error.is_none(), "{:?}", memory.policy_error); + let roots: Vec<&str> = memory.projects.iter().map(|p| p.root.as_str()).collect(); + assert_eq!(roots, vec!["apps/web", "services/api"], "the ignored root is not processed"); + let memory_policy = memory.policy.clone().expect("policy block"); + assert_eq!(memory_policy["source"], "file"); + + let mut disk_filtered = std::collections::BTreeSet::new(); + for root in ["apps/web", "apps/legacy", "services/api"] { + let disk = run_disk_in(&server, &repo, root, false); + assert_eq!(disk.envelope["status"], "success", "{root}: {}", disk.stderr); + assert_eq!(disk.envelope["policy"]["sha256"], memory_policy["sha256"], "{root}"); + disk_filtered.extend(filtered_set(&disk.envelope["policy"])); + if let Some(project) = memory.projects.iter().find(|p| p.root == root) { + assert_eq!(project.redirect, disk.envelope["redirect"], "{root}"); + let prefix = format!("{root}/"); + let memory_changed: BTreeMap> = engine_changed(&memory) + .into_iter() + .filter(|(k, _)| k.starts_with(&prefix)) + .collect(); + assert_eq!(memory_changed, disk.changed, "{root}"); + } else { + assert!(disk.changed.is_empty(), "{root}: an ignored root changes nothing"); + } + } + assert_eq!(filtered_set(&memory_policy), disk_filtered); + assert!(disk_filtered.contains(&("apps/legacy".to_string(), None, "policy_path_excluded".to_string()))); + assert!(disk_filtered.contains(&( + "services/api".to_string(), + Some("pkg:cargo/serde@1.0.190".to_string()), + "policy_package_ignored".to_string() + ))); +} + +#[tokio::test] +async fn parity_socket_yml_severity_floor() { + let (patches, repo) = policy_repo("version: 2\npatches:\n minSeverity: critical\n"); + let server = MockServer::start().await; + mount_api(&server, &patches).await; + let memory = run_engine(&server, build_input(&repo, &[], policy_options())).await; + let web = memory.projects.iter().find(|p| p.root == "apps/web").unwrap(); + assert!(web.redirected.is_empty(), "{:#}", web.redirect); + assert!(web.skipped.iter().any(|s| s.reason == "policy_severity"), "{:?}", web.skipped); + assert!(engine_changed(&memory).is_empty()); + let disk = run_disk_in(&server, &repo, "apps/web", false); + assert!(disk.changed.is_empty()); + assert_eq!(disk.envelope["redirect"], web.redirect); + let memory_web: std::collections::BTreeSet<_> = filtered_set(memory.policy.as_ref().unwrap()) + .into_iter() + .filter(|(project, _, _)| project == "apps/web") + .collect(); + assert_eq!(memory_web, filtered_set(&disk.envelope["policy"])); +} + +#[tokio::test] +async fn memory_policy_file_withheld_or_invalid_is_a_policy_error() { + let (patches, repo) = policy_repo("version: 2\npatches:\n maxNewPatches: 1\n"); + let server = MockServer::start().await; + mount_api(&server, &patches).await; + // Listed by selection but never streamed. + let mut withheld = repo.clone(); + withheld.remove("socket.yml"); + let out = run_engine(&server, build_input(&withheld, &[], policy_options())).await; + let err = out.policy_error.expect("policyError"); + assert_eq!(err.code, "socket_yml_invalid"); + assert!(out.projects.is_empty() && out.changed_files.is_empty() && out.policy.is_none()); + // Streamed present-without-content. + let out = run_engine(&server, build_input(&withheld, &["socket.yml"], policy_options())).await; + assert_eq!(out.policy_error.expect("policyError").code, "socket_yml_invalid"); + // Invalid content. + let (_, bad) = policy_repo("version: 2\npatches:\n apiUrl: https://evil.example\n"); + let out = run_engine(&server, build_input(&bad, &[], policy_options())).await; + let err = out.policy_error.expect("policyError"); + assert!(err.detail.contains("patches.apiUrl"), "{}", err.detail); + assert!(out.changed_files.is_empty()); + // noSocketYml skips it. + let mut opts = policy_options(); + opts.no_socket_yml = Some(true); + let out = run_engine(&server, build_input(&bad, &[], opts)).await; + assert!(out.policy_error.is_none()); + assert_eq!(out.policy.unwrap()["source"], "bypassed"); +} + +#[tokio::test] +async fn memory_min_severity_option_beats_the_file() { + let (patches, repo) = policy_repo("version: 2\npatches:\n minSeverity: critical\n"); + let server = MockServer::start().await; + mount_api(&server, &patches).await; + let mut opts = policy_options(); + opts.min_severity = Some("none".to_string()); + let out = run_engine(&server, build_input(&repo, &[], opts)).await; + let policy = out.policy.unwrap(); + assert_eq!(policy["minSeverity"], serde_json::json!({"value": null, "source": "flag"})); + assert!(out.projects.iter().any(|p| !p.redirected.is_empty())); + let mut bad = policy_options(); + bad.min_severity = Some("severe".to_string()); + assert!(socket_patch_cli::hosted_memory::SessionBuilder::new(bad).is_err()); +} + +#[test] +fn selection_streams_policy_files_and_applies_built_in_ignores() { + use socket_patch_cli::hosted_memory::{select_paths, SelectOptions, TreeEntryInput}; + let blob = |path: &str, mode: &str| TreeEntryInput { + path: path.to_string(), + mode: mode.to_string(), + kind: "blob".into(), + size: Some(1), + }; + let entries = vec![ + blob("socket.yml", "100644"), + blob("socket.yaml", "120000"), + blob("Socket.yml", "100644"), + blob("apps/web/package-lock.json", "100644"), + blob("apps/web/tests/app/package-lock.json", "100644"), + blob("Fixtures/x/yarn.lock", "100644"), + ]; + let selection = select_paths(&entries, &SelectOptions::default()); + assert_eq!(selection.policy_paths, vec!["socket.yml", "socket.yaml"]); + assert!(selection.fetch_text.contains(&"socket.yml".to_string())); + assert!(selection.symlinks.contains(&"socket.yaml".to_string())); + assert_eq!(selection.roots, vec!["apps/web"]); + assert!(selection + .ignored_sample + .iter() + .any(|i| i.path == "apps/web/tests/app/package-lock.json" && i.reason == "policy_path_excluded")); + // Named roots are explicit: the built-in ignores do not apply. + let named = select_paths( + &entries, + &SelectOptions { + project_roots: Some(vec!["apps/web/tests/app".to_string()]), + ..SelectOptions::default() + }, + ); + assert_eq!(named.roots, vec!["apps/web/tests/app"]); +} diff --git a/crates/socket-patch-core/src/policy/mod.rs b/crates/socket-patch-core/src/policy/mod.rs index 466d7f1c..f60a2472 100644 --- a/crates/socket-patch-core/src/policy/mod.rs +++ b/crates/socket-patch-core/src/policy/mod.rs @@ -432,6 +432,13 @@ fn compile(lists: &[(&'static str, &[String])]) -> PathMatcher { .unwrap_or_else(|_| PathMatcher::new(&[]).expect("an empty pattern list always compiles")) } +/// The built-in defaults, compiled once (for callers that only need the +/// default path ignores, e.g. tree-listing root detection). +pub fn builtin_defaults() -> &'static SelectionPolicy { + static DEFAULTS: std::sync::LazyLock = std::sync::LazyLock::new(SelectionPolicy::unrestricted); + &DEFAULTS +} + impl SelectionPolicy { /// No file: only the built-in default ignores. pub fn unrestricted() -> Self { diff --git a/crates/socket-patch-core/src/policy/tests.rs b/crates/socket-patch-core/src/policy/tests.rs index 8f053ffb..b3a75a36 100644 --- a/crates/socket-patch-core/src/policy/tests.rs +++ b/crates/socket-patch-core/src/policy/tests.rs @@ -585,3 +585,18 @@ mod disk { assert_eq!(warnings[0].code, "socket_yml_repo_untrusted"); } } + +#[test] +fn this_repos_socket_yml_loads_and_excludes_its_fixtures() { + let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let (policy, warnings) = + SelectionPolicy::load(&DiskPolicyFs::new(&repo), &PolicyOverrides::default()).expect("valid"); + assert!(warnings.is_empty(), "{warnings:?}"); + assert!(matches!(policy.source(), PolicySource::File { path, .. } if path == "socket.yml")); + let lock = strings(&["package-lock.json"]); + let err = policy + .admits_root(&root("crates/socket-patch-core/tests/fixtures/redirect/npm", &lock, true)) + .unwrap_err(); + assert_eq!(err.detail(), "crates/socket-patch-core/tests/fixtures/** (projectIgnorePaths)"); + assert!(policy.admits_root(&root("", &lock, true)).is_ok()); +} diff --git a/crates/socket-patch-node/npm/index.d.ts b/crates/socket-patch-node/npm/index.d.ts index e5fc61dc..e12e8d8c 100644 --- a/crates/socket-patch-node/npm/index.d.ts +++ b/crates/socket-patch-node/npm/index.d.ts @@ -9,6 +9,7 @@ export interface PathSelection { symlinks: string[] // candidate paths that are symlinks (mode 120000) — refuse-to-write ignoredCount: number ignoredSample: { path: string; reason: string }[] // ≤100 + policyPaths: string[] // root socket.yml / socket.yaml the tree lists (also in fetchText or symlinks); pass back as the session's policyPaths } export function selectHostedScanPaths(entries: TreeEntryInput[], options?: { projectRoots?: string[]; ecosystems?: Ecosystem[] }): PathSelection export function hostedScanCandidateFiles(): string[] // debug listing only @@ -48,6 +49,9 @@ export interface HostedScanSessionOptions { providerConcurrency?: number // default 8 requestTimeoutMs?: number // per provider call, default 60000 limits?: HostedScanLimits + noSocketYml?: boolean // ignore the repo's socket.yml (built-in test/fixture ignores still apply); default false + minSeverity?: 'critical' | 'high' | 'medium' | 'moderate' | 'low' | 'none' // beats socket.yml patches.minSeverity + policyPaths?: string[] // selectHostedScanPaths' policyPaths; each must be streamed with content or the session returns policyError } export class HostedScanSession { constructor(options: HostedScanSessionOptions, provider: PatchProvider) @@ -63,7 +67,7 @@ export interface ProjectResult { redirect: Record // same shape as CLI `--json` `redirect` block summary: { scannedPackages: number; packagesWithPatches: number; totalPatches: number; freePatches: number; paidPatches: number; canAccessPaidPatches: boolean } redirected: { purl: string; uuid: string }[] - skipped: { purl: string; uuid: string; reason: string; detail?: string }[] + skipped: { purl: string; uuid: string; reason: string; detail?: string }[] // reasons include the policy_* codes error?: { code: string; message: string } // project-level failure (e.g. corrupt_ledger, patch_lookup_failed) } export interface HostedScanResult { @@ -74,6 +78,19 @@ export interface HostedScanResult { warnings: EngineWarning[] stats: { projects: number; filesInput: number; bytesInput: number; packagesScanned: number; packagesWithPatches: number; patchesSelected: number; patchesRedirected: number; filesChanged: number; providerCalls: Record; phaseMs: Record } engineVersion: string + policy?: PolicyBlock // absent only with policyError + policyError?: { code: 'socket_yml_invalid' | 'socket_yml_ambiguous'; detail: string } // no root processed, no file changed +} +export type PolicyReason = 'policy_disabled' | 'policy_path_excluded' | 'policy_path_not_included' | 'policy_ecosystem' | 'policy_package_not_listed' | 'policy_package_ignored' | 'policy_severity' +export interface PolicyBlock { + source: 'none' | 'file' | 'bypassed' + path: string | null + sha256: string | null + enabled: boolean + minSeverity: { value: 'critical' | 'high' | 'medium' | 'low' | null; source: 'flag' | 'env' | 'file' | 'default' } + counts: { filtered: number; retained: number } + filtered: { purl: string | null; uuid: string | null; project: string; reason: PolicyReason; detail: string }[] + retained: { purl: string; project: string; recordedUuid: string; reason: PolicyReason; detail: string; upgradeAvailable: boolean }[] } export class SocketPatchAddonUnavailableError extends Error { From 48f2e78e591b700ce1242495ffa4f87304282263 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 14:35:02 +0000 Subject: [PATCH 13/22] Document the socket.yml patch policy CLI_CONTRACT.md gains a "socket.yml patch policy" section (grammar, precedence, paths, lookup, validation, commands, error codes and the policy JSON block), the flag and env rows, and the "narrow or pace" half of the trust-boundary rule. README adds a "Roll out gradually" section with copyable socket.yml recipes, CHANGELOG lists the new policy and the breaking scan changes, and the design docs record the decisions made while building it. Claude-Session: https://claude.ai/code/session_01BKsyzefGhAnPkYmXCwq3H3 Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 42 ++++++++++++ README.md | 69 +++++++++++++++++++ crates/socket-patch-cli/CLI_CONTRACT.md | 88 ++++++++++++++++++++++++- docs/design/configuration.md | 18 ++++- docs/design/staged-rollout.md | 57 ++++++++++++++++ 5 files changed, 270 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f92db53..9907bd10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -560,8 +560,50 @@ into the new version's section — see docs/releasing.md. batch is reported as failed (warning, or the all-failed error when it was the only batch) — instead of that one package being skipped silently. +- **scan honors the repo's socket.yml.** `projectIgnorePaths` (the + scanner's key) now also keeps `scan` from patching the matching projects, + in every mode and in the in-memory engine, whether or not the file has a + `patches` block (malformed values there only warn + `socket_yml_ignored_value`). See "socket.yml patch policy" in + CLI_CONTRACT.md. +- **Test and fixture trees are skipped by default when scan discovers + projects.** `test/ tests/ fixtures/ __fixtures__/ testdata/` (any case) + are built-in `ignorePaths` for discovered roots: hosted/vendored + PATH-glob matches (`scan 'services/*'`) and the in-memory engine's + detected roots (which used to skip them through a hard-coded, case- + sensitive segment list). A directory you name (`--cwd`, a literal PATH, + `projectRoots`) is not affected; `ignorePaths: ["!/e2e/tests/"]` + re-includes one. +- **An invalid socket.yml fails scan.** An unparseable file, a misspelled or + invalid `patches` block (unknown key, wrong type, bad glob, `patches` + without `version: 2`), or `socket.yml` and `socket.yaml` that disagree + now fail `scan` before any request or write: exit 1, `errorCode: + socket_yml_invalid` / `socket_yml_ambiguous`, the key path and the fix + in the message. `--no-socket-yml` ignores the file for one run. +- **scan rejects a PATH outside the repository root** (exit 2): one socket.yml + policy per invocation. + ### Added +- **socket.yml patch policy (staged rollout).** A `patches` block in the + repo-root socket.yml narrows what `scan` patches: `enabled` (false = + report only), `includePaths` / `ignorePaths` (gitignore patterns matched + against each project's lockfiles, npm `ignore` semantics), + `ecosystems`, `packages` / `ignorePackages` (`--package` specs), + `minSeverity` (critical|high|medium|moderate|low, judged by the worst + advisory a patch fixes) and `maxNewPatches` (validated; the per-run cap + lands with `--max-new-patches`). Flags only narrow further. A package + that already carries a patch is never removed, upgraded or replaced by + the policy: it is held and reported under `policy.retained[]`. New flags + `--min-severity` / `SOCKET_MIN_SEVERITY` and `--no-socket-yml` / + `SOCKET_NO_SOCKET_YML`; every successful `scan --json` result gains a + top-level `policy` block (`source`, `sha256`, `minSeverity`, `filtered[]`, + `retained[]`) and the human output a `Policy (socket.yml): …` line that + names every skipped critical/high patch. The in-memory engine takes + `noSocketYml` / `minSeverity` / `policyPaths`, `selectHostedScanPaths` + returns `policyPaths`, and the result carries `policy` or `policyError`. + `get` ignores the policy and warns `policy_bypassed`. + - **`scan --package `** (repeatable or comma-separated, env `SOCKET_SCAN_PACKAGES`) scopes a scan to the named packages: a name (`lodash`, `@scope/pkg`, `group:artifact`) or a purl with or without its diff --git a/README.md b/README.md index 645bf414..6231ab44 100644 --- a/README.md +++ b/README.md @@ -413,6 +413,10 @@ socket-patch scan 'services/*' # directory glo hosted and vendored mode each PATH is a project directory, scanned as if it were `--cwd` under an `== ==` header; the worst exit code wins. +To make the choice stick for everyone who runs `scan` in the repo (CI and the Socket +autopatch bot included), put it in `socket.yml` instead — see +[Roll out gradually](#roll-out-gradually-with-socketyml). + ### Patch one specific CVE or advisory ```bash @@ -650,6 +654,8 @@ socket-patch scan [PATHS]... [options] | `--prune` | — | Agent-mode garbage collection after the scan: remove manifest entries for packages no longer present in the crawl (installed trees + lockfiles — a wiped `node_modules` alone doesn't prune lockfile-listed entries) and delete orphan blob/diff/package-archive files. [Vendored](#vendor) packages are exempt from the crawl-based prune, but a vendored entry whose dependency has left the lockfile is reverted. Ignored, with a `redirect_prune_ignored` warning, in hosted mode; without a mode the scan is report-only. | | `--sync` | — | Shorthand for `--mode agent --prune`: the one-flag agent-mode auto-update run. | | `--batch-size ` | `SOCKET_BATCH_SIZE` | Packages per API request (default: `500` on the authenticated API, `100` on the public proxy). A request whose body would exceed 256 KiB is split into smaller ones. | +| `--min-severity ` | `SOCKET_MIN_SEVERITY` | Only patch packages whose patch fixes an advisory of at least `critical`, `high`, `medium` (or `moderate`) or `low`; `none` lifts the floor. Overrides `patches.minSeverity` in socket.yml. Patches of unknown severity are skipped whenever a floor is set. | +| `--no-socket-yml` | `SOCKET_NO_SOCKET_YML` | Ignore the repo's socket.yml patch policy for this run (the built-in test/fixture directory ignores still apply). | | `--all-releases` | `SOCKET_ALL_RELEASES` | Store patches for every release/distribution variant, not just the installed one — PyPI wheel/sdist, RubyGems platform, Maven classifier. Makes the manifest portable across environments (e.g. cross-platform CI caches). | | `--vex ` | `SOCKET_VEX` | On a successful scan, also write an OpenVEX 0.2.0 document to this path. See [Inline VEX](#inline-vex-on-apply--scan--vendor). | | `--vex-product`, `--vex-no-verify`, `--vex-doc-id`, `--vex-compact` | `SOCKET_VEX_*` | Passthrough to the embedded VEX builder; mirror the standalone [`vex`](#vex) knobs. Inert unless `--vex` is set. | @@ -696,6 +702,69 @@ socket-patch scan --vex socket.vex.json > artifact is the patch); a newer available patch still appears in `updates[]` — re-run > `scan --mode vendored` to take it. +#### Roll out gradually with socket.yml + +A `patches` block in the repo-root `socket.yml` (the file the Socket scanner already +reads; keep `version: 2`) narrows what `scan` may patch, for every mode and for the +in-memory engine behind the Socket autopatch bot. It can only narrow: nothing in it can +name an endpoint or token, pick a mode, or turn off a safety check. + +```yaml +# Critical first: widen by editing one line +version: 2 +patches: + minSeverity: critical # later: high, then low, then remove the key + # (low still skips patches whose severity is unknown) +``` + +```yaml +# One directory first (monorepo) +version: 2 +patches: + includePaths: + - "/services/payments/" + # add "/services/checkout/" next sprint +``` + +```yaml +# One ecosystem, hold one package +version: 2 +patches: + ecosystems: [npm] + ignorePackages: ["pkg:npm/left-pad"] +``` + +```yaml +# Pause: report only; existing patches stay in place +version: 2 +patches: + enabled: false +``` + +- Paths are gitignore patterns (the same rules as `projectIgnorePaths`, which scan now + honors too), matched against each project's lockfiles: `"/services/payments/"`, + `"**/yarn.lock"`, `"examples/**"`. `test/`, `tests/`, `fixtures/`, `__fixtures__/` + and `testdata/` directories are skipped by default when scan discovers projects + (a directory glob such as `scan 'services/*'`); re-include one with a negation + (`ignorePaths: ["!/e2e/tests/"]`). A directory you name yourself is always scanned. +- `packages` / `ignorePackages` take `--package` specs; prefer purls (`pkg:npm/core`), + because a bare name also matches other ecosystems and scoped packages (`core` + matches `@babel/core`). +- Narrowing never removes a patch: a package that already carries one and is now + filtered out is left exactly as it is (reported under `policy.retained[]`). Use + `rollback` or `remove` to take a patch out. +- A broken file fails the scan (exit 1, `errorCode: socket_yml_invalid`) before anything + is written, with the key and the fix in the message — a typo never widens the + rollout. `--no-socket-yml` ignores the file for one run. +- `scan --json` reports what the policy did in a top-level `policy` block + (`jq '.policy.counts'`); the human output adds a `Policy (socket.yml): …` line and + always names skipped critical/high patches. `get` ignores the policy (explicit + intent) and warns `policy_bypassed`. + +Every key: `enabled`, `includePaths`, `ignorePaths`, `ecosystems`, `packages`, +`ignorePackages`, `minSeverity`, `maxNewPatches` — see CLI_CONTRACT.md "socket.yml patch +policy" for the full grammar, precedence and validation rules. + ### `vex` Generate an [OpenVEX](https://github.com/openvex) 0.2.0 attestation describing the diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index f5e53273..c258cade 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -87,6 +87,8 @@ Beyond the globals above, each subcommand defines a small set of local arguments | `scan` | `--package ` (repeatable or comma-separated) | `SOCKET_SCAN_PACKAGES` | (v5.0) Only scan these packages: a name (`lodash`, `@scope/pkg`, `requests`, `group:artifact`; matched against the full name or its last segment, case-insensitively) or a purl with or without a version (`pkg:npm/lodash` matches every version, `pkg:pypi/requests@2.31.0` only that one). Qualifiers are ignored. Filters the crawl like `--ecosystems`, after the prune universe is captured, so `--prune` still judges the full crawl | | `scan` | `--vendor` / `--detached` | — | Vendor every patched dependency instead of applying in place (`--vendor` == `--mode vendored`; conflicts with `--apply`/`--sync`, combines with `--prune`). Vendored mode is manifest-free (v5.0): the vendor ledger embeds the patch records and `.socket/manifest.json` is never written. `--detached` — the former opt-in for exactly that — is **hidden** and retained for compatibility as a no-op; it is still a usage error (exit 2) without vendored mode in either spelling | | `scan` | `--batch-size` | `SOCKET_BATCH_SIZE` | API batch chunk size. Unset (v5.0): `500` on the authenticated API (the server's per-request maximum), `100` on the public proxy; a given value applies on either endpoint (`0` is floored to `1`). A chunk whose request body would exceed 256 KiB (the public proxy's body cap) is split into consecutive smaller chunks, deterministically (greedy, in crawl order). A mid-run downgrade to the proxy keeps the chunks already formed | +| `scan` | `--no-socket-yml` | `SOCKET_NO_SOCKET_YML` | (v5.0) Ignore the repository's socket.yml patch policy (its `patches` block and `projectIgnorePaths`) for this run; the built-in test/fixture ignores still apply. The `policy` block reports `source: "bypassed"`. See "socket.yml patch policy". | +| `scan` | `--min-severity ` | `SOCKET_MIN_SEVERITY` | (v5.0) Severity floor for the patch a package may receive (worst advisory severity; unknown severity is skipped whenever a floor is set). Beats `patches.minSeverity`; the flag beats the env; `none` lifts the floor. A malformed value is exit 2. | | `get`, `scan` | `--all-releases` | `SOCKET_ALL_RELEASES` | Download patches for every release/distribution variant of a matched package — PyPI wheel/sdist (`artifact_id`), RubyGems (`platform`), Maven (`classifier`) — not just the one(s) matching the locally-installed distribution. On `scan` this makes the stored manifest portable across environments (e.g. cross-platform CI caches). On `get` (v3.6) it ALSO disables the coarse installed-**version** narrowing of CVE/GHSA fan-outs (see "get --mode and installed narrowing"): every found version's patch is fetched, installed or not | | `get` | positional `identifier`; `--id` / `--cve` / `--ghsa` / `--package` (`-p`); `--save-only` (alias `--no-apply`); `--one-off` (hidden from `--help`: always fails "not yet implemented"); `--mode ` | `SOCKET_SAVE_ONLY`, `SOCKET_ONE_OFF` | Patch lookup + consumption mode (v3.6). `--mode` reuses scan's value enum (same hidden value aliases `host`/`redirect`/`vendor`; deliberately no env binding, matching scan). Default (v5.0): `hosted`, like scan; `agent` (save + apply in place) when `--save-only` or `--global`/`--global-prefix` is given. An explicit `--save-only` conflicts with `--mode hosted\|vendored` — rejected with **exit 1** via get's established self-enforced-conflict style (unlike scan's exit-2 mode conflicts; see the exit-code table) | | `remove` | positional `identifier`; `--skip-rollback`; `--preserve-state` (v5.0) | `SOCKET_SKIP_ROLLBACK`, `SOCKET_PRESERVE_STATE` | Manifest entry removal. `--preserve-state` is the single-patch twin of `rollback --preserve-state`: restore the tree and unwind the identifier's vendored/hosted wiring, but keep the manifest entry, the vendored artifact + ledger entry, and skip all GC. Combining it with `--skip-rollback` is a self-enforced usage error (exit 2): one flag keeps the tree and drops the state, the other restores the tree and keeps the state — together they select the do-nothing quadrant ("the combination would be a no-op: nothing would change"). The conflict fires whether either flag is spelled on the command line or sourced from its env var | @@ -176,6 +178,84 @@ The hidden alias `--no-apply` on `get --save-only` is **part of the contract** **Python stale-install guard**: after a hosted redirect, `scan` / `get` use the Python crawler to inspect every matching installed package, including Poetry's out-of-tree virtualenvs and `--global-prefix`. A readable file that differs from the patch's `afterHash` emits `redirect_pypi_stale_install` in JSON `redirect.warnings[]` and human stderr. The probe changes no installed files, re-runs on idempotent scans, and falls back to persisted patch records when fresh record fetching fails. Missing/unreadable files alone do not prove staleness; lock-only checkouts stay quiet. Dry runs skip the probe. Same-run VEX excludes positively stale Python packages (qualifier-insensitive), even with `--vex-no-verify` or a healthy copy in another interpreter; if nothing remains to attest, the command exits 1 with `no_applicable_patches`. Reinstall from the rewritten lock in the affected interpreter and verify with `socket-patch vex`. +### socket.yml patch policy (v5.0) + +A repository can **narrow** what `scan` patches with a `patches` block in its root `socket.yml` (the Socket scanner's config file; `version: 2` keeps every other consumer working — they strip or ignore the block). Design record: `docs/design/staged-rollout.md`. + +**Grammar.** Every key is optional; camelCase, like the rest of socket.yml. + +```yaml +version: 2 # required once a patches block exists (integer 2 or string "2") +projectIgnorePaths: ["examples/**"] # the scanner's key; socket-patch honors it too +patches: + enabled: true # bool, default true; false = report only, nothing is written + includePaths: ["/services/payments/"] # gitignore list; absent = every project + ignorePaths: ["/legacy/"] # gitignore list, evaluated after the built-in defaults + ecosystems: [npm, pypi] # any --ecosystems name (npm pypi cargo gem golang maven composer nuget deno) + packages: ["pkg:npm/lodash"] # allowlist in the --package grammar + ignorePackages: ["pkg:npm/left-pad"] # denylist in the --package grammar + minSeverity: high # critical|high|medium|moderate|low (moderate = medium) + maxNewPatches: 5 # integer 0..=4294967295; validated now, the per-run cap lands with `--max-new-patches` +``` + +- Deny wins: `ignorePackages` beats `packages`, ignore paths beat `includePaths`. An empty allowlist (`includePaths: []`, `ecosystems: []`, `packages: []`) is an error ("use `enabled: false`"), never "all". +- Package specs are exactly `--package`'s: a name (full or last segment, case-insensitive) or a purl with or without a version; qualifiers ignored. A bare name matches across ecosystems (`core` matches `@babel/core`), so prefer purls. Invalid: empty, or `pkg:` without a type and a name. +- `minSeverity` judges the patch by the worst severity across the advisories it fixes (the per-package records scan fetches, never the batch summary). With a floor set, a patch of unknown severity is skipped (`minSeverity: low` therefore still skips those). The floor restricts which patch may **win** a package: a lower-ranked patch above the floor can still win. + +**Precedence (flags narrow, never widen).** List filters intersect: `--ecosystems`, `--package` and PATH arguments narrow the file's lists further. Scalars go flag > env > file > default: `--min-severity ` > `SOCKET_MIN_SEVERITY` > `patches.minSeverity` > no floor. `--no-socket-yml` / `SOCKET_NO_SOCKET_YML` skips the file entirely (the built-in default ignores still apply). An empty env value is unset; a malformed flag or env value is a usage error (exit 2). + +**Paths.** Path lists are gitignore patterns with the npm `ignore` package's semantics (the backend's `projectIgnorePaths` matcher): case-insensitive, anchored at the repo root, a leading or middle `/` anchors, a bare name matches at any depth, a trailing `/` matches directories only, `!` negates, the last match wins, and a negation cannot re-include anything under an ignored directory (evaluation walks top-down). Backslash is gitignore's escape character, not a separator. Patterns with a `..` segment, a drive letter, a NUL byte, or over 1024 bytes are rejected. + +- They are matched against a project root's **marker files**, repo-relative: the lockfiles in the root's directory (`package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`, `bun.lock(b)`, `vlt-lock.json`, `rush.json`, `uv.lock`, `poetry.lock`, `pdm.lock`, `Pipfile.lock`, `requirements.txt`, `*.py.lock`/`pylock*.toml`, `Cargo.lock`, `go.mod`, `go.sum`, `composer.lock`, `Gemfile.lock`, `gems.locked`, plus the Maven/NuGet markers). A root is ignored iff **every** marker is ignored; with `includePaths`, it is included iff **any** marker matches; a root with no marker is matched as its directory. So `/package-lock.json`, `**/yarn.lock` and `examples/**` mean what they mean to the scanner; `includePaths: ["/*", "!/*/"]` targets only the repo-root project. +- Evaluation order (one combined list): the **built-in defaults** `test/ tests/ fixtures/ __fixtures__/ testdata/`, then `projectIgnorePaths`, then `patches.ignorePaths`. Re-include a default with a negation (`ignorePaths: ["!/e2e/tests/"]`). The defaults apply only to **discovered** roots: hosted/vendored PATH-glob matches and roots the in-memory engine detects. A root you name — `--cwd`, a literal PATH, an in-memory `projectRoots` entry — skips them (the other lists still apply). `node_modules .git .socket .yarn vendor` stay structural excludes of in-memory root detection; no policy negates them. +- A workspace member that shares its root's lockfile is part of that root's project: exclude it with `ignorePackages`, not paths. +- A hosted/vendored PATH that resolves outside the repository root is a usage error (exit 2): one policy per invocation. + +**Lookup.** The repo root is the nearest ancestor of `--cwd` (inclusive) holding `.git` (a directory, or a file for worktrees and submodules), not walking past a `GIT_CEILING_DIRECTORIES` entry or into the home directory (unless `--cwd` is it), and, on Unix, only when `.git` belongs to the current user or root (otherwise warning `socket_yml_repo_untrusted` and `--cwd` is the root). No `.git`: the root is `--cwd`. Only `/socket.yml` and `/socket.yaml` are read, matched by exact directory-entry name (`Socket.yml` is not read: warning `socket_yml_name_case`); nested files never are. A symlinked file is followed only to a regular file inside the repo root. `--global` / `--global-prefix` scans read no file. + +**Validation (fail closed).** Because the file only narrows, a file that cannot be honored never means "no policy". Checked in order: file access (a regular file after resolving, at most 64 KiB, read from the opened handle), encoding (UTF-8; a BOM is stripped and CRLF is fine; UTF-16 and NUL bytes are errors), YAML 1.2 syntax (duplicate keys, a non-mapping top level, nesting deeper than 32 and a second document are errors), a top-level key equal to `patch`/`patches` ignoring case but not exactly `patches`, the version gate (`patches` requires `version: 2`), then the keys. Inside `patches` and `projectIgnorePaths`, anchors, aliases, merge keys (`<<`) and custom tags are errors (aliases elsewhere are never expanded). An unknown key under `patches` is an error with a did-you-mean hint and "a newer socket-patch may support it". Wrong types are errors — no coercion (`"false"` is not a bool; YAML 1.2, so `no` is a string) — as are an unknown severity, an out-of-range `maxNewPatches`, an invalid pattern or spec, a list over 1000 entries and an entry over 1024 bytes. Every error names the file and the key path (`patches.minSeverity`). `projectIgnorePaths` is validated strictly when a `patches` block exists (a single string is coerced to a one-element list); without one, a malformed value only warns `socket_yml_ignored_value` and is ignored, and it is honored whatever the `version`. An empty or comment-only file counts as no file. When both `socket.yml` and `socket.yaml` exist, both are validated; if their `projectIgnorePaths` and `patches` are equal as parsed values `socket.yml` is used, otherwise the run fails with `socket_yml_ambiguous`. + +**Error output.** Before any request or write, `scan` exits **1** with scan's error object plus an additive `errorCode` (`socket_yml_invalid` or `socket_yml_ambiguous`): `{"status": "error", "error": "socket.yml: patches.minSeverty: unknown key … (fix the file, or pass --no-socket-yml to ignore it)", "errorCode": "socket_yml_invalid", …}` with every count at zero; no `policy` block. Human output: `Error (socket_yml_invalid): …` on stderr. The in-memory engine reports `policyError: {code, detail}` with no root processed and no file changed. + +**The trust boundary holds.** No key names an endpoint, a credential, an org, a mode, a download format or a safety switch — such keys are unknown keys and fail validation. Every key only removes candidates or (`maxNewPatches`) delays them; none can add a package or bypass the tier filter, the agent partition, reference grants, containment checks or any refusal. + +**Narrowing never removes.** The policy runs after the `--prune` universe is captured, so `--prune` still judges the full crawl. A package that already carries a recorded patch (the merged recorded view: manifest > hosted lockfile pins > vendor ledger) and is now excluded by paths, ecosystems, packages or `enabled: false` is **retained**: never handed to the hosted rewriters, the vendor engine or agent apply, never upgraded, left byte-identical, and listed under `policy.retained[]` with `upgradeAvailable`. A recorded package whose patches all fall below a new floor keeps its recorded patch, and the floor never replaces a recorded patch that outranks every admitted one (a recorded merged patch stays). Removing a patch is only ever `rollback` / `remove`, or the dependency leaving the lockfile. + +**`enabled: false`.** Discovery and the table still run; nothing is written (the `--prune` GC is skipped too); every candidate is reported `policy_disabled` (recorded ones as retained); warning `patches_disabled`; exit 0. + +**Commands.** `scan` (hosted, vendored, agent; wet and `--dry-run`), the in-memory engine and `hosted-bundle` honor the policy. `get` is explicit intent: it ignores the policy and warns `policy_bypassed` (in `warnings[]`, and on stderr) when socket.yml would have skipped the package; an invalid file never fails `get`, it only drops the warning. `apply`, `list`, `vex`, `rollback`, `remove`, `repair` and `vendor` ignore it. + +**`policy` JSON block** (additive, MINOR; on every successful `scan --json` result, and session-level on the in-memory result): + +```json +"policy": { + "source": "file", + "path": "socket.yml", + "sha256": "…", + "enabled": true, + "minSeverity": {"value": "high", "source": "file"}, + "counts": {"filtered": 3, "retained": 1}, + "filtered": [ + {"purl": "pkg:npm/qs@6.5.2", "uuid": null, "project": "services/legacy", + "reason": "policy_path_excluded", "detail": "/legacy/ (patches.ignorePaths)"} + ], + "retained": [ + {"purl": "pkg:npm/lodash@4.17.20", "project": "", "recordedUuid": "…", + "reason": "policy_package_ignored", "detail": "lodash (patches.ignorePackages)", "upgradeAvailable": true} + ] +} +``` + +- `source`: `none` (no file, an empty file, `--global`, or only a case variant; `path`/`sha256` null), `file` (the file used and the SHA-256 of its bytes), `bypassed` (`--no-socket-yml`). +- `minSeverity.source`: `flag` | `env` | `file` | `default`; `value` null = no floor (`moderate` reads as `medium`). +- `project`: the repo-relative root directory (`""` for the repo root). +- `filtered[]`: `uuid` is null for a package filtered before any patch lookup (path, ecosystem and package reasons — those packages are never queried); a root filtered as a whole is one entry with `purl: null`. A severity entry names the top-ranked patch the floor withheld. `retained[]`: recorded packages the filters hold in place. +- Reason codes (stable): `policy_disabled`, `policy_path_excluded`, `policy_path_not_included`, `policy_ecosystem`, `policy_package_not_listed`, `policy_package_ignored`, `policy_severity` (detail `low < high`, `unknown < high`). In-memory `ProjectResult.skipped[]` carries the post-lookup ones (severity, disabled) with the same codes. +- Every string copied from the file (patterns, specs, key names) is truncated to 200 characters with control characters stripped. +- Warnings ride scan's top-level `warnings[]` (`{code, detail}`): `socket_yml_ignored_value`, `socket_yml_name_case`, `socket_yml_repo_untrusted`, `patches_disabled`. +- Human output: one line after the table, e.g. `Policy (socket.yml): 3 skipped by filters, 1 patched package held.`, then every filtered critical/high candidate by name (a policy must not hide those silently); `--verbose` lists every entry. +- Exit code is unchanged by filtering. + ### Embedded VEX (`apply --vex` / `scan --vex` / `vendor --vex`) `--vex ` folds OpenVEX 0.2.0 generation into `apply`, `scan`, and `vendor`: on a successful run the command writes the document to `` using the same engine as the standalone `vex` command. The `--vex-*` flags mirror `vex`'s `--product` / `--no-verify` / `--doc-id` / `--compact` knobs (namespaced to avoid colliding with the host command), and reuse the standalone env vars (`SOCKET_VEX_PRODUCT`, etc.). They are inert unless `--vex` is set. @@ -1083,6 +1163,8 @@ Empty string means unset at every layer: exported-but-empty flag-bound vars are | `SOCKET_PATCH_VERSION` | `--update ` | (latest) | Local to `--update`; the same pin `install.sh` and the gem launcher honor. Not one of the deprecated legacy `SOCKET_PATCH_*` trio. | | `SOCKET_BATCH_SIZE` | `scan --batch-size` | `500` authenticated / `100` proxy | Local to `scan`. | | `SOCKET_SCAN_PACKAGES` | `scan --package` | (none) | Local to `scan` (v5.0); comma-separated names or purls. | +| `SOCKET_NO_SOCKET_YML` | `scan --no-socket-yml` | `false` | Local to `scan` (v5.0); bool vocabulary, empty = unset. | +| `SOCKET_MIN_SEVERITY` | `scan --min-severity` | (none) | Local to `scan` (v5.0); read by scan (not clap) so the `policy` block can say `source: "env"`; empty = unset, malformed = exit 2. | | `SOCKET_SAVE_ONLY` | `get --save-only` | `false` | Local to `get`. | | `SOCKET_ONE_OFF` | `get --one-off` / `rollback --one-off` | `false` | Local to `get`/`rollback`. Both are **not yet implemented**: the flag parses (boolishly, empty-tolerant) and the command fails up front with a "not yet implemented" error, before any network or disk activity (on `rollback`, with no identifier-shaped target it instead fails "requires an identifier", equally up front). | | `SOCKET_ALL_RELEASES` | `get --all-releases` / `scan --all-releases` | `false` | Local to `get`/`scan`. Download patches for every release/distribution variant, not just the installed one. | @@ -1128,7 +1210,7 @@ Contract properties: - The file is read lazily at most once per process, only when a key is still unresolved after flag + env. - The telemetry endpoint resolver shares the same `apiBaseUrl` chain as API-client construction (`resolve_api_base_url`), so telemetry can never target a different host than the client. - `--offline` semantics are unchanged: reading the local file is not network contact; a config-sourced token is inert offline. -- **Repo-level files never carry endpoints, credentials, or interlock-disablers**: configuration for those comes only from flags, env vars, this user-level file, and built-in defaults — never from files inside the repository being patched (manifest, socket.yml, `.env`, …). +- **Repo-level files never carry endpoints, credentials, or interlock-disablers**: configuration for those comes only from flags, env vars, this user-level file, and built-in defaults — never from files inside the repository being patched (manifest, socket.yml, `.env`, …). A repository file may **narrow or pace** what `scan` patches (socket.yml's `patches` block and `projectIgnorePaths`, see "socket.yml patch policy"). It may never name an endpoint or credential, pick a mode or download format, turn off a safety check, or make `scan` patch anything it would not patch with no file present; the one exception is negating the built-in test/fixture path ignores, which are repo policy by nature. - `--debug` names the source on stderr whenever a setting resolves from the socket-cli config (the token value itself is never echoed). ### Registry override env vars @@ -1632,8 +1714,8 @@ Exit `1` when `status` is `partialFailure` (any `events[*].action == "failed"`) | Code | Meaning | |---|---| | `0` | Success | -| `1` | Error (missing/invalid manifest, fetch failed, apply failed, selection cancelled in non-JSON mode, etc.) | -| `2` | Usage error: clap parse failures (unknown flag/value, missing required arg — including the clap-enforced `setup --check --remove` conflict) and the conflicts the commands enforce themselves — `scan`'s cross-mode conflicts (`--mode` combined with a DIFFERENT mode's boolean spelling, rejected in `resolve_mode_flags`), `--detached` without vendored mode and `--mode hosted` with `--global`/`--global-prefix` (same enforcement point); in hosted/vendored `scan` (bare `scan` included), a PATH that is not a directory, a PATH glob matching no directory, and `--json` with more than one project directory (`run_project_dirs`); `remove --preserve-state --skip-rollback` (the no-op quadrant; flag- or env-sourced alike), an unparseable path glob on `scan`/`rollback`, `repair --offline --download-only`. `vex` also exits `2` on hard errors before document generation (see its tri-state table below). **Carve-out**: `get`'s self-enforced conflicts have always exited `1` via its error envelope (`--id`/`--cve`/`--ghsa`/`--package` multi-select, `--one-off --save-only`) and the v3.6 `--mode hosted\|vendored --save-only` conflict deliberately follows that get-internal precedent — changing the existing ones to `2` would be a MAJOR exit-code change | +| `1` | Error (missing/invalid manifest, fetch failed, apply failed, selection cancelled in non-JSON mode, an invalid or ambiguous socket.yml on `scan` (v5.0), etc.) | +| `2` | Usage error: clap parse failures (unknown flag/value, missing required arg — including the clap-enforced `setup --check --remove` conflict) and the conflicts the commands enforce themselves — `scan`'s cross-mode conflicts (`--mode` combined with a DIFFERENT mode's boolean spelling, rejected in `resolve_mode_flags`), `--detached` without vendored mode and `--mode hosted` with `--global`/`--global-prefix` (same enforcement point); in hosted/vendored `scan` (bare `scan` included), a PATH that is not a directory, a PATH glob matching no directory, and `--json` with more than one project directory (`run_project_dirs`); `remove --preserve-state --skip-rollback` (the no-op quadrant; flag- or env-sourced alike), an unparseable path glob on `scan`/`rollback`, a `scan` PATH outside the repository root and a malformed `SOCKET_MIN_SEVERITY` (v5.0), `repair --offline --download-only`. `vex` also exits `2` on hard errors before document generation (see its tri-state table below). **Carve-out**: `get`'s self-enforced conflicts have always exited `1` via its error envelope (`--id`/`--cve`/`--ghsa`/`--package` multi-select, `--one-off --save-only`) and the v3.6 `--mode hosted\|vendored --save-only` conflict deliberately follows that get-internal precedent — changing the existing ones to `2` would be a MAJOR exit-code change | `list` returns **`0`** for an empty manifest and **`1`** for a missing manifest — these are distinct and load-bearing (a manifest-less project whose vendor ledger holds records or whose lockfiles pin hosted patches is NOT "missing": `list` reads all three sources and exits 0 — see the `manifest_not_found` row). Every lock-taking subcommand — including `scan`/`get --mode hosted` as of v5.0 — returns **`1`** with `errorCode: lock_held` when another live socket-patch process holds `<.socket>/apply.lock`. diff --git a/docs/design/configuration.md b/docs/design/configuration.md index 4c4a617c..555b1db4 100644 --- a/docs/design/configuration.md +++ b/docs/design/configuration.md @@ -1,7 +1,8 @@ # Configuration design: env vars, the socket-cli config file, and what we deliberately don't read Status: **implemented** (v3.5); section 4 (`socket.yml` patch policy) is -**planned** for v5.0 (see `staged-rollout.md`). This document records the +**implemented** in v5.0 for its filters (`socket_patch_core::policy`; the +per-run cap follows with `--max-new-patches`, see `staged-rollout.md`). This document records the settled design so future configuration surface grows inside it instead of inventing new mechanisms. @@ -113,6 +114,14 @@ The trust boundary is unchanged and gains its positive half: `remove`, `repair`, `apply`, `vendor`) ignore it; `get` bypasses it with a warning. +Implementation: `socket_patch_core::policy` (`SelectionPolicy::load` over a +`PolicyFs`: `DiskPolicyFs` for a checkout, `MemoryPolicyFs` for the +in-memory engine) parses the file as a YAML 1.2 event stream (serde-saphyr's +parser, so aliases are never expanded) and validates only `version`, +`projectIgnorePaths` and `patches`. Disk scan glue lives in +`commands/scan/policy.rs`; the flags in `commands/scan/socket_yml_args.rs`. +The full contract is CLI_CONTRACT.md "socket.yml patch policy". + ## Explicitly rejected | Idea | Why not | @@ -149,6 +158,13 @@ The trust boundary is unchanged and gains its positive half: ## Test strategy (how this stays true) +- socket.yml policy: table-driven unit tests in + `socket-patch-core/src/policy/` (every validation row, the lookup and + file-access rules, and a golden fixture generated from the npm `ignore` + package by `scripts/gen-ignore-golden.mjs`), the parser contract in + `tests/cli_parse_scan.rs`, disk e2e in `tests/e2e_socket_yml_policy.rs` + and disk/memory parity in `tests/hosted_memory_parity.rs`. + - `tests/cli_config_fallback.rs` spawns the binary against fixture `config.json` files (fresh process per case — the disk read is cached per process) and pins: config token/apiBaseUrl authenticate, `defaultOrg` diff --git a/docs/design/staged-rollout.md b/docs/design/staged-rollout.md index cbe8a507..1bb12aff 100644 --- a/docs/design/staged-rollout.md +++ b/docs/design/staged-rollout.md @@ -1015,6 +1015,63 @@ change), README (recipe R5, `--max-new-patches`), CHANGELOG - If B is ready before A merges, B ships with the file layer passed as `None` and wires it in a follow-up commit on its branch once A lands. +### 9.4 Work item A: decisions made while building + +Gaps and contradictions A resolved with the smallest reasonable decision +(each is also in A's PR description): + +1. **YAML crate.** `serde-saphyr` 1.3.0 (maintained, YAML 1.2), driven + through its re-exported event parser (`serde_saphyr::granit_parser`, no + extra dependency) into a small node tree. Aliases are never expanded, so + an alias bomb anywhere costs nothing, and anchors / aliases / merge keys / + custom tags are refused only inside `patches` and `projectIgnorePaths` + (an anchor in `issueRules` keeps working). Duplicate keys and the depth + bound are enforced while building the tree. `serde_norway` was not + picked: its libyaml core expands aliases with only a global repetition + limit and cannot refuse them per subtree. Unit tests prove each property. +2. **Disk markers** are the in-memory engine's lock markers (plus the + Maven/NuGet markers disk scans support); manifests are not markers. + With `package.json` as a disk marker, `ignorePaths: ["**/package-lock.json"]` + would never exclude a disk root while excluding the same root in memory. +3. **Whole-file errors.** YAML syntax, duplicate keys, a non-mapping top + level, depth and a second document are errors even without a `patches` + block (the file cannot be known not to have one). `patches: null` counts + as present for the version gate; a key under `patches` with no value is an + error, never "default". +4. **`enabled: false`** reports recorded packages under `retained[]` + (`policy_disabled`) and every other candidate under `filtered[]`. +5. **Floor vs recorded patch** (until B's `search_result_supersedes`): a + recorded package keeps its recorded patch when it outranks every + floor-admitted patch in the canonical ranking, or when nothing passes the + floor; otherwise the admitted winner is selected, exactly as without a + floor. +6. **Retained packages** stay in the batch query (so `upgradeAvailable` can + be reported) but never reach selection or a writer. +7. **PATH-glob matches under a default ignore** are still visited as roots + and root-filtered (one `purl: null` entry), so a recorded patch there is + reported as retained. +8. **In-memory engine gaps until B lands its recorded view**: `retained[]` + is empty and the floor rule of item 5 cannot see recorded pins (filtered + packages' pins stay byte-identical regardless: the rewriters only touch + selected dependencies). `selectHostedScanPaths` applies the built-in + default ignores, so a socket.yml negation of a default cannot re-include + an in-memory root (the file's content is unknown at selection time); a + root named in `projectRoots` is explicit and skips the defaults. There + is no case-variant warning in memory (selection streams exact names + only). `ProjectResult.skipped[]` carries the post-lookup policy reasons + (severity, disabled); the pre-lookup ones are in the session `policy` + block only. +9. **Session option `policyPaths`** (selection's list, handed back like + `projectRoots`) is how the session tells "listed but never sent" from + absent. +10. **Env layer of `--min-severity`** is read by scan, not clap, so the + `policy` block can say `source: "env"`; a malformed env value exits 2 at + run time, a malformed flag at parse time. +11. **README recipes**: R2-R4 and R6 ship without `maxNewPatches` lines (A + validates the key but does not enforce the cap); R1 and R5 come with B. +12. **`get`'s `policy_bypassed`** is one warning per package; the severity + reason fires only when none of the package's patches passes the floor. + ## 10. Open questions (decided by default, revisit with evidence) - A separate upgrade cap (`maxUpgrades`) if server-side republishes rotate From 711ee3fe0c1c65a147ab4728f46c88be314ea263 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 14:47:34 +0000 Subject: [PATCH 14/22] Fix rollout review findings The recorded view is now an index built once per project and keyed like discovery (case-folded nuget/composer, PEP 503 pypi names), and it keeps every hosted pin, so a case-folded pin or a pair of pinned qualifier twins no longer reads as NEW or as a phantom upgrade. The hosted gate uses key sets instead of linear scans, takes the apply lock for rows an earlier directory admitted, and error envelopes drop the rollout block. The in-memory engine keeps the first pass's skips when it rewrites a root again, and a root whose lookups all failed freezes new admissions. Human output words dry runs, incomplete lookups, zero caps and shared budgets correctly, an explicit flag no longer reads the env value, and the docs say the socket.yml layer arrives with the patch policy. New tests cover reference failures, upgrades under a cap of zero in hosted mode, recorded packages with failed lookups, and stronger dry-run and parity checks. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 7 +- README.md | 14 +- crates/socket-patch-cli/CLI_CONTRACT.md | 6 +- .../src/commands/scan/hosted.rs | 33 +- .../socket-patch-cli/src/commands/scan/mod.rs | 41 ++- .../src/commands/scan/rollout.rs | 331 ++++++++++++++---- .../src/commands/scan/rollout_args.rs | 18 +- .../src/commands/scan/vendor_flow.rs | 9 +- .../socket-patch-cli/src/hosted_memory/mod.rs | 38 +- .../tests/hosted_memory_rollout.rs | 67 +++- .../tests/scan_rollout_e2e.rs | 313 ++++++++++++++++- crates/socket-patch-core/src/rollout.rs | 25 +- docs/design/staged-rollout.md | 18 + 13 files changed, 775 insertions(+), 145 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b272721..be2d1c56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -563,7 +563,8 @@ into the new version's section — see docs/releasing.md. ### Added - **`scan --max-new-patches ` rolls patches out gradually** - (env `SOCKET_MAX_NEW_PATCHES`; socket.yml `patches.maxNewPatches`). Each + (env `SOCKET_MAX_NEW_PATCHES`; socket.yml `patches.maxNewPatches` with + the socket.yml patch policy). Each run adds at most N patches to packages that had none, most severe first (then by how many advisories a patch fixes), and defers the rest to the next run; upgrades of packages that are already patched are never @@ -576,7 +577,9 @@ into the new version's section — see docs/releasing.md. agent mode, `--dry-run` included; `scan --json` gains a top-level `rollout` block (`maxNewPatches`, `counts`, ranked `deferred[]`) and hosted mode lists deferred rows in `redirect.skipped[]` as - `rollout_deferred`. The in-memory engine (napi, `hosted-bundle`) takes + `rollout_deferred`. +- **The in-memory hosted engine paces rollouts too.** It (napi, + `hosted-bundle`) takes `maxNewPatches`, `maxNewPatchesCap` and `inFlightPatches`, spends one budget across every project root, and reports a session `rollout` block and `ProjectResult.deferred[]`. diff --git a/README.md b/README.md index b990bb68..b09badc3 100644 --- a/README.md +++ b/README.md @@ -424,7 +424,7 @@ hosted and vendored mode each PATH is a project directory, scanned as if it were ```bash socket-patch scan --max-new-patches 5 # at most 5 packages get their first patch socket-patch scan --max-new-patches 0 # only upgrade patches you already have -socket-patch scan --max-new-patches none # no cap this run (overrides socket.yml) +socket-patch scan --max-new-patches none # no cap this run ``` A capped scan patches the most critical packages first: by the severity of the patch, @@ -432,10 +432,14 @@ then by how many advisories it fixes. Upgrades of packages that are already patc never capped. Commit the result and run `scan` again to add the next batch; repeated runs on an unchanged repo add the same packages in the same order and stop once everything is patched. `--dry-run` shows exactly what the run would add and defer, and -`--json` reports it under `rollout` (`jq '.rollout.counts.deferred'`). With several -project directories (`scan apps/*`) the cap is shared, visited in sorted order. +`--json` reports it under `rollout` (`jq '.rollout.counts.deferred'`). In hosted and +vendored mode, several project directories in one run (`scan apps/*`) share the cap, +visited in sorted order; `--json` takes one directory, so a CI job per directory gets +its own N. -To drip patches in through a PR bot, set the cap once in the repo's `socket.yml`: +To drip patches in through a PR bot, set the cap once in the repo's `socket.yml` +(read together with the rest of the socket.yml `patches:` policy; until your +socket-patch reads that policy, use the flag or `SOCKET_MAX_NEW_PATCHES`): ```yaml # Weekly drip with the depscan autopatch PR @@ -683,7 +687,7 @@ socket-patch scan [PATHS]... [options] | `--package ` | `SOCKET_SCAN_PACKAGES` | Only scan these packages: a name (`lodash`, `@scope/pkg`, `requests`; case-insensitive) or a purl with or without its version (`pkg:npm/lodash`, `pkg:pypi/requests@2.31.0`). Repeat the flag or separate with commas. | | `--prune` | — | Agent-mode garbage collection after the scan: remove manifest entries for packages no longer present in the crawl (installed trees + lockfiles — a wiped `node_modules` alone doesn't prune lockfile-listed entries) and delete orphan blob/diff/package-archive files. [Vendored](#vendor) packages are exempt from the crawl-based prune, but a vendored entry whose dependency has left the lockfile is reverted. Ignored, with a `redirect_prune_ignored` warning, in hosted mode; without a mode the scan is report-only. | | `--sync` | — | Shorthand for `--mode agent --prune`: the one-flag agent-mode auto-update run. | -| `--max-new-patches ` | `SOCKET_MAX_NEW_PATCHES` | Add at most N patches to packages that have none yet, most severe first; the rest are deferred to the next scan and listed in the output. Upgrades of already-patched packages are not capped. `0` adds no new patches, `none` lifts a cap set in `socket.yml` (`patches.maxNewPatches`). See [Add a few new patches per run](#add-a-few-new-patches-per-run). | +| `--max-new-patches ` | `SOCKET_MAX_NEW_PATCHES` | Add at most N patches to packages that have none yet, most severe first; the rest are deferred to the next scan and listed in the output. Upgrades of already-patched packages are not capped. `0` adds no new patches, `none` means no cap (it also lifts a `socket.yml` `patches.maxNewPatches`). See [Add a few new patches per run](#add-a-few-new-patches-per-run). | | `--batch-size ` | `SOCKET_BATCH_SIZE` | Packages per API request (default: `500` on the authenticated API, `100` on the public proxy). A request whose body would exceed 256 KiB is split into smaller ones. | | `--all-releases` | `SOCKET_ALL_RELEASES` | Store patches for every release/distribution variant, not just the installed one — PyPI wheel/sdist, RubyGems platform, Maven classifier. Makes the manifest portable across environments (e.g. cross-platform CI caches). | | `--vex ` | `SOCKET_VEX` | On a successful scan, also write an OpenVEX 0.2.0 document to this path. See [Inline VEX](#inline-vex-on-apply--scan--vendor). | diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index d3b80fbc..ad3a410f 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -87,7 +87,7 @@ Beyond the globals above, each subcommand defines a small set of local arguments | `scan` | `--package ` (repeatable or comma-separated) | `SOCKET_SCAN_PACKAGES` | (v5.0) Only scan these packages: a name (`lodash`, `@scope/pkg`, `requests`, `group:artifact`; matched against the full name or its last segment, case-insensitively) or a purl with or without a version (`pkg:npm/lodash` matches every version, `pkg:pypi/requests@2.31.0` only that one). Qualifiers are ignored. Filters the crawl like `--ecosystems`, after the prune universe is captured, so `--prune` still judges the full crawl | | `scan` | `--vendor` / `--detached` | — | Vendor every patched dependency instead of applying in place (`--vendor` == `--mode vendored`; conflicts with `--apply`/`--sync`, combines with `--prune`). Vendored mode is manifest-free (v5.0): the vendor ledger embeds the patch records and `.socket/manifest.json` is never written. `--detached` — the former opt-in for exactly that — is **hidden** and retained for compatibility as a no-op; it is still a usage error (exit 2) without vendored mode in either spelling | | `scan` | `--batch-size` | `SOCKET_BATCH_SIZE` | API batch chunk size. Unset (v5.0): `500` on the authenticated API (the server's per-request maximum), `100` on the public proxy; a given value applies on either endpoint (`0` is floored to `1`). A chunk whose request body would exceed 256 KiB (the public proxy's body cap) is split into consecutive smaller chunks, deterministically (greedy, in crawl order). A mid-run downgrade to the proxy keeps the chunks already formed | -| `scan` | `--max-new-patches ` | `SOCKET_MAX_NEW_PATCHES` | (v5.0) Per-run cap on NEW patches (packages with no recorded patch in the project), most severe first; the rest are deferred to the next scan. `0` admits upgrades only, `none` (case-insensitive) is unlimited, absent is unlimited unless socket.yml sets `patches.maxNewPatches`. Precedence: flag > env > socket.yml > unlimited. The env value is read at run time (the `rollout` block reports `flag` vs `env`): empty is unset, malformed is a usage error (exit 2, before any network access). Upgrades and already-applied patches are never capped. See "Per-run limit on new patches" below | +| `scan` | `--max-new-patches ` | `SOCKET_MAX_NEW_PATCHES` | (v5.0) Per-run cap on NEW patches (packages with no recorded patch in the project), most severe first; the rest are deferred to the next scan. `0` admits upgrades only, `none` (case-insensitive) is unlimited, absent is unlimited unless socket.yml sets `patches.maxNewPatches`. Precedence: flag > env > socket.yml > unlimited (the socket.yml layer is read once scan loads the socket.yml patch policy; until then it is unset). The env value is read at run time (the `rollout` block reports `flag` vs `env`): empty is unset, malformed is a usage error (exit 2, before any network access). Upgrades and already-applied patches are never capped. See "Per-run limit on new patches" below | | `get`, `scan` | `--all-releases` | `SOCKET_ALL_RELEASES` | Download patches for every release/distribution variant of a matched package — PyPI wheel/sdist (`artifact_id`), RubyGems (`platform`), Maven (`classifier`) — not just the one(s) matching the locally-installed distribution. On `scan` this makes the stored manifest portable across environments (e.g. cross-platform CI caches). On `get` (v3.6) it ALSO disables the coarse installed-**version** narrowing of CVE/GHSA fan-outs (see "get --mode and installed narrowing"): every found version's patch is fetched, installed or not | | `get` | positional `identifier`; `--id` / `--cve` / `--ghsa` / `--package` (`-p`); `--save-only` (alias `--no-apply`); `--one-off` (hidden from `--help`: always fails "not yet implemented"); `--mode ` | `SOCKET_SAVE_ONLY`, `SOCKET_ONE_OFF` | Patch lookup + consumption mode (v3.6). `--mode` reuses scan's value enum (same hidden value aliases `host`/`redirect`/`vendor`; deliberately no env binding, matching scan). Default (v5.0): `hosted`, like scan; `agent` (save + apply in place) when `--save-only` or `--global`/`--global-prefix` is given. An explicit `--save-only` conflicts with `--mode hosted\|vendored` — rejected with **exit 1** via get's established self-enforced-conflict style (unlike scan's exit-2 mode conflicts; see the exit-code table) | | `remove` | positional `identifier`; `--skip-rollback`; `--preserve-state` (v5.0) | `SOCKET_SKIP_ROLLBACK`, `SOCKET_PRESERVE_STATE` | Manifest entry removal. `--preserve-state` is the single-patch twin of `rollback --preserve-state`: restore the tree and unwind the identifier's vendored/hosted wiring, but keep the manifest entry, the vendored artifact + ledger entry, and skip all GC. Combining it with `--skip-rollback` is a self-enforced usage error (exit 2): one flag keeps the tree and drops the state, the other restores the tree and keeps the state — together they select the do-nothing quadrant ("the combination would be a no-op: nothing would change"). The conflict fires whether either flag is spelled on the command line or sourced from its env var | @@ -179,7 +179,7 @@ The hidden alias `--no-apply` on `get --save-only` is **part of the contract** ### Per-run limit on new patches (`scan --max-new-patches`, v5.0) -`scan --max-new-patches ` (env `SOCKET_MAX_NEW_PATCHES`, socket.yml `patches.maxNewPatches`) paces a rollout: each run adds at most N patches to packages that had none, the most critical first, and defers the rest to the next run. It applies to `scan` in hosted, vendored and agent mode, wet and `--dry-run`, and to the in-memory engine (napi `maxNewPatches`, `hosted-bundle`); `get` is explicit intent and ignores it. Design: `docs/design/staged-rollout.md` §5. +`scan --max-new-patches ` (env `SOCKET_MAX_NEW_PATCHES`; socket.yml `patches.maxNewPatches` once scan loads the socket.yml patch policy) paces a rollout: each run adds at most N patches to packages that had none, the most critical first, and defers the rest to the next run. It applies to `scan` in hosted, vendored and agent mode, wet and `--dry-run`, and to the in-memory engine (napi `maxNewPatches`, `hosted-bundle`); `get` is explicit intent and ignores it. Design: `docs/design/staged-rollout.md` §5. **Classification.** After per-package selection, each selected `(project, purl)` row is compared with the project's **recorded view** — the merged manifest > hosted lockfile pins > vendor ledger that `updates[]` reads (§5.1): @@ -216,7 +216,7 @@ Supersession is judged on the by-package records the selection itself uses, so a `maxNewPatches.value` is `null` for unlimited; `source` is `flag`, `env`, `file`, `default` or `cap` (the in-memory `maxNewPatchesCap` tightened it; the in-memory `maxNewPatches` option reports `flag`). `counts.new` / `counts.deferred` count base purls, `counts.upgrade` / `counts.already` count `(project, purl)` rows. `deferred[]` is in rank order: `purl` is the base purl, `uuids` the distinct selected uuids across its rows, `projects` the repo-relative project directories (`""` is the scanned directory), `rank` 1-based among eligible NEW base purls. Deferred rows are never written, downloaded or vendored: hosted mode mirrors each into `redirect.skipped[]` as `{purl, uuid, reason: "rollout_deferred", detail}`, the in-memory engine lists them in `ProjectResult.deferred[]` (`{purl, uuid, severity, rank}`) and `skipped[]`, and agent / vendored mode leave them out of `apply.patches[]` / `vendor`. Warnings (`rollout_incomplete_lookup`, `rollout_reference_failed`) go to the top-level `warnings[]`. Exit codes are unchanged: deferring is not a failure. -Human output adds, when a cap is set, `Rollout: 3 of 9 new patches applied (maxNewPatches=3 from --max-new-patches); 0 upgrades, 0 already applied.` and next steps naming the deferred patches (`6 new patches deferred; commit these changes and run scan again to apply the next 3.`, `Next up: minimist@1.2.5 (critical), …`). +Human output adds, when a cap is set, `Rollout: 3 of 9 new patches applied (maxNewPatches=3 from --max-new-patches); 0 upgrades, 0 already applied.` (with several project directories: `…, shared by this run's directories, 1 left)`) and next steps naming the deferred patches (`6 new patches deferred; commit these changes and run scan again to apply the next 3.`, `Next up: minimist@1.2.5 (critical), …`; a dry run says `would be deferred`, and an incomplete lookup says so instead). Hosted mode prints them on stdout after its own next steps, unindented; agent and vendored mode under a `Next steps:` heading. The `rollout` block is left out of error envelopes (`status: "error"`). CI recipe: `socket-patch scan --json --max-new-patches 5 | jq '.rollout.counts.deferred'`. diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 54a89553..809686ee 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -563,6 +563,10 @@ fn emit_json_error_with_code( let mut result = scan_result.unwrap_or_else(|| serde_json::json!({ "status": "error" })); result["status"] = serde_json::json!("error"); result["error"] = serde_json::json!(message); + // The rollout block describes a successful run only. + if let Some(obj) = result.as_object_mut() { + obj.remove("rollout"); + } if let Some(code) = code { result["errorCode"] = serde_json::json!(code); } @@ -1062,7 +1066,7 @@ pub(super) async fn run_redirect( // The merged recorded view (manifest > hosted pins > vendor ledger) the // rollout classifies against, whether a batch failed, and the stage // that holds this directory's budget. - recorded: Option<&socket_patch_core::manifest::schema::PatchManifest>, + recorded: &super::rollout::RecordedState<'_>, batch_failed: bool, stage: &mut super::rollout::Stage, ) -> i32 { @@ -1121,7 +1125,7 @@ pub(super) async fn run_redirect( &pairs, scan_result, npm_prior, - Some(super::rollout::Gate { stage, rows }), + Some(super::rollout::Gate::new(stage, rows)), ) .await } @@ -1494,7 +1498,11 @@ pub(crate) async fn run_redirect_selected( // a warning instead of failing the run. Err(e) if rollout.as_ref().is_some_and(|gate| { - gate.stage.capped() && selected.iter().all(|(p, u)| gate.is_new(p, u)) + let new = gate.new_keys(); + gate.stage.capped() + && selected + .iter() + .all(|(p, u)| new.contains(&(p.clone(), u.clone()))) }) => { if let Some(gate) = rollout.as_mut() { @@ -1682,10 +1690,11 @@ pub(crate) async fn run_redirect_selected( // A capped run whose only candidates are NEW rows it cannot admit // (budget 0, or incomplete data) writes nothing: take no lock either. let may_write = rollout.as_ref().is_none_or(|gate| { - gate.stage.may_admit_new() - || candidates - .iter() - .any(|c| !gate.is_new(&c.sel_purl, &c.dep.patch_uuid)) + let new = gate.new_keys(); + candidates.iter().any(|c| { + !new.contains(&(c.sel_purl.clone(), c.dep.patch_uuid.clone())) + || gate.may_admit(&c.sel_purl) + }) }); let mut lock: Option = if !common.dry_run && !candidates.is_empty() && may_write { match acquire_hosted_lock(common, &mut scan_result) { @@ -2428,8 +2437,9 @@ pub(crate) async fn run_redirect_selected( overrides = candidates.iter().map(|c| c.dep.clone()).collect(); (files, rewrite) = rewrite_candidates(files, &overrides, &inputs).await; } - // A row that turned out to be pinned already still gets written: take - // the lock skipped above (no takeover ran without it). + // A row that turned out to be pinned already (`mark_pinned`: a pin + // discovery did not recognize) still gets written: take the lock + // skipped above. Only NEW rows ran without it, so no takeover did. if lock.is_none() && !common.dry_run && !candidates.is_empty() { match acquire_hosted_lock(common, &mut scan_result) { Ok(guard) => lock = Some(guard), @@ -3215,8 +3225,9 @@ pub(crate) async fn run_redirect_selected( human_files.extend(takeover_files.iter().cloned()); human_files.sort(); human_files.dedup(); - // The one stdout line: scripts read it, so it stays on stdout; - // everything below is on stderr and names its package itself. + // The summary line: scripts read it, so it stays on stdout, as do + // the rollout line and the next steps; the warnings below are on + // stderr and name their package themselves. println!( "{}", format_redirect_summary(confirmed.len(), human_files.len(), common.dry_run) diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 336b0729..a243b6f1 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -596,14 +596,14 @@ fn updates_json(updates: &[discovery::UpdateInfo]) -> Vec { fn classified_rows( stage: &mut rollout::Stage, discovered: &Discovered, - recorded: Option<&PatchManifest>, + recorded: &rollout::RecordedState<'_>, batch_failed: bool, packages: &[BatchPackagePatches], result: Option<&mut serde_json::Value>, ) -> Vec { let failed: Vec = discovered.failed.iter().map(|(purl, _)| purl.clone()).collect(); - stage.incomplete = rollout::lookup_incomplete(recorded, &failed, batch_failed); - let rows = rollout::classify(&discovered.offers, recorded, &stage.project); + stage.incomplete = rollout::lookup_incomplete(&recorded.index, &failed, batch_failed); + let rows = rollout::classify(&discovered.offers, &recorded.index, &stage.project); if let Some(result) = result { let updates = offer_updates(&rows, discovered, recorded, packages); result["updates"] = serde_json::Value::Array(updates_json(&updates)); @@ -615,7 +615,7 @@ fn classified_rows( fn offer_updates( rows: &[rollout::Row], discovered: &Discovered, - recorded: Option<&PatchManifest>, + recorded: &rollout::RecordedState<'_>, packages: &[BatchPackagePatches], ) -> Vec { let purls: Vec = packages.iter().map(|p| p.purl.clone()).collect(); @@ -623,7 +623,7 @@ fn offer_updates( rows, &discovered.offers, &purls, - detect_updates(recorded, packages), + detect_updates(recorded.manifest, packages), ) } @@ -759,6 +759,9 @@ async fn fetch_patch_details( fn emit_discovery_error_json(result: &mut serde_json::Value, message: &str) { result["status"] = serde_json::json!("error"); result["error"] = serde_json::json!(message); + if let Some(obj) = result.as_object_mut() { + obj.remove("rollout"); + } print_json(result); } @@ -2006,6 +2009,10 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { &hosted_pins, ); let mut updates = detect_updates(update_manifest.as_deref(), &all_packages_with_patches); + let recorded = rollout::RecordedState { + manifest: update_manifest.as_deref(), + index: rollout::RecordedIndex::new(update_manifest.as_deref(), &hosted_pins), + }; // The hosted-wiring probes below take `all_purls` (POST-filter: only // packages this run covered), unlike the PRE-filter `scanned_purls` @@ -2064,7 +2071,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { Some(result), telemetry, npm_crawl.as_ref(), - update_manifest.as_deref(), + &recorded, batch_error_count > 0, &mut stage, ) @@ -2113,7 +2120,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { let rows = classified_rows( &mut stage, &discovered, - update_manifest.as_deref(), + &recorded, batch_error_count > 0, &all_packages_with_patches, Some(&mut result), @@ -2226,7 +2233,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { use_public_proxy, &all_packages_with_patches, can_access_paid_patches, - update_manifest.as_deref(), + &recorded, batch_error_count > 0, &mut stage, &mut result, @@ -2354,7 +2361,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { let rows = classified_rows( &mut stage, &discovered, - update_manifest.as_deref(), + &recorded, batch_error_count > 0, &all_packages_with_patches, None, @@ -2362,7 +2369,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { updates = offer_updates( &rows, &discovered, - update_manifest.as_deref(), + &recorded, &all_packages_with_patches, ); rows @@ -2525,10 +2532,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { &pairs, None, npm_crawl.as_ref(), - Some(rollout::Gate { - stage: &mut stage, - rows, - }), + Some(rollout::Gate::new(&mut stage, rows)), ) .await; } @@ -2605,7 +2609,11 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { if !silent { open_paragraph(&mut skip_paragraph); if !stage.deferred_keys().is_empty() { - println!("No new patches admitted this run."); + if args.common.dry_run { + println!("No new patches would be added this run."); + } else { + println!("No new patches added this run."); + } } else if already_recorded.is_empty() { println!("No patches selected."); } else { @@ -2791,7 +2799,8 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { } } - print_rollout_human(&stage, false, silent); + // The deferred next steps assume a run that landed. + print_rollout_human(&stage, false, silent || code != 0); // Post-apply GC: only with `--prune` or `--sync`; otherwise an agent // apply leaves every other manifest entry alone (`socket-patch repair` diff --git a/crates/socket-patch-cli/src/commands/scan/rollout.rs b/crates/socket-patch-cli/src/commands/scan/rollout.rs index ae134c44..c2140994 100644 --- a/crates/socket-patch-cli/src/commands/scan/rollout.rs +++ b/crates/socket-patch-cli/src/commands/scan/rollout.rs @@ -3,7 +3,7 @@ //! mode's planning pass decide eligibility, spend the per-run budget on //! NEW packages most critical first, and report what was deferred. -use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::path::Path; use socket_patch_core::api::ranking::{ @@ -17,7 +17,6 @@ use socket_patch_core::rollout::{ canonical_base_purl, plan_rollout, severity_label, Candidate, MaxNew, MaxNewSource, Recorded, RolloutPlan, }; -use socket_patch_core::utils::purl::normalize_purl; use super::discovery::UpdateInfo; use super::rollout_args::RolloutCarry; @@ -66,47 +65,92 @@ pub(crate) struct Row { pub(crate) writer: PatchSearchResult, } -/// The uuids the recorded view holds for `purl`: exact key, else the same -/// purl up to percent-encoding, else any qualifier twin. -pub(crate) fn recorded_uuids(recorded: &PatchManifest, purl: &str) -> Vec { - if let Some(r) = recorded.patches.get(purl) { - return vec![r.uuid.clone()]; +/// The recorded view (§5.1), indexed once so every row is an O(1) lookup: +/// the merged manifest (manifest > hosted pins > vendor ledger) plus every +/// hosted pin (the merge keeps one per key; a project can pin each +/// qualifier twin of a package to its own patch). +#[derive(Debug, Default)] +pub(crate) struct RecordedIndex { + exact: HashMap>, + /// Discovery's folded base purl plus the raw qualifier suffix. + qualified: HashMap>, + by_base: HashMap>, +} + +/// The recorded view one project root classifies against: the merged +/// manifest (`updates[]`'s batch fallback reads it) and its index. +pub(crate) struct RecordedState<'a> { + pub(crate) manifest: Option<&'a PatchManifest>, + pub(crate) index: RecordedIndex, +} + +/// `purl`'s folded base plus its qualifiers: equal for two spellings of the +/// same qualified purl (percent-encoding, case where it does not matter). +fn qualified_key(purl: &str) -> String { + let suffix = purl.find(['?', '#']).map_or("", |i| &purl[i..]); + format!("{}{suffix}", canonical_base_purl(purl)) +} + +impl RecordedIndex { + pub(crate) fn new(manifest: Option<&PatchManifest>, pins: &[(String, String)]) -> Self { + let mut index = RecordedIndex::default(); + let entries = manifest + .into_iter() + .flat_map(|m| m.patches.iter().map(|(k, r)| (k.as_str(), r.uuid.as_str()))) + .chain(pins.iter().map(|(p, u)| (p.as_str(), u.as_str()))); + for (key, uuid) in entries { + index + .exact + .entry(key.to_string()) + .or_default() + .push(uuid.to_string()); + index + .qualified + .entry(qualified_key(key)) + .or_default() + .push(uuid.to_string()); + index + .by_base + .entry(canonical_base_purl(key)) + .or_default() + .push(uuid.to_string()); + } + for list in index + .exact + .values_mut() + .chain(index.qualified.values_mut()) + .chain(index.by_base.values_mut()) + { + list.sort(); + list.dedup(); + } + index } - let want = normalize_purl(purl); - let mut same: Vec = recorded - .patches - .iter() - .filter(|(k, _)| normalize_purl(k) == want) - .map(|(_, r)| r.uuid.clone()) - .collect(); - if same.is_empty() { - let base = canonical_base_purl(purl); - same = recorded - .patches - .iter() - .filter(|(k, _)| canonical_base_purl(k) == base) - .map(|(_, r)| r.uuid.clone()) - .collect(); + + /// The uuids recorded for `purl`, sorted: the exact key, else the same + /// purl in another spelling, else any qualifier twin. + pub(crate) fn uuids(&self, purl: &str) -> &[String] { + self.exact + .get(purl) + .or_else(|| self.qualified.get(&qualified_key(purl))) + .or_else(|| self.by_base.get(&canonical_base_purl(purl))) + .map_or(&[], Vec::as_slice) + } + + /// Whether any patch is recorded for `purl`'s base purl. + fn records_package(&self, purl: &str) -> bool { + self.by_base.contains_key(&canonical_base_purl(purl)) } - same.sort(); - same.dedup(); - same } /// Classify every selected purl of one project root (§5.1). `recorded` is /// the merged view (manifest > hosted pins > vendor ledger). -pub(crate) fn classify( - offers: &Offers, - recorded: Option<&PatchManifest>, - project: &str, -) -> Vec { +pub(crate) fn classify(offers: &Offers, recorded: &RecordedIndex, project: &str) -> Vec { offers .selected .iter() .map(|(purl, selected)| { - let uuids = recorded - .map(|m| recorded_uuids(m, purl)) - .unwrap_or_default(); + let uuids = recorded.uuids(purl); let offered = offers .unfiltered .get(purl) @@ -181,14 +225,14 @@ pub(super) fn upgrades(rows: &[Row], package_purls: &[String]) -> Vec, + recorded: &RecordedIndex, failed_details: &[String], batch_failed: bool, ) -> bool { batch_failed || failed_details .iter() - .any(|purl| recorded.is_none_or(|m| recorded_uuids(m, purl).is_empty())) + .any(|purl| !recorded.records_package(purl)) } /// Every canonical-shaped uuid (`8-4-4-4-12` hex) `text` mentions, @@ -241,12 +285,29 @@ pub(crate) struct Gate<'a> { pub(crate) rows: Vec, } -impl Gate<'_> { - /// Whether `(purl, uuid)` is a NEW row. - pub(crate) fn is_new(&self, purl: &str, uuid: &str) -> bool { - self.rows.iter().any(|r| { - r.candidate.recorded.is_new() && r.writer.purl == purl && r.writer.uuid == uuid - }) +impl<'a> Gate<'a> { + pub(crate) fn new(stage: &'a mut Stage, rows: Vec) -> Self { + Gate { stage, rows } + } + + /// `(purl, uuid)` of every NEW row, for O(1) [`Self::is_new`] checks. + pub(crate) fn new_keys(&self) -> HashSet<(String, String)> { + self.rows + .iter() + .filter(|r| r.candidate.recorded.is_new()) + .map(|r| (r.writer.purl.clone(), r.writer.uuid.clone())) + .collect() + } + + /// Whether a NEW `(purl, uuid)` row could be admitted: budget left, or + /// its package already admitted by an earlier directory. + pub(crate) fn may_admit(&self, purl: &str) -> bool { + self.stage.may_admit_new() + || (!(self.stage.capped() && self.stage.incomplete) + && self + .stage + .already_admitted + .contains(&canonical_base_purl(purl))) } } @@ -422,7 +483,12 @@ impl Stage { let Some(plan) = self.plan.as_ref() else { return (None, Vec::new()); }; - human_lines(&self.configured, plan, dry_run) + let ctx = HumanContext { + dry_run, + incomplete: self.incomplete && self.capped(), + shared: self.carry.is_some(), + }; + human_lines(&self.configured, plan, ctx) } } @@ -448,22 +514,26 @@ struct DeferredGroup { fn deferred_groups(plan: &RolloutPlan) -> Vec { let mut groups: Vec = Vec::new(); + let mut at: HashMap<&str, usize> = HashMap::new(); for (c, rank) in &plan.deferred { - match groups.iter_mut().find(|g| g.base_purl == c.base_purl) { + match at.get(c.base_purl.as_str()).map(|&i| &mut groups[i]) { Some(g) => { g.uuids.insert(c.uuid.clone()); g.projects.insert(c.project.clone()); g.severity_order = g.severity_order.min(c.severity_order); g.advisory_count = g.advisory_count.max(c.advisory_count); } - None => groups.push(DeferredGroup { - base_purl: c.base_purl.clone(), - uuids: BTreeSet::from([c.uuid.clone()]), - severity_order: c.severity_order, - advisory_count: c.advisory_count, - projects: BTreeSet::from([c.project.clone()]), - rank: *rank, - }), + None => { + at.insert(c.base_purl.as_str(), groups.len()); + groups.push(DeferredGroup { + base_purl: c.base_purl.clone(), + uuids: BTreeSet::from([c.uuid.clone()]), + severity_order: c.severity_order, + advisory_count: c.advisory_count, + projects: BTreeSet::from([c.project.clone()]), + rank: *rank, + }) + } } } groups.sort_by_key(|g| g.rank); @@ -512,20 +582,34 @@ fn short_name(base_purl: &str) -> &str { .map_or(base_purl, |(_, name)| name) } +/// How [`human_lines`] words a run. +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct HumanContext { + pub(crate) dry_run: bool, + /// A lookup failed, so no new patch could be admitted. + pub(crate) incomplete: bool, + /// The budget is shared with other project directories of this run. + pub(crate) shared: bool, +} + pub(crate) fn human_lines( configured: &MaxNew, plan: &RolloutPlan, - dry_run: bool, + ctx: HumanContext, ) -> (Option, Vec) { let c = plan.counts; let line = configured.value.map(|cap| { - let verb = if dry_run { + let verb = if ctx.dry_run { "would be applied" } else { "applied" }; + let shared = match (ctx.shared, plan.remaining) { + (true, Some(left)) => format!(", shared by this run's directories, {left} left"), + _ => String::new(), + }; format!( - "Rollout: {} of {} {verb} (maxNewPatches={cap} from {}); {}, {} already applied.", + "Rollout: {} of {} {verb} (maxNewPatches={cap} from {}{shared}); {}, {} already applied.", c.new, crate::ui::plural((c.new + c.deferred) as usize, "new patch", "new patches"), source_label(configured), @@ -538,16 +622,34 @@ pub(crate) fn human_lines( return (line, Vec::new()); } let deferred = crate::ui::plural(groups.len(), "new patch", "new patches"); + let deferred = if ctx.dry_run { + format!("{deferred} would be deferred") + } else { + format!("{deferred} deferred") + }; let first = match configured.value { + _ if ctx.incomplete => format!( + "{deferred}: a patch lookup failed, so no new patch was added this run; run scan \ + again once the patch API answers." + ), Some(0) => format!( - "{deferred} deferred: maxNewPatches=0 adds no new patches; raise it (or pass \ - --max-new-patches) to add them." + "{deferred}: maxNewPatches=0 adds no new patches; {} to add them.", + match configured.source { + MaxNewSource::Flag => "pass a larger --max-new-patches", + MaxNewSource::Env => "raise SOCKET_MAX_NEW_PATCHES", + MaxNewSource::File => "raise patches.maxNewPatches in socket.yml", + MaxNewSource::Cap | MaxNewSource::Default => "raise the cap", + } + ), + Some(cap) if ctx.dry_run => format!( + "{deferred}; the wet run adds the top {}, and each later committed scan the next ones.", + (cap as usize).min(c.new as usize + groups.len()) ), Some(cap) => format!( - "{deferred} deferred; commit these changes and run scan again to apply the next {}.", + "{deferred}; commit these changes and run scan again to apply the next {}.", (cap as usize).min(groups.len()) ), - None => format!("{deferred} deferred; run scan again once the patch API answers."), + None => format!("{deferred}; run scan again to add them."), }; let shown: Vec = groups .iter() @@ -670,7 +772,7 @@ mod tests { ("pkg:npm/tie@1", "z-rec"), ("pkg:npm/gone@1", "g1"), ]); - let rows = classify(&offers, Some(&recorded), ""); + let rows = classify(&offers, &RecordedIndex::new(Some(&recorded), &[]), ""); assert_eq!( classes(&rows), vec![ @@ -723,7 +825,7 @@ mod tests { ("pkg:npm/@s/x@1", "e1"), ("pkg:pypi/w@1?artifact_id=a", "w1"), ]); - let rows = classify(&offers, Some(&recorded), ""); + let rows = classify(&offers, &RecordedIndex::new(Some(&recorded), &[]), ""); assert_eq!(rows[0].candidate.recorded, Recorded::Same); // The twin's recorded uuid is not offered for this twin: the late // twin lands uncapped as an UPGRADE. @@ -743,13 +845,13 @@ mod tests { ("pkg:pypi/w@1?artifact_id=1", "c"), ("pkg:pypi/w@1?artifact_id=2", "b"), ]); - let rows = classify(&offers, Some(&recorded), ""); + let rows = classify(&offers, &RecordedIndex::new(Some(&recorded), &[]), ""); assert_eq!(rows[0].candidate.recorded, Recorded::Same); let recorded = manifest(&[ ("pkg:pypi/w@1?artifact_id=1", "d"), ("pkg:pypi/w@1?artifact_id=2", "c"), ]); - let rows = classify(&offers, Some(&recorded), ""); + let rows = classify(&offers, &RecordedIndex::new(Some(&recorded), &[]), ""); assert_eq!( rows[0].candidate.recorded, Recorded::Superseded { @@ -781,11 +883,21 @@ mod tests { #[test] fn a_lock_naming_the_selected_uuid_marks_the_row_already() { let results = vec![ - offer("pkg:npm/a@1", "aaaaaaaa-1111-4111-8111-00000000000a", "", &["high"]), - offer("pkg:npm/b@1", "bbbbbbbb-1111-4111-8111-00000000000b", "", &["high"]), + offer( + "pkg:npm/a@1", + "aaaaaaaa-1111-4111-8111-00000000000a", + "", + &["high"], + ), + offer( + "pkg:npm/b@1", + "bbbbbbbb-1111-4111-8111-00000000000b", + "", + &["high"], + ), ]; let offers = offers_from_results(&results, true); - let mut rows = classify(&offers, None, ""); + let mut rows = classify(&offers, &RecordedIndex::default(), ""); mark_pinned( &mut rows, &["resolved: https://x/AAAAAAAA-1111-4111-8111-00000000000A/a.tgz"], @@ -794,6 +906,79 @@ mod tests { assert_eq!(rows[1].candidate.recorded, Recorded::None); } + #[test] + fn case_folded_pins_match_the_selection_spelling() { + // Discovery keys a nuget pin by the lowercased name; the API and + // the lockfile keep the original case. + let results = vec![ + offer("pkg:nuget/Newtonsoft.Json@13.0.3", "a-sel", "", &["high"]), + offer("pkg:nuget/Newtonsoft.Json@13.0.3", "z-pin", "", &["high"]), + ]; + let offers = offers_from_results(&results, true); + let index = RecordedIndex::new( + None, + &[("pkg:nuget/newtonsoft.json@13.0.3".into(), "z-pin".into())], + ); + let rows = classify(&offers, &index, ""); + assert_eq!( + rows[0].candidate.recorded, + Recorded::Kept { + uuid: "z-pin".into() + }, + "an equal sibling never replaces the pinned patch" + ); + assert_eq!(rows[0].writer.uuid, "z-pin"); + } + + #[test] + fn both_pinned_qualifier_twins_are_already() { + // Hosted pins are keyed by base purl; each twin carries its own + // patch. Neither may read as an UPGRADE on a converged repo. + let results = vec![ + offer( + "pkg:pypi/foo@1.0?artifact_id=sdist", + "u-sdist", + "", + &["high"], + ), + offer("pkg:pypi/foo@1.0?artifact_id=whl", "u-whl", "", &["high"]), + ]; + let offers = offers_from_results(&results, true); + let pins = vec![ + ("pkg:pypi/foo@1.0".to_string(), "u-sdist".to_string()), + ("pkg:pypi/foo@1.0".to_string(), "u-whl".to_string()), + ]; + let mut merged = PatchManifest::new(); + merged.patches.insert( + "pkg:pypi/foo@1.0".into(), + manifest(&[("x", "u-sdist")]).patches.remove("x").unwrap(), + ); + let rows = classify(&offers, &RecordedIndex::new(Some(&merged), &pins), ""); + assert!( + rows.iter().all(|r| r.candidate.recorded == Recorded::Same), + "{rows:?}" + ); + } + + #[test] + fn lookup_incomplete_only_when_a_new_package_could_be_missing() { + let index = RecordedIndex::new(Some(&manifest(&[("pkg:npm/rec@1", "u")])), &[]); + assert!(!lookup_incomplete(&index, &[], false)); + assert!( + lookup_incomplete(&index, &[], true), + "a failed batch hides unknown packages" + ); + assert!( + !lookup_incomplete(&index, &["pkg:npm/rec@1".into()], false), + "a recorded package's failed lookup cannot hide a NEW row" + ); + assert!(lookup_incomplete( + &index, + &["pkg:npm/other@1".into()], + false + )); + } + #[test] fn stage_carries_the_budget_and_renders_the_block() { let configured = MaxNew { @@ -808,7 +993,7 @@ mod tests { let offers = offers_from_results(&results, true); let mut first = Stage::new(configured, Some(carry.clone()), Path::new("/repo/x")); assert_eq!(first.project, "x"); - let rows = classify(&offers, None, &first.project); + let rows = classify(&offers, &RecordedIndex::default(), &first.project); first.plan(&rows, |_| true); assert_eq!(carry.lock().remaining, Some(0)); let results = vec![ @@ -817,7 +1002,7 @@ mod tests { ]; let offers = offers_from_results(&results, true); let mut second = Stage::new(configured, Some(carry.clone()), Path::new("/repo/y")); - let rows = classify(&offers, None, &second.project); + let rows = classify(&offers, &RecordedIndex::default(), &second.project); second.plan(&rows, |_| true); let json = second.json(); assert_eq!( @@ -846,8 +1031,8 @@ mod tests { assert_eq!( line.as_deref(), Some( - "Rollout: 1 of 2 new patches applied (maxNewPatches=2 from --max-new-patches); \ - 0 upgrades, 0 already applied." + "Rollout: 1 of 2 new patches applied (maxNewPatches=2 from --max-new-patches, \ + shared by this run's directories, 0 left); 0 upgrades, 0 already applied." ) ); assert_eq!( @@ -864,7 +1049,7 @@ mod tests { let results = vec![offer("pkg:npm/a@1", "ua", "", &["critical"])]; let offers = offers_from_results(&results, true); let mut stage = Stage::new(MaxNew::UNLIMITED, None, Path::new("/repo")); - let rows = classify(&offers, None, ""); + let rows = classify(&offers, &RecordedIndex::default(), ""); stage.plan(&rows, |_| true); assert_eq!(stage.human(false), (None, Vec::new())); assert_eq!( @@ -886,7 +1071,7 @@ mod tests { source: MaxNewSource::File, }; let mut stage = Stage::new(zero, None, Path::new("/repo")); - let rows = classify(&offers, None, ""); + let rows = classify(&offers, &RecordedIndex::default(), ""); stage.plan(&rows, |_| true); let (line, next) = stage.human(true); assert_eq!( @@ -914,13 +1099,13 @@ mod tests { let mut stage = Stage::new(capped, None, Path::new("/repo")); stage.incomplete = true; assert!(!stage.may_admit_new()); - stage.plan(&classify(&offers, None, ""), |_| true); + stage.plan(&classify(&offers, &RecordedIndex::default(), ""), |_| true); let codes: Vec<&str> = stage.warnings().iter().map(|(c, _)| *c).collect(); assert_eq!(codes, [ROLLOUT_INCOMPLETE_LOOKUP]); let mut unlimited = Stage::new(MaxNew::UNLIMITED, None, Path::new("/repo")); unlimited.incomplete = true; assert!(unlimited.may_admit_new()); - unlimited.plan(&classify(&offers, None, ""), |_| true); + unlimited.plan(&classify(&offers, &RecordedIndex::default(), ""), |_| true); assert!(unlimited.warnings().is_empty()); } } diff --git a/crates/socket-patch-cli/src/commands/scan/rollout_args.rs b/crates/socket-patch-cli/src/commands/scan/rollout_args.rs index ae24cd07..8179e54c 100644 --- a/crates/socket-patch-cli/src/commands/scan/rollout_args.rs +++ b/crates/socket-patch-cli/src/commands/scan/rollout_args.rs @@ -20,6 +20,9 @@ pub struct MaxNewPatches(pub Option); /// `N` (0..=4294967295) or `none`, case-insensitive. pub fn parse_max_new_patches(s: &str) -> Result { let s = s.trim(); + if s.is_empty() { + return Err("a number of patches (0 to 4294967295) or `none` is required".to_string()); + } if s.eq_ignore_ascii_case("none") { return Ok(MaxNewPatches(None)); } @@ -32,7 +35,8 @@ pub fn parse_max_new_patches(s: &str) -> Result { pub struct RolloutArgs { /// Add at most N patches to packages that have none yet, most severe /// first; the rest are deferred to the next scan. Upgrades of patched - /// packages are not capped. `none` lifts a cap set in socket.yml + /// packages are not capped, and `0` adds only upgrades. `none` means no + /// cap. Also read from SOCKET_MAX_NEW_PATCHES #[arg( long = "max-new-patches", value_name = "N|none", @@ -50,7 +54,9 @@ impl RolloutArgs { /// [`MAX_NEW_PATCHES_ENV`] value; empty is unset), then the socket.yml /// value, then unlimited. A malformed env value is a usage error. pub fn resolve(&self, env: Option<&str>, file: Option) -> Result { - let env = match env.filter(|v| !v.is_empty()) { + // The flag wins outright: an env value it overrides is never read. + let env = env.filter(|_| self.max_new_patches.is_none()); + let env = match env.filter(|v| !v.trim().is_empty()) { Some(raw) => Some( parse_max_new_patches(raw) .map_err(|e| format!("{MAX_NEW_PATCHES_ENV}: {e}"))? @@ -141,5 +147,13 @@ mod tests { assert_eq!((got.value, got.source), (None, MaxNewSource::Default)); let err = none.resolve(Some("lots"), None).unwrap_err(); assert!(err.starts_with("SOCKET_MAX_NEW_PATCHES: "), "{err}"); + let got = none.resolve(Some(" "), Some(3)).unwrap(); + assert_eq!(got.source, MaxNewSource::File, "whitespace is unset"); + let got = flag.resolve(Some("lots"), None).unwrap(); + assert_eq!( + got.source, + MaxNewSource::Flag, + "an overridden env value is not parsed" + ); } } diff --git a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs index 6320adec..51f4fc20 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -20,7 +20,7 @@ use socket_patch_core::api::client::ApiClient; use socket_patch_core::api::types::{BatchPackagePatches, PatchResponse, PatchSearchResult}; use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; -use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; +use socket_patch_core::manifest::schema::PatchRecord; use socket_patch_core::telemetry::{track_patch_vendor_failed, PendingTelemetry}; use socket_patch_core::utils::purl::strip_purl_qualifiers; use socket_patch_core::vendor::{load_state, lookup_entry, save_state, VendorState}; @@ -477,7 +477,7 @@ async fn run_vendor_json_path( use_public_proxy: bool, all_packages_with_patches: &[BatchPackagePatches], can_access_paid_patches: bool, - recorded: Option<&PatchManifest>, + recorded: &super::rollout::RecordedState<'_>, batch_failed: bool, stage: &mut Stage, result: &mut serde_json::Value, @@ -603,6 +603,9 @@ async fn run_vendor_json_path( "code": code, "message": message, }); + if let Some(obj) = result.as_object_mut() { + obj.remove("rollout"); + } print_json(result); return 1; } @@ -827,7 +830,7 @@ pub(super) fn boxed_vendor_json_path<'a>( use_public_proxy: bool, all_packages_with_patches: &'a [BatchPackagePatches], can_access_paid_patches: bool, - recorded: Option<&'a PatchManifest>, + recorded: &'a super::rollout::RecordedState<'a>, batch_failed: bool, stage: &'a mut Stage, result: &'a mut serde_json::Value, diff --git a/crates/socket-patch-cli/src/hosted_memory/mod.rs b/crates/socket-patch-cli/src/hosted_memory/mod.rs index 0738304f..40cad3a4 100644 --- a/crates/socket-patch-cli/src/hosted_memory/mod.rs +++ b/crates/socket-patch-cli/src/hosted_memory/mod.rs @@ -59,7 +59,8 @@ pub use select::{candidate_files, safe_repo_path, select_paths}; pub use types::*; use crate::commands::scan::rollout::{ - classify, lookup_incomplete, mentioned_uuids, offers_from_results, Offers, Row, Stage, + classify, lookup_incomplete, mentioned_uuids, offers_from_results, Offers, RecordedIndex, Row, + Stage, ROLLOUT_DEFERRED, }; use discover::Provider; @@ -373,7 +374,7 @@ fn memory_recorded( root: &str, roots: &[String], offers: &Offers, -) -> Option { +) -> RecordedIndex { let manifest = project .text(select::MANIFEST_REL) .and_then(|text| serde_json::from_str(text).ok()); @@ -404,8 +405,12 @@ fn memory_recorded( .map(|p| (purl.clone(), p.uuid.clone())) }) .collect(); - crate::commands::scan::merge_ledger_records_for_updates(manifest.as_ref(), vendor.as_ref(), &pins) - .map(std::borrow::Cow::into_owned) + let merged = crate::commands::scan::merge_ledger_records_for_updates( + manifest.as_ref(), + vendor.as_ref(), + &pins, + ); + RecordedIndex::new(merged.as_deref(), &pins) } async fn engine( @@ -613,6 +618,11 @@ async fn engine( // lockfiles name. ALREADY rows carry the recorded uuid, so a re-scan // re-confirms a pin instead of swapping it. let mut stage = Stage::new(options.max_new, None, std::path::Path::new("")); + // A root whose every lookup failed hides packages that could have been + // NEW: a capped run then admits none anywhere (§5.2). + stage.incomplete |= states + .iter() + .any(|s| s.error.as_ref().is_some_and(|e| e.code == "patch_lookup_failed")); let roots_by_path: Vec = states.iter().map(|s| s.root.clone()).collect(); for state in states.iter_mut().filter(|s| s.error.is_none()) { let Some(project) = state.project.as_ref() else { @@ -620,11 +630,11 @@ async fn engine( }; let recorded = memory_recorded(project, &state.root, &roots_by_path, &state.offers); stage.incomplete |= lookup_incomplete( - recorded.as_ref(), + &recorded, &state.failed_details, batch_failed, ); - let mut rows = classify(&state.offers, recorded.as_ref(), &state.root); + let mut rows = classify(&state.offers, &recorded, &state.root); for row in &mut rows { row.candidate.in_flight = options.in_flight.contains(&row.candidate.base_purl); } @@ -663,6 +673,7 @@ async fn engine( state.selected.clear(); continue; } + stage.incomplete = true; state.fail( "reference_lookup_failed", format!("failed to resolve patch references: {error}"), @@ -743,11 +754,16 @@ async fn engine( let deferred_rows: Vec<(socket_patch_core::rollout::Candidate, u32)> = stage.plan.as_ref().map(|p| p.deferred.clone()).unwrap_or_default(); if !deferred_rows.is_empty() { + let root_index: BTreeMap = states + .iter() + .enumerate() + .map(|(i, s)| (s.root.clone(), i)) + .collect(); for (row, rank) in &deferred_rows { - let Some(state) = states.iter_mut().find(|s| s.root == row.project) else { + let Some(&i) = root_index.get(&row.project) else { continue; }; - state.deferred.push(DeferredPatch { + states[i].deferred.push(DeferredPatch { purl: row.purl.clone(), uuid: row.uuid.clone(), severity: socket_patch_core::rollout::severity_label(row.severity_order).into(), @@ -775,8 +791,10 @@ async fn engine( continue; } checkpoint(&cancel).await?; + // The first pass's skips stay (wheel metadata the rewrite could + // not fetch): its candidates are already gone, so the second + // pass cannot report them again. let mut plan = done.planned; - plan.skipped = skipped_before.remove(&index).unwrap_or_default(); plan.candidates .retain(|c| !root_deferred.contains(&c.dep.patch_uuid)); plan.skipped @@ -794,7 +812,7 @@ async fn engine( // Roots that never reached the rewrite (unknown eligibility) list // their deferred rows as skipped too. for state in states.iter_mut() { - if unknown_roots.contains(&state.root) { + if unknown_roots.contains(&state.root) && state.error.is_none() { let extra: Vec = state.deferred.iter().map(deferred_skip).collect(); state.skipped.extend(extra); } diff --git a/crates/socket-patch-cli/tests/hosted_memory_rollout.rs b/crates/socket-patch-cli/tests/hosted_memory_rollout.rs index 1d5847c2..415c1d75 100644 --- a/crates/socket-patch-cli/tests/hosted_memory_rollout.rs +++ b/crates/socket-patch-cli/tests/hosted_memory_rollout.rs @@ -277,6 +277,13 @@ async fn one_root_disk_and_memory_admit_and_defer_the_same_rows_until_converged( run + 1 ); let next = apply(&files, &mem); + let memory_changed: Vec<&String> = mem.changed_files.iter().map(|f| &f.path).collect(); + assert_eq!( + memory_changed, + disk.changed.keys().collect::>(), + "run {}: the same files change", + run + 1 + ); for (rel, bytes) in &disk.changed { assert_eq!( String::from_utf8_lossy(&next[rel]), @@ -324,11 +331,12 @@ async fn two_roots_spend_one_budget_in_memory_and_one_per_directory_on_disk() { let next = apply(&files, &mem); assert_eq!(pinned(&next, "a/package-lock.json"), ["mem-b"]); assert_eq!(pinned(&next, "b/package-lock.json"), ["mem-e"]); - let ranks: Vec<(String, u32)> = mem + let mut ranks: Vec<(String, u32)> = mem .projects .iter() .flat_map(|p| p.deferred.iter().map(|d| (d.purl.clone(), d.rank))) .collect(); + ranks.sort(); assert_eq!( ranks, [(purl("mem-a"), 5), (purl("mem-c"), 3), (purl("mem-d"), 4)], @@ -381,15 +389,60 @@ async fn memory_counts_a_committed_manifest_vendor_entry_or_pin_as_recorded() { .unwrap(), ); let mem = memory(&server, &files, options(Some(1))).await; - let counts = &mem.rollout["counts"]; - assert_eq!(counts["new"], 1, "{:#}", mem.rollout); - assert_eq!(counts["already"], 2, "{:#}", mem.rollout); - let next = apply(&files, &mem); - assert!( - pinned(&next, "package-lock.json").contains(&"mem-c"), + assert_eq!( + mem.rollout["counts"], + json!({ "new": 1, "deferred": 2, "upgrade": 0, "already": 2 }), "{:#}", mem.rollout ); + let deferred: Vec<&str> = mem.projects[0] + .deferred + .iter() + .map(|d| d.purl.as_str()) + .collect(); + assert_eq!(deferred, [purl("mem-d"), purl("mem-a")]); + // mem-e (manifest) re-confirms its pin; mem-b (vendored) is refused as a + // takeover; mem-c is the one NEW patch admitted. + let next = apply(&files, &mem); + assert_eq!(pinned(&next, "package-lock.json"), ["mem-e", "mem-c"]); + // The rerun lands the next one. + let again = memory(&server, &next, options(Some(1))).await; + assert_eq!( + again.rollout["counts"], + json!({ "new": 1, "deferred": 1, "upgrade": 0, "already": 3 }) + ); + assert_eq!( + pinned(&apply(&next, &again), "package-lock.json"), + ["mem-e", "mem-c", "mem-d"] + ); +} + +#[tokio::test] +async fn memory_defers_new_rows_when_the_reference_lookup_fails_under_a_cap() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with(ResponseTemplate::new(500)) + .with_priority(1) + .mount(&server) + .await; + mount(&server).await; + let mut files = BTreeMap::new(); + let names: Vec<&str> = PACKAGES.iter().map(|(n, _)| *n).collect(); + lock(&mut files, "", &names); + let mem = memory(&server, &files, options(Some(2))).await; + let project = &mem.projects[0]; + assert!(project.error.is_none(), "{:?}", project.error); + assert_eq!(project.deferred.len(), 5); + assert!(mem.changed_files.is_empty()); + let codes: Vec<&str> = mem.warnings.iter().map(|w| w.code.as_str()).collect(); + assert!(codes.contains(&"rollout_reference_failed"), "{codes:?}"); + // Uncapped, the failure is the root's. + let mem = memory(&server, &files, options(None)).await; + assert_eq!( + mem.projects[0].error.as_ref().map(|e| e.code.as_str()), + Some("reference_lookup_failed") + ); } #[tokio::test] diff --git a/crates/socket-patch-cli/tests/scan_rollout_e2e.rs b/crates/socket-patch-cli/tests/scan_rollout_e2e.rs index 55409669..2e0fba55 100644 --- a/crates/socket-patch-cli/tests/scan_rollout_e2e.rs +++ b/crates/socket-patch-cli/tests/scan_rollout_e2e.rs @@ -263,6 +263,16 @@ fn run_json(root: &Path, mock: &MockServer, args: &[&str]) -> Value { serde_json::from_str(stdout.trim()).unwrap_or_else(|e| panic!("{e}: {stdout}")) } +/// How many times the mock served `view/{uuid}`. +async fn view_fetches(mock: &MockServer, uuid: &str) -> usize { + mock.received_requests() + .await + .unwrap_or_default() + .iter() + .filter(|r| r.url.path().ends_with(&format!("/patches/view/{uuid}"))) + .count() +} + /// The packages whose hosted artifact the lockfile pins. fn pinned(root: &Path) -> Vec { let lock = std::fs::read_to_string(root.join("package-lock.json")).unwrap(); @@ -331,11 +341,44 @@ async fn hosted_cap_rolls_nine_packages_forward_three_per_run() { ); let mut previous_lock = lock_before; + let mut dry = dry; for run_no in 0..3 { let v = run_json(tmp.path(), &mock, &args); + // The dry run before each wet run predicts it exactly. + // (Warning details switch tense: "would be written" / "was written".) + let decisions = |block: &Value| -> Value { + let mut block = block.clone(); + let obj = block.as_object_mut().unwrap(); + obj.remove("dryRun"); + let codes: Vec = obj["warnings"] + .as_array() + .unwrap() + .iter() + .map(|w| w["code"].clone()) + .collect(); + obj.insert("warnings".into(), Value::Array(codes)); + block + }; + let (predicted, actual) = (decisions(&dry["redirect"]), decisions(&v["redirect"])); + assert_eq!( + dry["rollout"], + v["rollout"], + "run {}: dry run == wet run", + run_no + 1 + ); + assert_eq!(predicted, actual, "run {}: dry run == wet run", run_no + 1); if run_no == 0 { - assert_eq!(dry["rollout"], v["rollout"], "dry run == wet run"); - assert_eq!(dry["redirect"]["skipped"], v["redirect"]["skipped"]); + assert_eq!( + v["rollout"]["deferred"][0], + json!({ + "purl": "pkg:npm/roll-c@1.0.0", + "uuids": [uuid("roll-c")], + "severity": "high", + "advisoryCount": 1, + "projects": [""], + "rank": 4, + }) + ); } assert_eq!( v["rollout"]["maxNewPatches"], @@ -372,6 +415,9 @@ async fn hosted_cap_rolls_nine_packages_forward_three_per_run() { assert_eq!(skipped.len() as u64, 6 - done); assert_eq!(v["redirect"]["redirected"], 3 * (run_no as u64 + 1)); previous_lock = std::fs::read(tmp.path().join("package-lock.json")).unwrap(); + let mut next_dry = args.to_vec(); + next_dry.push("--dry-run"); + dry = run_json(tmp.path(), &mock, &next_dry); } let v = run_json(tmp.path(), &mock, &args); @@ -548,10 +594,21 @@ async fn agent_cap_rolls_forward_and_upgrades_ignore_the_cap() { before(name), "a deferred package is not touched" ); + assert_eq!( + view_fetches(&mock, &uuid(name)).await, + 0, + "a deferred patch is never downloaded: {name}" + ); } } + let manifest = std::fs::read(tmp.path().join(".socket/manifest.json")).unwrap(); let v = run_json(tmp.path(), &mock, &args); assert_eq!(counts(&v), (0, 0, 0, 9)); + assert_eq!( + std::fs::read(tmp.path().join(".socket/manifest.json")).unwrap(), + manifest, + "a converged run is byte-stable" + ); // A newer patch for an applied package is an UPGRADE: it lands even // with `--max-new-patches 0`, and is reported in `updates[]`. @@ -673,8 +730,9 @@ async fn project_directories_share_one_budget_in_sorted_order() { "3", "--patch-server-url", HOST, - "a", + // Given out of order: the directories are still visited sorted. "b", + "a", ], ); assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); @@ -682,12 +740,16 @@ async fn project_directories_share_one_budget_in_sorted_order() { assert_eq!(pinned(&tmp.path().join("b")), names(&["roll-e", "roll-b"])); assert!( stdout.contains( - "Rollout: 2 of 2 new patches applied (maxNewPatches=3 from --max-new-patches)" + "Rollout: 2 of 2 new patches applied (maxNewPatches=3 from --max-new-patches, \ + shared by this run's directories, 1 left)" ), "{stdout}" ); assert!( - stdout.contains("Rollout: 2 of 4 new patches applied"), + stdout.contains( + "Rollout: 2 of 4 new patches applied (maxNewPatches=3 from --max-new-patches, \ + shared by this run's directories, 0 left)" + ), "b/ admits roll-b free and roll-e with the last slot: {stdout}" ); assert!( @@ -697,7 +759,7 @@ async fn project_directories_share_one_budget_in_sorted_order() { } #[tokio::test] -async fn a_malformed_env_cap_is_a_usage_error_and_the_flag_wins() { +async fn a_malformed_env_cap_is_a_usage_error_unless_the_flag_overrides_it() { let mock = MockServer::start().await; mount_api(&mock, |_| Grant::Granted).await; let tmp = tempfile::tempdir().unwrap(); @@ -718,6 +780,41 @@ async fn a_malformed_env_cap_is_a_usage_error_and_the_flag_wins() { assert_eq!(out.status.code(), Some(2)); assert!(String::from_utf8_lossy(&out.stderr).contains("SOCKET_MAX_NEW_PATCHES")); + // A flag overrides the env value without reading it. + let mut with_flag = Command::new(binary()); + with_flag + .args([ + "scan", + "--json", + "--dry-run", + "--max-new-patches", + "1", + "--api-url", + ]) + .arg(mock.uri()) + .args(["--api-token", "t", "--org", ORG]) + .current_dir(tmp.path()); + for (key, _) in std::env::vars_os() { + if key.to_string_lossy().starts_with("SOCKET_") { + with_flag.env_remove(&key); + } + } + with_flag + .env("SOCKET_TELEMETRY_DISABLED", "1") + .env("SOCKET_MAX_NEW_PATCHES", "lots"); + let out = with_flag.output().unwrap(); + assert_eq!( + out.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + let v: Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!( + v["rollout"]["maxNewPatches"], + json!({ "value": 1, "source": "flag" }) + ); + cmd.env("SOCKET_MAX_NEW_PATCHES", "1"); let out = cmd.output().unwrap(); let v: Value = serde_json::from_slice(&out.stdout).unwrap(); @@ -734,4 +831,208 @@ async fn a_malformed_env_cap_is_a_usage_error_and_the_flag_wins() { v["rollout"]["maxNewPatches"], json!({ "value": null, "source": "flag" }) ); + assert_eq!( + v["rollout"]["counts"]["new"], 1, + "roll-b landed on the capped run; roll-a now" + ); +} + +/// Mount, ahead of `mount_api`'s answers, a newer superseding patch for +/// `name`: offered first by-package, granted, and viewable. +async fn mount_newer(mock: &MockServer, name: &str, newer: &str) { + let sevs = PACKAGES.iter().find(|(n, _)| *n == name).unwrap().1; + Mock::given(method("GET")) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.*{name}(%40|@)1\\.0\\.0$" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "patches": [ + { "uuid": newer, "purl": purl(name), "publishedAt": "2026-06-01T00:00:00Z", + "description": "newer", "license": "MIT", "tier": "free", + "vulnerabilities": vulns(name, sevs) }, + { "uuid": uuid(name), "purl": purl(name), "publishedAt": "2026-01-01T00:00:00Z", + "description": name, "license": "MIT", "tier": "free", + "vulnerabilities": vulns(name, sevs) } + ], + "canAccessPaidPatches": false, + }))) + .with_priority(1) + .mount(mock) + .await; + let mut results = serde_json::Map::new(); + for (n, _) in PACKAGES { + results.insert( + uuid(n), + json!({ "status": "granted", "url": hosted_url(n), "purl": purl(n), + "artifacts": [{ "kind": "tarball", "url": hosted_url(n), + "integrity": { "sha512": format!("sha512-PATCHED{n}==") } }], + "registryOverride": null }), + ); + } + let newer_url = hosted_url(name).replace(&uuid(name), newer); + results.insert( + newer.to_string(), + json!({ "status": "granted", "url": newer_url, "purl": purl(name), + "artifacts": [{ "kind": "tarball", "url": newer_url, + "integrity": { "sha512": "sha512-NEWER==" } }], + "registryOverride": null }), + ); + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "results": results }))) + .with_priority(1) + .mount(mock) + .await; +} + +#[tokio::test] +async fn hosted_upgrades_land_with_a_cap_of_zero() { + let mock = MockServer::start().await; + mount_api(&mock, |_| Grant::Granted).await; + let tmp = tempfile::tempdir().unwrap(); + let names9: Vec<&str> = PACKAGES.iter().map(|(n, _)| *n).collect(); + write_project(tmp.path(), &names9); + let args = [ + "--mode", + "hosted", + "--max-new-patches", + "3", + "--patch-server-url", + HOST, + ]; + run_json(tmp.path(), &mock, &args); + assert_eq!(pinned(tmp.path()), names(&ORDER[..3])); + + // roll-e (applied) gains a newer patch; six packages still wait. + let newer = "0000000e-1111-4111-8111-00000000000e"; + mount_newer(&mock, "roll-e", newer).await; + let v = run_json( + tmp.path(), + &mock, + &[ + "--mode", + "hosted", + "--max-new-patches", + "0", + "--patch-server-url", + HOST, + ], + ); + assert_eq!(counts(&v), (0, 6, 1, 2), "{v}"); + assert_eq!( + v["updates"], + json!([{ "purl": purl("roll-e"), "oldUuid": uuid("roll-e"), "newUuid": newer }]) + ); + let lock = std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(); + assert!(lock.contains(newer), "the upgrade landed: {lock}"); + assert_eq!( + pinned(tmp.path()), + names(&["roll-b", "roll-g"]), + "nothing new was added" + ); +} + +#[tokio::test] +async fn a_failed_reference_lookup_defers_new_rows_instead_of_failing() { + let tmp = tempfile::tempdir().unwrap(); + let names9: Vec<&str> = PACKAGES.iter().map(|(n, _)| *n).collect(); + write_project(tmp.path(), &names9); + let lock_before = std::fs::read(tmp.path().join("package-lock.json")).unwrap(); + let args = [ + "--mode", + "hosted", + "--max-new-patches", + "3", + "--patch-server-url", + HOST, + ]; + + let failing = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with(ResponseTemplate::new(500)) + .with_priority(1) + .mount(&failing) + .await; + mount_api(&failing, |_| Grant::Granted).await; + + // Every row is NEW: the failure only affects rows the incomplete lookup + // defers anyway, so the capped run succeeds and writes nothing. + let v = run_json(tmp.path(), &failing, &args); + assert_eq!(counts(&v), (0, 9, 0, 0), "{v}"); + let codes: Vec<&str> = v["warnings"] + .as_array() + .unwrap() + .iter() + .map(|w| w["code"].as_str().unwrap()) + .collect(); + assert!(codes.contains(&"rollout_reference_failed"), "{codes:?}"); + assert!(codes.contains(&"rollout_incomplete_lookup"), "{codes:?}"); + assert_eq!( + std::fs::read(tmp.path().join("package-lock.json")).unwrap(), + lock_before + ); + assert!(!tmp.path().join(".socket").exists(), "no lock, no state"); + + // Without a cap the failure is the run's, as before. + let (code, stdout, _) = run(tmp.path(), &failing, &["--json", "--mode", "hosted"]); + assert_eq!(code, 1, "{stdout}"); + let v: Value = serde_json::from_str(stdout.trim()).unwrap(); + assert_eq!(v["status"], "error"); + assert!( + v.get("rollout").is_none(), + "no rollout block on an error envelope" + ); + + // With applied rows the failure affects them too: the run fails. + let ok = MockServer::start().await; + mount_api(&ok, |_| Grant::Granted).await; + run_json(tmp.path(), &ok, &args); + let mut failing_args = vec!["--json"]; + failing_args.extend_from_slice(&args); + let (code, stdout, _) = run(tmp.path(), &failing, &failing_args); + assert_eq!(code, 1, "{stdout}"); +} + +#[tokio::test] +async fn a_failed_detail_lookup_for_an_applied_package_does_not_freeze_new_rows() { + let mock = MockServer::start().await; + mount_api(&mock, |_| Grant::Granted).await; + let tmp = tempfile::tempdir().unwrap(); + let names9: Vec<&str> = PACKAGES.iter().map(|(n, _)| *n).collect(); + write_project(tmp.path(), &names9); + let args = [ + "--mode", + "hosted", + "--max-new-patches", + "3", + "--patch-server-url", + HOST, + ]; + run_json(tmp.path(), &mock, &args); + Mock::given(method("GET")) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.*roll-e(%40|@)1\\.0\\.0$" + ))) + .respond_with(ResponseTemplate::new(500)) + .with_priority(1) + .mount(&mock) + .await; + let v = run_json(tmp.path(), &mock, &args); + assert_eq!( + counts(&v).0, + 3, + "roll-e is recorded, so the next three land: {v}" + ); + let codes: Vec<&str> = v["warnings"] + .as_array() + .unwrap() + .iter() + .map(|w| w["code"].as_str().unwrap()) + .collect(); + assert!(!codes.contains(&"rollout_incomplete_lookup"), "{codes:?}"); + assert!( + pinned(tmp.path()).contains(&"roll-e".to_string()), + "the pin stays" + ); } diff --git a/crates/socket-patch-core/src/rollout.rs b/crates/socket-patch-core/src/rollout.rs index 92016bd9..a09e21c6 100644 --- a/crates/socket-patch-core/src/rollout.rs +++ b/crates/socket-patch-core/src/rollout.rs @@ -14,8 +14,6 @@ use std::cmp::{Ordering, Reverse}; use std::collections::{BTreeMap, BTreeSet}; -use crate::utils::purl::canonical_purl; - /// What the recorded state (manifest > hosted pins > vendor ledger) says /// about one selected row. #[derive(Debug, Clone, PartialEq, Eq)] @@ -134,11 +132,14 @@ pub fn resolve_max_new( } } -/// The budget unit: ecosystem + name + version, qualifiers stripped and -/// percent-decoded, so qualifier twins (a wheel and its sdist, gem -/// platforms) and the API's encoded spelling are one package. +/// The budget unit: ecosystem + name + version, qualifiers stripped, +/// percent-decoded and case-folded where the ecosystem is case-insensitive +/// (discovery's [`crate::vex::discover::canonical_base_purl`], the key +/// hosted pins carry), so qualifier twins (a wheel and its sdist, gem +/// platforms), the API's encoded spelling and a lockfile's `Newtonsoft.Json` +/// vs a pin's `newtonsoft.json` are one package. pub fn canonical_base_purl(purl: &str) -> String { - canonical_purl(purl) + crate::vex::discover::canonical_base_purl(purl) } /// Rollout order, most urgent first: in-flight, severity, advisory count @@ -567,6 +568,14 @@ mod tests { canonical_base_purl("pkg:npm/%40scope/x@1.0.0"), "pkg:npm/@scope/x@1.0.0" ); + assert_eq!( + canonical_base_purl("pkg:nuget/Newtonsoft.Json@13.0.3"), + canonical_base_purl("pkg:nuget/newtonsoft.json@13.0.3") + ); + assert_eq!( + canonical_base_purl("pkg:pypi/Foo_Bar@1.0"), + canonical_base_purl("pkg:pypi/foo-bar@1.0") + ); let rows = vec![ row("", "pkg:pypi/foo@1.0?artifact_id=whl", "u2", 1, 1), row("", "pkg:pypi/foo@1.0?artifact_id=sdist", "u1", 1, 1), @@ -584,8 +593,10 @@ mod tests { assert_eq!(plan.counts.deferred, 1); } + // The ecosystem key agrees with the purl type prefix of the base purl + // key below it; the test pins the combined order. #[test] - fn ties_across_ecosystems_break_by_ecosystem_name() { + fn ties_across_ecosystems_break_by_ecosystem_then_name() { let rows = vec![ row("", "pkg:npm/x@1", "u1", 1, 1), row("", "pkg:cargo/x@1", "u2", 1, 1), diff --git a/docs/design/staged-rollout.md b/docs/design/staged-rollout.md index f7655388..656b115a 100644 --- a/docs/design/staged-rollout.md +++ b/docs/design/staged-rollout.md @@ -1069,6 +1069,24 @@ keeps its rules intact: - **Lock.** A wet hosted run whose only candidates are NEW rows it cannot admit (budget 0, or incomplete data) takes no apply lock and writes nothing, `.socket/` included. +- **Folding.** `canonical_base_purl` is discovery's, so a nuget or + composer pin (lowercased) and a pypi pin (PEP 503 name) match the API's + spelling, and a package in two spellings is one budget unit. Every hosted + pin joins the recorded index, not just the first per key, so both pinned + qualifier twins read ALREADY. +- **Flag over env.** An explicit `--max-new-patches` wins without parsing + `SOCKET_MAX_NEW_PATCHES`; whitespace-only env values are unset. +- **Known limits.** (1) In memory, the symlink / unreadable-file refusals + run inside the first rewrite, before the plan, so a candidate file that + only a deferred NEW row would rewrite still refuses its root (disk runs + its symlink guard after the gate). (2) On disk, a project directory that + fails outright spends nothing and does not freeze later directories. (3) + A NEW row that `mark_pinned` turns ALREADY in a run that could admit no + NEW row takes the apply lock only after its files were read. (4) Memory + pin evidence is any fetched text file of the root; disk's is its + discovery plus the candidate files. (5) No e2e case covers a + vlt-withheld top-ranked row; it takes the same ineligible path as the + withdrawn and `bad_purl` rows the e2e tests cover. - **socket.yml layer.** B resolves the cap as flag > env > file > unlimited through `resolve_max_new`; the file value is passed once A loads the policy in `scan` (9.3). From faa6ffcc4df48b19561bae300d738091d2add5b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 14:56:07 +0000 Subject: [PATCH 15/22] Harden the socket.yml policy after review Fixes from an adversarial self-review of the policy work: - A non-ASCII package spec no longer panics validation, and error text, warnings and verbose lines drop terminal escapes and bidi or zero-width characters. - Ignore lists that fail to compile together are an error instead of silently matching nothing, and the policy file is opened by its resolved path without following a swapped-in symlink. - A top-level key that looks like a misspelled `patches` (`patchs`), a top-level merge key or an aliased key now fails closed. - Repo-root lookup follows a `.git` symlink and trusts the checkout owner under root and sudo, so CI containers keep the policy. - The severity floor always reports the patch it held back, report- only --json runs report what a floor or `enabled: false` hides, a root skipped as a whole is always one entry and no longer prints "No packages found", warnings print once per invocation, and the policy line is omitted when there is nothing to say. - policy entries are sorted and use canonical purls on disk and in memory; the in-memory engine applies a socket.yml negation of a built-in ignore to roots it was given, and policyError carries no CLI-only remedy. - A lockfile-less disk root matches path filters by its manifests. Tests cover each fix, plus the prune universe, agent path filters, a vendored package held byte-identical, and tighter oracles; docs are corrected where they overstated what the human output names. Claude-Session: https://claude.ai/code/session_01BKsyzefGhAnPkYmXCwq3H3 Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 15 +- README.md | 7 +- crates/socket-patch-cli/CLI_CONTRACT.md | 13 +- .../socket-patch-cli/src/commands/scan/mod.rs | 41 ++++-- .../src/commands/scan/policy.rs | 109 +++++++++++---- .../socket-patch-cli/src/hosted_memory/mod.rs | 8 +- .../src/hosted_memory/roots.rs | 18 ++- .../socket-patch-cli/tests/cli_parse_scan.rs | 2 + .../tests/e2e_socket_yml_policy.rs | 105 +++++++++++++- .../tests/hosted_memory_common/mod.rs | 5 +- .../tests/hosted_memory_parity.rs | 25 ++++ crates/socket-patch-core/src/policy/mod.rs | 129 +++++++++++------- .../src/policy/socket_yml.rs | 114 +++++++++++++--- crates/socket-patch-core/src/policy/tests.rs | 30 ++-- docs/design/staged-rollout.md | 24 +++- 15 files changed, 500 insertions(+), 145 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9907bd10..4a6db524 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -573,9 +573,11 @@ into the new version's section — see docs/releasing.md. detected roots (which used to skip them through a hard-coded, case- sensitive segment list). A directory you name (`--cwd`, a literal PATH, `projectRoots`) is not affected; `ignorePaths: ["!/e2e/tests/"]` - re-includes one. -- **An invalid socket.yml fails scan.** An unparseable file, a misspelled or - invalid `patches` block (unknown key, wrong type, bad glob, `patches` + re-includes one (in memory only when the host streamed that root's + files: `selectHostedScanPaths` applies the defaults). +- **An invalid socket.yml fails scan.** An unparseable file, a misspelled + top-level `patches` key (`Patches`, `patchs`), a top-level merge or + aliased key, an invalid `patches` block (unknown key, wrong type, bad glob, `patches` without `version: 2`), or `socket.yml` and `socket.yaml` that disagree now fail `scan` before any request or write: exit 1, `errorCode: socket_yml_invalid` / `socket_yml_ambiguous`, the key path and the fix @@ -592,14 +594,17 @@ into the new version's section — see docs/releasing.md. `ecosystems`, `packages` / `ignorePackages` (`--package` specs), `minSeverity` (critical|high|medium|moderate|low, judged by the worst advisory a patch fixes) and `maxNewPatches` (validated; the per-run cap - lands with `--max-new-patches`). Flags only narrow further. A package + lands with `--max-new-patches`). List flags (`--ecosystems`, + `--package`, PATHs) only narrow further; `--min-severity` beats the + file's floor and `--no-socket-yml` ignores the file. A package that already carries a patch is never removed, upgraded or replaced by the policy: it is held and reported under `policy.retained[]`. New flags `--min-severity` / `SOCKET_MIN_SEVERITY` and `--no-socket-yml` / `SOCKET_NO_SOCKET_YML`; every successful `scan --json` result gains a top-level `policy` block (`source`, `sha256`, `minSeverity`, `filtered[]`, `retained[]`) and the human output a `Policy (socket.yml): …` line that - names every skipped critical/high patch. The in-memory engine takes + names every skipped project and every critical/high patch the severity + floor held back. The in-memory engine takes `noSocketYml` / `minSeverity` / `policyPaths`, `selectHostedScanPaths` returns `policyPaths`, and the result carries `policy` or `policyError`. `get` ignores the policy and warns `policy_bypassed`. diff --git a/README.md b/README.md index 6231ab44..dd8bbe44 100644 --- a/README.md +++ b/README.md @@ -747,6 +747,8 @@ patches: and `testdata/` directories are skipped by default when scan discovers projects (a directory glob such as `scan 'services/*'`); re-include one with a negation (`ignorePaths: ["!/e2e/tests/"]`). A directory you name yourself is always scanned. + (The autopatch bot's tree listing skips those directories before it reads + socket.yml, so there a negation cannot bring one back.) - `packages` / `ignorePackages` take `--package` specs; prefer purls (`pkg:npm/core`), because a bare name also matches other ecosystems and scoped packages (`core` matches `@babel/core`). @@ -757,8 +759,9 @@ patches: is written, with the key and the fix in the message — a typo never widens the rollout. `--no-socket-yml` ignores the file for one run. - `scan --json` reports what the policy did in a top-level `policy` block - (`jq '.policy.counts'`); the human output adds a `Policy (socket.yml): …` line and - always names skipped critical/high patches. `get` ignores the policy (explicit + (`jq '.policy.counts'`); the human output adds a `Policy (socket.yml): …` line that + names every skipped project and every critical/high patch the severity floor held + back (`--verbose` lists everything). `get` ignores the policy (explicit intent) and warns `policy_bypassed`. Every key: `enabled`, `includePaths`, `ignorePaths`, `ecosystems`, `packages`, diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index c258cade..25f41588 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -206,16 +206,16 @@ patches: **Paths.** Path lists are gitignore patterns with the npm `ignore` package's semantics (the backend's `projectIgnorePaths` matcher): case-insensitive, anchored at the repo root, a leading or middle `/` anchors, a bare name matches at any depth, a trailing `/` matches directories only, `!` negates, the last match wins, and a negation cannot re-include anything under an ignored directory (evaluation walks top-down). Backslash is gitignore's escape character, not a separator. Patterns with a `..` segment, a drive letter, a NUL byte, or over 1024 bytes are rejected. -- They are matched against a project root's **marker files**, repo-relative: the lockfiles in the root's directory (`package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`, `bun.lock(b)`, `vlt-lock.json`, `rush.json`, `uv.lock`, `poetry.lock`, `pdm.lock`, `Pipfile.lock`, `requirements.txt`, `*.py.lock`/`pylock*.toml`, `Cargo.lock`, `go.mod`, `go.sum`, `composer.lock`, `Gemfile.lock`, `gems.locked`, plus the Maven/NuGet markers). A root is ignored iff **every** marker is ignored; with `includePaths`, it is included iff **any** marker matches; a root with no marker is matched as its directory. So `/package-lock.json`, `**/yarn.lock` and `examples/**` mean what they mean to the scanner; `includePaths: ["/*", "!/*/"]` targets only the repo-root project. -- Evaluation order (one combined list): the **built-in defaults** `test/ tests/ fixtures/ __fixtures__/ testdata/`, then `projectIgnorePaths`, then `patches.ignorePaths`. Re-include a default with a negation (`ignorePaths: ["!/e2e/tests/"]`). The defaults apply only to **discovered** roots: hosted/vendored PATH-glob matches and roots the in-memory engine detects. A root you name — `--cwd`, a literal PATH, an in-memory `projectRoots` entry — skips them (the other lists still apply). `node_modules .git .socket .yarn vendor` stay structural excludes of in-memory root detection; no policy negates them. +- They are matched against a project root's **marker files**, repo-relative: the lockfiles in the root's directory (`package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`, `bun.lock(b)`, `vlt-lock.json`, `rush.json`, `uv.lock`, `poetry.lock`, `pdm.lock`, `Pipfile.lock`, `requirements.txt`, `*.py.lock`/`pylock*.toml`, `Cargo.lock`, `go.mod`, `go.sum`, `composer.lock`, `Gemfile.lock`, `gems.locked`, plus the Maven/NuGet markers). A root is ignored iff **every** marker is ignored; with `includePaths`, it is included iff **any** marker matches; a disk root with no lockfile uses its manifests (`package.json`, `pyproject.toml`, `setup.py`, `Cargo.toml`, `composer.json`, `Gemfile`, `pom.xml`, `build.gradle`) instead, and one with neither is matched as its directory. So `/package-lock.json`, `**/yarn.lock` and `examples/**` mean what they mean to the scanner; `includePaths: ["/*", "!/*/"]` targets only the repo-root project. +- Evaluation order (one combined list): the **built-in defaults** `test/ tests/ fixtures/ __fixtures__/ testdata/`, then `projectIgnorePaths`, then `patches.ignorePaths`. Re-include a default with a negation (`ignorePaths: ["!/e2e/tests/"]`). The defaults apply only to **discovered** roots: hosted/vendored PATH-glob matches and roots the in-memory engine detects. In memory a negation re-includes a root only if the host streamed its files: `selectHostedScanPaths` cannot read socket.yml, so it applies the defaults itself (name such a root in `projectRoots` instead). A root you name — `--cwd`, a literal PATH, an in-memory `projectRoots` entry — skips them (the other lists still apply). `node_modules .git .socket .yarn vendor` stay structural excludes of in-memory root detection; no policy negates them. - A workspace member that shares its root's lockfile is part of that root's project: exclude it with `ignorePackages`, not paths. - A hosted/vendored PATH that resolves outside the repository root is a usage error (exit 2): one policy per invocation. -**Lookup.** The repo root is the nearest ancestor of `--cwd` (inclusive) holding `.git` (a directory, or a file for worktrees and submodules), not walking past a `GIT_CEILING_DIRECTORIES` entry or into the home directory (unless `--cwd` is it), and, on Unix, only when `.git` belongs to the current user or root (otherwise warning `socket_yml_repo_untrusted` and `--cwd` is the root). No `.git`: the root is `--cwd`. Only `/socket.yml` and `/socket.yaml` are read, matched by exact directory-entry name (`Socket.yml` is not read: warning `socket_yml_name_case`); nested files never are. A symlinked file is followed only to a regular file inside the repo root. `--global` / `--global-prefix` scans read no file. +**Lookup.** The repo root is the nearest ancestor of `--cwd` (inclusive) holding `.git` (a directory, a file for worktrees and submodules, or a symlink to either), not walking past a `GIT_CEILING_DIRECTORIES` entry or into the home directory (unless `--cwd` is it), and, on Unix, only when `.git` belongs to the current user, to root, or to the user `sudo` ran for (`SUDO_UID`); a root process trusts every owner, since CI containers commonly run as root over a checkout owned by another uid (otherwise warning `socket_yml_repo_untrusted` and `--cwd` is the root). No `.git`: the root is `--cwd`. Only `/socket.yml` and `/socket.yaml` are read, matched by exact directory-entry name (`Socket.yml` is not read: warning `socket_yml_name_case`); nested files never are. A symlinked file is followed only to a regular file inside the repo root. `--global` / `--global-prefix` scans read no file. -**Validation (fail closed).** Because the file only narrows, a file that cannot be honored never means "no policy". Checked in order: file access (a regular file after resolving, at most 64 KiB, read from the opened handle), encoding (UTF-8; a BOM is stripped and CRLF is fine; UTF-16 and NUL bytes are errors), YAML 1.2 syntax (duplicate keys, a non-mapping top level, nesting deeper than 32 and a second document are errors), a top-level key equal to `patch`/`patches` ignoring case but not exactly `patches`, the version gate (`patches` requires `version: 2`), then the keys. Inside `patches` and `projectIgnorePaths`, anchors, aliases, merge keys (`<<`) and custom tags are errors (aliases elsewhere are never expanded). An unknown key under `patches` is an error with a did-you-mean hint and "a newer socket-patch may support it". Wrong types are errors — no coercion (`"false"` is not a bool; YAML 1.2, so `no` is a string) — as are an unknown severity, an out-of-range `maxNewPatches`, an invalid pattern or spec, a list over 1000 entries and an entry over 1024 bytes. Every error names the file and the key path (`patches.minSeverity`). `projectIgnorePaths` is validated strictly when a `patches` block exists (a single string is coerced to a one-element list); without one, a malformed value only warns `socket_yml_ignored_value` and is ignored, and it is honored whatever the `version`. An empty or comment-only file counts as no file. When both `socket.yml` and `socket.yaml` exist, both are validated; if their `projectIgnorePaths` and `patches` are equal as parsed values `socket.yml` is used, otherwise the run fails with `socket_yml_ambiguous`. +**Validation (fail closed).** Because the file only narrows, a file that cannot be honored never means "no policy". Checked in order: file access (a regular file after resolving, at most 64 KiB, read from the opened handle), encoding (UTF-8; a BOM is stripped and CRLF is fine; UTF-16 and NUL bytes are errors), YAML 1.2 syntax (duplicate keys, a non-mapping top level, nesting deeper than 32 and a second document are errors), a top-level key that looks like a misspelled `patches` (equal to `patch`/`patches` ignoring case, or within two edits of it and starting `pat`/`pac`, e.g. `patchs`), a top-level merge key (`<<`) or aliased key (either could carry a `patches` block other YAML readers apply), the version gate (`patches` requires `version: 2`), then the keys. Inside `patches` and `projectIgnorePaths`, anchors, aliases, merge keys (`<<`) and custom tags are errors (aliases elsewhere are never expanded). An unknown key under `patches` is an error with a did-you-mean hint and "a newer socket-patch may support it". Wrong types are errors — no coercion (`"false"` is not a bool; YAML 1.2, so `no` is a string) — as are an unknown severity, an out-of-range `maxNewPatches`, an invalid pattern or spec, a list over 1000 entries and an entry over 1024 bytes. Every error names the file and the key path (`patches.minSeverity`). `projectIgnorePaths` is validated strictly when a `patches` block exists (a single string is coerced to a one-element list); without one, a malformed value only warns `socket_yml_ignored_value` and is ignored, and it is honored whatever the `version`. An empty or comment-only file counts as no file. When both `socket.yml` and `socket.yaml` exist, both are validated; if their `projectIgnorePaths` and `patches` are equal as parsed values `socket.yml` is used, otherwise the run fails with `socket_yml_ambiguous`. -**Error output.** Before any request or write, `scan` exits **1** with scan's error object plus an additive `errorCode` (`socket_yml_invalid` or `socket_yml_ambiguous`): `{"status": "error", "error": "socket.yml: patches.minSeverty: unknown key … (fix the file, or pass --no-socket-yml to ignore it)", "errorCode": "socket_yml_invalid", …}` with every count at zero; no `policy` block. Human output: `Error (socket_yml_invalid): …` on stderr. The in-memory engine reports `policyError: {code, detail}` with no root processed and no file changed. +**Error output.** Before any request or write, `scan` exits **1** with scan's error object plus an additive `errorCode` (`socket_yml_invalid` or `socket_yml_ambiguous`): `{"status": "error", "error": "socket.yml: patches.minSeverty: unknown key … (fix the file, or pass --no-socket-yml to ignore it)", "errorCode": "socket_yml_invalid", …}` with every count at zero; no `policy` block. Human output: `Error (socket_yml_invalid): …` on stderr. The in-memory engine reports `policyError: {code, detail}` (the detail without the CLI remedy) with no root processed and no file changed. **The trust boundary holds.** No key names an endpoint, a credential, an org, a mode, a download format or a safety switch — such keys are unknown keys and fail validation. Every key only removes candidates or (`maxNewPatches`) delays them; none can add a package or bypass the tier filter, the agent partition, reference grants, containment checks or any refusal. @@ -253,7 +253,8 @@ patches: - Reason codes (stable): `policy_disabled`, `policy_path_excluded`, `policy_path_not_included`, `policy_ecosystem`, `policy_package_not_listed`, `policy_package_ignored`, `policy_severity` (detail `low < high`, `unknown < high`). In-memory `ProjectResult.skipped[]` carries the post-lookup ones (severity, disabled) with the same codes. - Every string copied from the file (patterns, specs, key names) is truncated to 200 characters with control characters stripped. - Warnings ride scan's top-level `warnings[]` (`{code, detail}`): `socket_yml_ignored_value`, `socket_yml_name_case`, `socket_yml_repo_untrusted`, `patches_disabled`. -- Human output: one line after the table, e.g. `Policy (socket.yml): 3 skipped by filters, 1 patched package held.`, then every filtered critical/high candidate by name (a policy must not hide those silently); `--verbose` lists every entry. +- Human output: one line after the table when anything was filtered or held, e.g. `Policy (socket.yml): 3 skipped by filters, 1 patched package held.`, then every skipped project and every critical/high patch the severity floor or `enabled: false` held back, by name (a policy must not hide those silently; path, ecosystem and package filters run before any patch lookup, so their severity is unknown); `--verbose` lists every entry. A report-only `--json` run (`--prune` or `--global` with no mode) fetches patch details only when a floor or `enabled: false` could withhold something, so its `filtered[]` matches the human output. +- `filtered[]` and `retained[]` are sorted by project, then purl; purls use the canonical spelling (qualifiers stripped, percent-decoded). - Exit code is unchanged by filtering. ### Embedded VEX (`apply --vex` / `scan --vex` / `vendor --vex`) diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index aecfacda..18198f93 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -1277,8 +1277,9 @@ async fn run_project_dirs( let resolved = std::fs::canonicalize(dir).unwrap_or_else(|_| dir.clone()); if !resolved.starts_with(&invocation.repo_root) { eprintln!( - "Error: `{}` is outside the repository root {}: scan one repository per \ - invocation", + "Error: `{}` is outside {} (the repository root socket.yml is read \ + from; without a trusted .git it is --cwd): run one scan per repository, \ + or pass --cwd at a common parent", dir.display(), invocation.repo_root.display() ); @@ -1705,14 +1706,17 @@ async fn run_scan( print_json(&result); return code; } else if !args.common.silent { - println!( - "{}", - render::no_packages_message( - args.common.is_global(), - args.common.ecosystems.as_deref(), - &args.paths, - ) - ); + // A project the policy skipped as a whole is not an empty one. + if !policy.root_excluded() { + println!( + "{}", + render::no_packages_message( + args.common.is_global(), + args.common.ecosystems.as_deref(), + &args.paths, + ) + ); + } policy.print_human(args.common.silent, args.common.verbose); } return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; @@ -2041,6 +2045,23 @@ async fn run_scan( let dry = args.common.dry_run; let mut apply_code = 0i32; + // A report-only run selects nothing, but a severity floor or + // `enabled: false` still hides candidates; report them like the + // human arm does (the detail fetch runs only then). + if !apply && !vendor && policy.reports_selection() && !all_packages_with_patches.is_empty() { + let _ = discover_selected( + &api_client, + &all_packages_with_patches, + can_access_paid_patches, + &policy, + false, + false, + telemetry, + Some(&mut result), + ) + .await; + } + // --- Apply path (if requested) ----------------------------------- if apply { let selected: Vec = match discover_selected( diff --git a/crates/socket-patch-cli/src/commands/scan/policy.rs b/crates/socket-patch-cli/src/commands/scan/policy.rs index 97dbc5de..79385fb5 100644 --- a/crates/socket-patch-cli/src/commands/scan/policy.rs +++ b/crates/socket-patch-cli/src/commands/scan/policy.rs @@ -39,6 +39,9 @@ pub(crate) struct InvocationPolicy { pub policy: SelectionPolicy, pub repo_root: PathBuf, pub warnings: Vec, + /// Set once the invocation's warnings were printed (a PATH list runs + /// one scan per directory; the file was read once). + pub warned: std::sync::atomic::AtomicBool, } /// Load the policy for `args` (4.5): `--global` scans have no repo and read @@ -54,6 +57,7 @@ pub(crate) fn load_invocation_policy(args: &ScanArgs) -> Result Result Vec { .collect() }) .unwrap_or_default(); + if markers.is_empty() { + // No lockfile: the manifests say what the project is. + markers = MANIFEST_MARKERS + .iter() + .filter(|name| dir.join(name).is_file()) + .map(|name| name.to_string()) + .collect(); + } markers.sort(); markers } +/// Manifests that stand in as markers for a root with no lockfile. +const MANIFEST_MARKERS: [&str; 8] = [ + "package.json", + "pyproject.toml", + "setup.py", + "Cargo.toml", + "composer.json", + "Gemfile", + "pom.xml", + "build.gradle", +]; + /// One `policy.filtered[]` entry. #[derive(Debug, Clone)] pub(crate) struct FilteredEntry { @@ -123,6 +148,11 @@ pub(crate) fn policy_block( _ => (serde_json::Value::Null, serde_json::Value::Null), }; let (floor, floor_source) = policy.min_severity(); + // Sorted: crawl order is filesystem order, and the two engines differ. + let mut filtered: Vec<&FilteredEntry> = filtered.iter().collect(); + filtered.sort_by(|a, b| (&a.project, &a.purl, a.reason.code()).cmp(&(&b.project, &b.purl, b.reason.code()))); + let mut retained: Vec<&RetainedEntry> = retained.iter().collect(); + retained.sort_by(|a, b| (&a.project, &a.purl).cmp(&(&b.project, &b.purl))); let filtered: Vec = filtered .iter() .map(|f| { @@ -178,6 +208,8 @@ struct Report { pub(crate) struct ScanPolicy { pub policy: SelectionPolicy, pub warnings: Vec, + /// Print [`Self::warnings`] on the human path (first root only). + announce_warnings: bool, /// Repo-relative root directory (`""` for the repo root). pub project: String, /// The root filter's verdict (`Ok` for global scans). @@ -211,16 +243,40 @@ impl ScanPolicy { .to_string(), }); } + let mut report = Report::default(); + // A root filtered as a whole is one entry, whatever it holds. + if let Err(reason) = &root_verdict { + report.filtered.push(FilteredEntry { + purl: None, + uuid: None, + project: project.clone(), + reason: reason.clone(), + severity: None, + }); + } + let announce_warnings = !invocation.warned.swap(true, std::sync::atomic::Ordering::Relaxed); Self { policy: invocation.policy.clone(), warnings, + announce_warnings, project, root_verdict, recorded: HashMap::new(), - report: Mutex::new(Report::default()), + report: Mutex::new(report), } } + /// Whether selection can filter anything (a floor, or patching + /// disabled): report-only runs select only for the report then. + pub(crate) fn reports_selection(&self) -> bool { + !self.policy.enabled() || self.policy.min_severity().0.is_some() + } + + /// Whether the policy filtered this whole project root. + pub(crate) fn root_excluded(&self) -> bool { + self.root_verdict.is_err() + } + fn report(&self) -> std::sync::MutexGuard<'_, Report> { self.report.lock().unwrap_or_else(|e| e.into_inner()) } @@ -271,15 +327,7 @@ impl ScanPolicy { return true; } if self.root_verdict.is_err() { - if !report.filtered.iter().any(|f| f.purl.is_none()) { - report.filtered.push(FilteredEntry { - purl: None, - uuid: None, - project: self.project.clone(), - reason, - severity: None, - }); - } + // Already reported as the root's one entry. } else if report.filtered_purls.insert(canon(purl)) { report.filtered.push(FilteredEntry { purl: Some(canon(purl)), @@ -341,7 +389,7 @@ impl ScanPolicy { } } None => report.filtered.push(FilteredEntry { - purl: Some(purl.clone()), + purl: Some(canon(&purl)), uuid: Some(group[0].uuid.clone()), project: self.project.clone(), severity: Some(patch_severity_order(&group[0])), @@ -362,21 +410,24 @@ impl ScanPolicy { (_, Some(w), _) => Some(w), // Nothing above the floor: a recorded package keeps its patch. (true, None, Some(r)) => Some(r), - (true, None, None) => None, - (false, None, _) => { + (_, None, _) => None, + }; + // What the floor hid is reported: the top-ranked patch it withheld + // when the package ends up unpatched or held at its recorded patch + // (not when a lower-ranked admitted patch simply wins). + let top_withheld = self.policy.admits_severity(patch_severity_order(&group[0])); + if let Err(reason) = top_withheld { + let upgrade_withheld = chosen.is_some() && chosen == recorded_at && recorded_at != Some(0); + if chosen.is_none() || upgrade_withheld { report.filtered.push(FilteredEntry { - purl: Some(purl.clone()), + purl: Some(canon(&purl)), uuid: Some(group[0].uuid.clone()), project: self.project.clone(), severity: Some(patch_severity_order(&group[0])), - reason: self - .policy - .admits_severity(patch_severity_order(&group[0])) - .expect_err("no offer passed the floor"), + reason, }); - None } - }; + } if let Some(i) = chosen { offers.selected.insert(purl.clone(), group[i].clone()); } @@ -425,7 +476,7 @@ impl ScanPolicy { /// Print the policy warnings (stderr) once, human path. pub(crate) fn print_warnings(&self, silent: bool) { - if silent { + if silent || !self.announce_warnings { return; } for w in &self.warnings { @@ -444,7 +495,7 @@ impl ScanPolicy { } let filtered = report.filtered.len(); let retained = report.retained.len(); - if filtered == 0 && retained == 0 && matches!(self.policy.source(), PolicySource::None) { + if filtered == 0 && retained == 0 && self.policy.enabled() { return; } let label = match self.policy.source() { @@ -461,13 +512,17 @@ impl ScanPolicy { line.push_str(" Patching is disabled (patches.enabled: false)."); } println!("{line}"); - for f in &report.filtered { + let mut entries: Vec<&FilteredEntry> = report.filtered.iter().collect(); + entries.sort_by(|a, b| (&a.project, &a.purl).cmp(&(&b.project, &b.purl))); + for f in entries { + // A skipped project and a withheld critical/high patch are always + // named; everything else only with --verbose. let severe = f.severity.is_some_and(|s| s <= 1); - if !(verbose || severe) { + if !(verbose || severe || f.purl.is_none()) { continue; } let what = match &f.purl { - Some(purl) => normalize_purl(purl).into_owned(), + Some(purl) => sanitize(&normalize_purl(purl)), None if self.project.is_empty() => "this project".to_string(), None => format!("project {}", sanitize(&self.project)), }; @@ -482,8 +537,8 @@ impl ScanPolicy { for r in &report.retained { println!( " held {} at {}: {}", - normalize_purl(&r.purl), - r.recorded_uuid, + sanitize(&normalize_purl(&r.purl)), + sanitize(&r.recorded_uuid), r.reason.detail() ); } diff --git a/crates/socket-patch-cli/src/hosted_memory/mod.rs b/crates/socket-patch-cli/src/hosted_memory/mod.rs index 1dce302f..77f80fdc 100644 --- a/crates/socket-patch-cli/src/hosted_memory/mod.rs +++ b/crates/socket-patch-cli/src/hosted_memory/mod.rs @@ -404,7 +404,7 @@ async fn engine( let root_list: Vec = match &options.project_roots { Some(roots) => roots.clone(), - None => roots::detect_roots(files.keys().map(String::as_str), ecosystems).0, + None => roots::detect_roots_with(files.keys().map(String::as_str), ecosystems, false).0, }; // The full policy (paths from the file too) judges every root before // the project limit; roots named in `projectRoots` are explicit. @@ -493,7 +493,7 @@ async fn engine( match policy.admits_purl(&purl) { Ok(()) => admitted.push(purl), Err(reason) => policy_filtered.push(FilteredEntry { - purl: Some(purl), + purl: Some(crate::commands::scan::policy::canon(&purl)), uuid: None, project: state.root.clone(), reason, @@ -825,7 +825,7 @@ fn policy_error_output( policy: None, policy_error: Some(PolicyErrorInfo { code: error.code().to_string(), - detail: error.to_string(), + detail: error.detail(), }), } } @@ -875,7 +875,7 @@ fn select_with_policy( detail: Some(reason.detail()), }); filtered.push(FilteredEntry { - purl: Some(purl), + purl: Some(crate::commands::scan::policy::canon(&purl)), uuid: Some(winner.uuid.clone()), project: root.to_string(), severity: Some(patch_severity_order(&winner)), diff --git a/crates/socket-patch-cli/src/hosted_memory/roots.rs b/crates/socket-patch-cli/src/hosted_memory/roots.rs index 91cac49b..02873cf6 100644 --- a/crates/socket-patch-cli/src/hosted_memory/roots.rs +++ b/crates/socket-patch-cli/src/hosted_memory/roots.rs @@ -124,10 +124,23 @@ fn allowed(ecosystems: Option<&[String]>, eco: &str) -> bool { ecosystems.is_none_or(|list| list.iter().any(|e| e == eco)) } -/// The detected roots (sorted) and the marker paths that did not make one. +/// The detected roots (sorted) and the marker paths that did not make one, +/// with the policy's built-in default ignores applied (path selection, +/// which cannot see socket.yml's content). pub(crate) fn detect_roots<'a>( paths: impl IntoIterator, ecosystems: Option<&[String]>, +) -> (Vec, Vec) { + detect_roots_with(paths, ecosystems, true) +} + +/// [`detect_roots`]; `apply_defaults: false` leaves the built-in default +/// ignores to the caller (the session applies the full policy, whose +/// negations can re-include a default-ignored root). +pub(crate) fn detect_roots_with<'a>( + paths: impl IntoIterator, + ecosystems: Option<&[String]>, + apply_defaults: bool, ) -> (Vec, Vec) { let mut ignored: Vec = Vec::new(); let mut markers: BTreeMap> = BTreeMap::new(); @@ -178,7 +191,8 @@ pub(crate) fn detect_roots<'a>( .flatten() .map(|p| split_path(p).1.to_string()) .collect(); - let default_ignored = socket_patch_core::policy::builtin_defaults() + let default_ignored = apply_defaults + && socket_patch_core::policy::builtin_defaults() .admits_root(&socket_patch_core::policy::Root { rel_dir: dir, markers: &marker_names, diff --git a/crates/socket-patch-cli/tests/cli_parse_scan.rs b/crates/socket-patch-cli/tests/cli_parse_scan.rs index 9d591160..52300ac6 100644 --- a/crates/socket-patch-cli/tests/cli_parse_scan.rs +++ b/crates/socket-patch-cli/tests/cli_parse_scan.rs @@ -475,6 +475,8 @@ fn scan_json_empty_cwd_emits_updates_key() { // sub-object onto the hosted default path fails loudly. let bin = env!("CARGO_BIN_EXE_socket-patch"); let tmp = tempfile::tempdir().expect("tempdir"); + // Its own repo root, so no socket.yml above the temp dir leaks in. + std::fs::create_dir(tmp.path().join(".git")).expect(".git"); let mut cmd = std::process::Command::new(bin); cmd.args(["scan", "--json", "--cwd"]).arg(tmp.path()); // Strip *every* SOCKET_* override the child would otherwise inherit. diff --git a/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs b/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs index 02e054b5..12e9fd1b 100644 --- a/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs +++ b/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs @@ -700,6 +700,35 @@ async fn recorded_merged_patch_below_a_new_floor_is_kept() { let (code, doc) = scan_json(&web, &server.uri(), &[], &[]); assert_eq!(code, 0, "{doc:#}"); assert_eq!(repo.lock("services/web"), pinned, "the floor never replaces the recorded merged patch"); + assert_eq!(doc["policy"]["minSeverity"], json!({"value": "high", "source": "file"})); + assert_eq!(doc["policy"]["counts"]["filtered"], 0, "a kept recorded patch is not a skip: {:#}", doc["policy"]); + + // The floor is live: a fresh root with the same offers gets the + // floor-admitted patch, not the merged low one. + let fresh = Repo::new(Some("version: 2\npatches:\n minSeverity: high\n")); + let (code, doc) = scan_json(&fresh.dir("services/web"), &server.uri(), &[], &[]); + assert_eq!(code, 0, "{doc:#}"); + let lock = fresh.lock("services/web"); + assert!(lock.contains(&P_ALPHA.hosted_url()), "{lock}"); + assert!(!lock.contains(P_ALPHA_MERGED_LOW.uuid), "{lock}"); +} + +#[tokio::test] +#[serial] +async fn floor_with_nothing_admitted_reports_the_withheld_patch() { + let server = MockServer::start().await; + mount_api(&server, vec![P_BETA]).await; + let repo = Repo::new(Some("version: 2\npatches:\n minSeverity: critical\n")); + let web = repo.dir("services/web"); + let lock = repo.lock("services/web"); + let (code, stdout, stderr) = scan(&web, &server.uri(), &[], &[]); + assert_eq!(code, 0, "{stdout}\n{stderr}"); + assert_eq!(repo.lock("services/web"), lock); + assert!(stdout.contains("Policy (socket.yml): 1 skipped by filters"), "{stdout}"); + // Only critical/high are named without --verbose. + assert!(!stdout.contains("skipped beta"), "{stdout}"); + let (_, stdout, _) = scan(&web, &server.uri(), &["--verbose"], &[]); + assert!(stdout.contains("skipped pkg:npm/beta@1.0.0 (low): low < critical"), "{stdout}"); } #[tokio::test] @@ -712,7 +741,7 @@ async fn path_outside_the_repo_is_a_usage_error() { write_npm_root(&outside, &["alpha"]); let (code, _, stderr) = scan(&repo.dir("services"), &server.uri(), &["web", "../../elsewhere"], &[]); assert_eq!(code, 2, "{stderr}"); - assert!(stderr.contains("outside the repository root"), "{stderr}"); + assert!(stderr.contains("is outside") && stderr.contains("run one scan per repository"), "{stderr}"); } #[tokio::test] @@ -861,3 +890,77 @@ async fn get_bypasses_the_policy_with_a_warning() { assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); assert!(!stdout.contains("policy_bypassed"), "{stdout}"); } + +#[tokio::test] +#[serial] +async fn agent_mode_honors_path_filters_and_keeps_the_prune_universe() { + let server = MockServer::start().await; + mount_api(&server, vec![P_ALPHA, P_BETA]).await; + let repo = Repo::new(None); + let web = repo.dir("services/web"); + let (code, doc) = scan_json(&web, &server.uri(), &["--mode", "agent"], &[]); + assert_eq!(code, 0, "{doc:#}"); + let manifest_before = std::fs::read(web.join(".socket/manifest.json")).unwrap(); + let recorded: Value = serde_json::from_slice(&manifest_before).unwrap(); + assert_eq!(recorded["patches"].as_object().unwrap().len(), 2); + + // The root is excluded by path: nothing selected, and a --sync (agent + // + prune) still judges the full crawl, so no entry is pruned. + std::fs::write(repo.root.join("socket.yml"), "version: 2\npatches:\n includePaths: [\"/services/legacy/\"]\n").unwrap(); + let (code, doc) = scan_json(&web, &server.uri(), &["--sync"], &[]); + assert_eq!(code, 0, "{doc:#}"); + assert_eq!(std::fs::read(web.join(".socket/manifest.json")).unwrap(), manifest_before); + assert_eq!(doc["policy"]["filtered"][0]["purl"], Value::Null); + assert_eq!(doc["policy"]["filtered"][0]["reason"], "policy_path_not_included"); + assert_eq!(doc["policy"]["counts"]["retained"], 2, "{:#}", doc["policy"]); + assert_eq!(doc["gc"]["removed"].as_array().map_or(0, Vec::len), 0, "{:#}", doc["gc"]); + + // A narrower ecosystem list under --sync prunes nothing either. + std::fs::write(repo.root.join("socket.yml"), "version: 2\npatches:\n ecosystems: [pypi]\n").unwrap(); + let (code, doc) = scan_json(&web, &server.uri(), &["--sync"], &[]); + assert_eq!(code, 0, "{doc:#}"); + assert_eq!(std::fs::read(web.join(".socket/manifest.json")).unwrap(), manifest_before); + + // patches.enabled: false skips the GC entirely. + std::fs::remove_dir_all(web.join("node_modules/beta")).unwrap(); + let pkg_lock = repo.lock("services/web").replace("\"node_modules/beta\"", "\"node_modules/gone\""); + std::fs::write(web.join("package-lock.json"), pkg_lock).unwrap(); + std::fs::write(repo.root.join("socket.yml"), "version: 2\npatches:\n enabled: false\n").unwrap(); + let (code, doc) = scan_json(&web, &server.uri(), &["--sync"], &[]); + assert_eq!(code, 0, "{doc:#}"); + assert!(doc.get("gc").is_none(), "{doc:#}"); + assert_eq!(std::fs::read(web.join(".socket/manifest.json")).unwrap(), manifest_before); +} + +#[tokio::test] +#[serial] +async fn narrowing_after_vendoring_leaves_the_vendored_package_byte_identical() { + let server = MockServer::start().await; + mount_api(&server, vec![P_ALPHA]).await; + let repo = Repo::new(None); + let web = repo.dir("services/web"); + // Vendor alpha for real (offline, from a seeded manifest + blob). + let before = compute_git_sha256_from_bytes(orig_index("alpha").as_bytes()); + let after = compute_git_sha256_from_bytes(patched_index("alpha").as_bytes()); + std::fs::create_dir_all(web.join(".socket/blobs")).unwrap(); + std::fs::write(web.join(".socket/blobs").join(&after), patched_index("alpha")).unwrap(); + let manifest = json!({"patches": {P_ALPHA.purl(): { + "uuid": P_ALPHA.uuid, "exportedAt": "2026-01-01T00:00:00Z", + "files": {"package/index.js": {"beforeHash": before, "afterHash": after}}, + "vulnerabilities": {}, "description": "d", "license": "MIT", "tier": "free" + }}}); + std::fs::write(web.join(".socket/manifest.json"), serde_json::to_vec_pretty(&manifest).unwrap()).unwrap(); + let (code, stdout, stderr) = run_cli(&web, &["vendor", "--json", "--offline", "--cwd", web.to_str().unwrap()], &[]); + assert_eq!(code, 0, "vendor fixture: {stdout}\n{stderr}"); + assert!(repo.lock("services/web").contains(".socket/vendor/"), "vendored lock"); + let snapshot = repo.snapshot(); + + std::fs::write(repo.root.join("socket.yml"), "version: 2\npatches:\n ignorePackages: [\"pkg:npm/alpha\"]\n").unwrap(); + let (code, doc) = scan_json(&web, &server.uri(), &["--mode", "vendored"], &[]); + assert_eq!(code, 0, "{doc:#}"); + let mut after_scan = repo.snapshot(); + after_scan.remove("socket.yml"); + assert_eq!(after_scan, snapshot, "the vendored package, its lock wiring and ledger stay byte-identical"); + assert_eq!(doc["policy"]["retained"][0]["purl"], "pkg:npm/alpha@1.0.0", "{:#}", doc["policy"]); +} + diff --git a/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs b/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs index 72ba3b97..1e78e102 100644 --- a/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs +++ b/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs @@ -331,9 +331,8 @@ pub fn run_disk_in( std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(&path, bytes).unwrap(); } - if !cwd_rel.is_empty() { - std::fs::create_dir_all(checkout.path().join(".git")).unwrap(); - } + // The checkout is its own repo: no socket.yml above the temp dir applies. + std::fs::create_dir_all(checkout.path().join(".git")).unwrap(); let cwd = checkout.path().join(cwd_rel); let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_socket-patch")); cmd.env_clear(); diff --git a/crates/socket-patch-cli/tests/hosted_memory_parity.rs b/crates/socket-patch-cli/tests/hosted_memory_parity.rs index c26764f0..b3027ffa 100644 --- a/crates/socket-patch-cli/tests/hosted_memory_parity.rs +++ b/crates/socket-patch-cli/tests/hosted_memory_parity.rs @@ -927,3 +927,28 @@ fn selection_streams_policy_files_and_applies_built_in_ignores() { ); assert_eq!(named.roots, vec!["apps/web/tests/app"]); } + +#[tokio::test] +async fn memory_negation_reincludes_a_default_ignored_root_it_was_given() { + let npm = fixtures_root().join("redirect/npm/package-lock-v3/basic"); + let patches = patches_from_overrides(&npm.join("overrides.json"), None); + let server = MockServer::start().await; + mount_api(&server, &patches).await; + let mut repo: BTreeMap> = BTreeMap::new(); + for root in ["e2e/tests", "x/tests"] { + for (rel, bytes) in fixture_files(&npm.join("input")) { + repo.insert(format!("{root}/{rel}"), bytes); + } + } + repo.insert( + "socket.yml".to_string(), + b"version: 2\npatches:\n ignorePaths: [\"!/e2e/tests/\"]\n".to_vec(), + ); + let memory = run_engine(&server, build_input(&repo, &[], policy_options())).await; + let roots: Vec<&str> = memory.projects.iter().map(|p| p.root.as_str()).collect(); + assert_eq!(roots, vec!["e2e/tests"]); + let filtered = filtered_set(memory.policy.as_ref().unwrap()); + assert!(filtered.contains(&("x/tests".to_string(), None, "policy_path_excluded".to_string()))); + let entry = &memory.policy.as_ref().unwrap()["filtered"][0]; + assert_eq!(entry["detail"], "tests/ (built-in default)"); +} diff --git a/crates/socket-patch-core/src/policy/mod.rs b/crates/socket-patch-core/src/policy/mod.rs index f60a2472..49e7113f 100644 --- a/crates/socket-patch-core/src/policy/mod.rs +++ b/crates/socket-patch-core/src/policy/mod.rs @@ -47,11 +47,24 @@ pub const POLICY_BYPASSED: &str = "policy_bypassed"; /// Longest file-derived string copied into output. const MAX_OUTPUT_CHARS: usize = 200; -/// Make a file-derived string safe to print: control characters dropped, -/// at most 200 characters. +/// Characters never copied into output: controls (terminal escapes) and +/// the invisible formatting characters that can reorder or hide text +/// (bidi overrides and isolates, zero-width characters, BOM). +fn unsafe_for_output(c: char) -> bool { + c.is_control() + || matches!(c, '\u{200B}'..='\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2060}'..='\u{2069}' | '\u{FEFF}') +} + +/// [`sanitize`] without the length cap, for whole messages. +pub fn strip_unsafe(s: &str) -> String { + s.chars().filter(|c| !unsafe_for_output(*c)).collect() +} + +/// Make a file-derived string safe to print: control and invisible +/// formatting characters dropped, at most 200 characters. pub fn sanitize(s: &str) -> String { s.chars() - .filter(|c| !c.is_control()) + .filter(|c| !unsafe_for_output(*c)) .take(MAX_OUTPUT_CHARS) .collect() } @@ -154,8 +167,12 @@ impl PolicyError { /// The message without the remedy. pub fn detail(&self) -> String { match self { - PolicyError::Invalid { file, key, message } if key.is_empty() => format!("{file}: {message}"), - PolicyError::Invalid { file, key, message } => format!("{file}: {key}: {message}"), + PolicyError::Invalid { file, key, message } if key.is_empty() => { + format!("{file}: {}", strip_unsafe(message)) + } + PolicyError::Invalid { file, key, message } => { + format!("{file}: {}: {}", sanitize(key), strip_unsafe(message)) + } PolicyError::Ambiguous { files } => format!( "{} and {} both exist and their `patches`/`projectIgnorePaths` differ; keep one file", files[0], files[1] @@ -223,10 +240,10 @@ impl DiskPolicyFs { fn open_nonblocking(path: &Path) -> std::io::Result { use std::os::unix::fs::OpenOptionsExt; // O_NONBLOCK: opening a FIFO must not wait for a writer; the handle's - // metadata then refuses it. + // metadata then refuses it. O_NOFOLLOW: the path was resolved already. std::fs::OpenOptions::new() .read(true) - .custom_flags(libc::O_NONBLOCK) + .custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW) .open(path) } @@ -244,16 +261,15 @@ impl PolicyFs for DiskPolicyFs { if !self.entry_names().iter().any(|n| n == name) { return Ok(RootFile::Absent); } - let path = self.root.join(name); - let link_meta = std::fs::symlink_metadata(&path)?; - if link_meta.file_type().is_symlink() { - let target = std::fs::canonicalize(&path)?; - let root = std::fs::canonicalize(&self.root)?; - if !target.starts_with(&root) { - return Err(io_other("symlink resolves outside the repository root")); - } + // Resolve first, confine, then open the resolved path without + // following a final symlink: a link swapped in after the check is + // refused instead of followed out of the repository. + let root = std::fs::canonicalize(&self.root)?; + let target = std::fs::canonicalize(self.root.join(name))?; + if !target.starts_with(&root) { + return Err(io_other("symlink resolves outside the repository root")); } - let file = open_nonblocking(&path)?; + let file = open_nonblocking(&target)?; let meta = file.metadata()?; if !meta.is_file() { return Err(io_other("not a regular file")); @@ -424,12 +440,15 @@ fn defaults_list() -> Vec { DEFAULT_IGNORE_PATHS.iter().map(|s| s.to_string()).collect() } -fn compile(lists: &[(&'static str, &[String])]) -> PathMatcher { - // Every list was compiled once during validation, so this cannot fail; - // an empty matcher would only ever admit more, which the validation - // already ruled out. - PathMatcher::new(lists) - .unwrap_or_else(|_| PathMatcher::new(&[]).expect("an empty pattern list always compiles")) +/// Compile a combined list. Each list was validated on its own, but the +/// combination can still exceed the glob engine's size limit; that is an +/// error (fail closed), never an empty matcher. +fn compile(file: &str, lists: &[(&'static str, &[String])]) -> Result { + PathMatcher::new(lists).map_err(|(list, _, message)| PolicyError::Invalid { + file: file.to_string(), + key: list.to_string(), + message: format!("the path patterns cannot be compiled together: {message}"), + }) } /// The built-in defaults, compiled once (for callers that only need the @@ -442,29 +461,37 @@ pub fn builtin_defaults() -> &'static SelectionPolicy { impl SelectionPolicy { /// No file: only the built-in default ignores. pub fn unrestricted() -> Self { - Self::from_parts(PolicySource::None, &[], &PatchesBlock::default()) + Self::from_parts("", PolicySource::None, &[], &PatchesBlock::default()) + .expect("the built-in default ignores always compile") } fn from_parts( + file: &str, source: PolicySource, project_ignore_paths: &[String], block: &PatchesBlock, - ) -> Self { + ) -> Result { let defaults = defaults_list(); - let ignore_discovered = compile(&[ - (DEFAULT_IGNORE_LIST, &defaults), - ("projectIgnorePaths", project_ignore_paths), - ("patches.ignorePaths", &block.ignore_paths), - ]); - let ignore_explicit = compile(&[ - ("projectIgnorePaths", project_ignore_paths), - ("patches.ignorePaths", &block.ignore_paths), - ]); - let include = block - .include_paths - .as_ref() - .map(|list| compile(&[("patches.includePaths", list)])); - Self { + let ignore_discovered = compile( + file, + &[ + (DEFAULT_IGNORE_LIST, &defaults), + ("projectIgnorePaths", project_ignore_paths), + ("patches.ignorePaths", &block.ignore_paths), + ], + )?; + let ignore_explicit = compile( + file, + &[ + ("projectIgnorePaths", project_ignore_paths), + ("patches.ignorePaths", &block.ignore_paths), + ], + )?; + let include = match block.include_paths.as_ref() { + Some(list) => Some(compile(file, &[("patches.includePaths", list)])?), + None => None, + }; + Ok(Self { source, enabled: block.enabled.unwrap_or(true), ignore_discovered, @@ -480,7 +507,7 @@ impl SelectionPolicy { SeveritySource::Default }, max_new_patches: block.max_new_patches, - } + }) } fn apply_overrides(&mut self, overrides: &PolicyOverrides) { @@ -500,8 +527,8 @@ impl SelectionPolicy { ) -> Result<(Self, Vec), PolicyError> { let mut warnings = Vec::new(); if overrides.bypass { - let mut policy = - Self::from_parts(PolicySource::Bypassed, &[], &PatchesBlock::default()); + let mut policy = Self::unrestricted(); + policy.source = PolicySource::Bypassed; policy.apply_overrides(overrides); return Ok((policy, warnings)); } @@ -553,7 +580,7 @@ impl SelectionPolicy { sha256: hex::encode(Sha256::digest(&bytes)), }; let block = parsed.patches.clone().unwrap_or_default(); - Self::from_parts(source, &parsed.project_ignore_paths, &block) + Self::from_parts(name, source, &parsed.project_ignore_paths, &block)? } _ => Self::unrestricted(), }; @@ -771,13 +798,19 @@ fn ceiling_dirs() -> Vec { #[cfg(unix)] fn trusted_owner(meta: &std::fs::Metadata) -> bool { use std::os::unix::fs::MetadataExt; + let sudo_uid = std::env::var("SUDO_UID").ok().and_then(|v| v.trim().parse::().ok()); // SAFETY: geteuid has no preconditions and cannot fail. - owner_trusted(meta.uid(), unsafe { libc::geteuid() }) + owner_trusted(meta.uid(), unsafe { libc::geteuid() }, sudo_uid) } +/// `.git` is trusted when it belongs to the invoking user, to root, or +/// (under sudo) to the user sudo ran for. Root trusts every owner: a root +/// process is exposed to the whole filesystem anyway, and CI containers +/// commonly run as root over a checkout owned by another uid, where +/// distrust would silently drop the repo's policy (which only narrows). #[cfg(unix)] -fn owner_trusted(owner: u32, euid: u32) -> bool { - owner == euid || owner == 0 +fn owner_trusted(owner: u32, euid: u32, sudo_uid: Option) -> bool { + euid == 0 || owner == euid || owner == 0 || sudo_uid == Some(owner) } #[cfg(not(unix))] @@ -788,7 +821,8 @@ fn trusted_owner(_meta: &std::fs::Metadata) -> bool { /// The repo root for `cwd` (4.5) with the lookup's warnings: the nearest /// ancestor (inclusive) holding a `.git` directory or file, not walking /// past `GIT_CEILING_DIRECTORIES` or into the home directory, and (Unix) -/// only when `.git` belongs to the current user or root. Otherwise `cwd`. +/// only when `.git` belongs to a trusted owner ([`owner_trusted`]). +/// Otherwise `cwd`. pub fn find_repo_root_with_warnings(cwd: &Path) -> (PathBuf, Vec) { let cwd = std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf()); let ceilings = ceiling_dirs(); @@ -799,7 +833,8 @@ pub fn find_repo_root_with_warnings(cwd: &Path) -> (PathBuf, Vec) if dir != cwd && home.as_deref() == Some(dir) { break; } - if let Ok(meta) = std::fs::symlink_metadata(dir.join(".git")) { + // `metadata` follows a `.git` symlink, as git does. + if let Ok(meta) = std::fs::metadata(dir.join(".git")) { if meta.is_dir() || meta.is_file() { if trusted_owner(&meta) { return (dir.to_path_buf(), warnings); diff --git a/crates/socket-patch-core/src/policy/socket_yml.rs b/crates/socket-patch-core/src/policy/socket_yml.rs index b434240d..30a99499 100644 --- a/crates/socket-patch-core/src/policy/socket_yml.rs +++ b/crates/socket-patch-core/src/policy/socket_yml.rs @@ -384,8 +384,8 @@ impl Ctx<'_> { fn err(&self, key: impl Into, message: impl Into) -> PolicyError { PolicyError::Invalid { file: self.file.to_string(), - key: key.into(), - message: message.into(), + key: sanitize(&key.into()), + message: super::strip_unsafe(&message.into()), } } } @@ -486,8 +486,7 @@ pub(crate) fn package_spec_error(spec: &str) -> Option<&'static str> { if spec.is_empty() { return Some("package spec is empty"); } - if spec.len() >= 4 && spec[..4].eq_ignore_ascii_case("pkg:") { - let rest = &spec[4..]; + if let Some(rest) = spec.get(..4).filter(|p| p.eq_ignore_ascii_case("pkg:")).map(|_| &spec[4..]) { let valid = rest.split_once('/').is_some_and(|(ty, name)| { !ty.is_empty() && !name.trim_matches('/').is_empty() && !name.starts_with('@') }); @@ -753,9 +752,19 @@ pub(crate) fn parse_file( let mut patches: Option<&Node> = None; let mut ignore_paths: Option<&Node> = None; for (key, value) in pairs { + // A merge key or an aliased key could carry a `patches` block that + // other YAML readers apply; refuse rather than read "no policy". + if key.is_merge_key() || matches!(key.kind, Kind::Alias) || key.anchored { + return Err(ctx.err( + "", + "top-level merge keys (`<<`) and aliased keys are not supported; write the keys out", + )); + } let Some(name) = key.as_str() else { continue }; let lower = name.to_ascii_lowercase(); - if (lower == "patch" || lower == "patches") && name != "patches" { + let near_patches = (lower.starts_with("pat") || lower.starts_with("pac")) + && edit_distance(&lower, "patches") <= 2; + if (lower == "patch" || lower == "patches" || near_patches) && name != "patches" { return Err(ctx.err( sanitize(name), "looks like a misspelled `patches` block; the key must be exactly `patches`", @@ -776,7 +785,7 @@ pub(crate) fn parse_file( Some(Err((key, message))) => { warnings.push(PolicyWarning { code: super::SOCKET_YML_IGNORED_VALUE, - detail: format!("{file}: {key} {message}; the key is ignored"), + detail: super::strip_unsafe(&format!("{file}: {key} {message}; the key is ignored")), }); Vec::new() } @@ -887,19 +896,82 @@ mod tests { #[test] fn encoding_errors() { - for bytes in [ - &b"\xFF\xFEv\0e\0r\0"[..], - &b"\xFE\xFF\0v\0e"[..], - &b"version: 2\0\n"[..], - &b"version: \xC3\x28\n"[..], + for (bytes, expected) in [ + (&b"\xFF\xFEv\0e\0r\0"[..], "UTF-16"), + (&b"\xFE\xFF\0v\0e"[..], "UTF-16"), + (&b"version: 2\0\n"[..], "NUL"), + (&b"version: \xC3\x28\n"[..], "not valid UTF-8"), ] { - assert!( - parse_file("socket.yml", bytes, &mut Vec::new()).is_err(), - "{bytes:?}" - ); + match parse_file("socket.yml", bytes, &mut Vec::new()) { + Err(PolicyError::Invalid { message, .. }) => { + assert!(message.contains(expected), "{message}") + } + other => panic!("{bytes:?}: {other:?}"), + } } } + #[test] + fn checks_run_in_order() { + // YAML beats everything; the case variant beats the version gate; + // the version gate beats the keys. + assert_eq!(err_key("patches: {minSeverty: x}\n").0, "version"); + assert!(err_key("Patches: {}\npatches: {minSeverty: x}\n").1.contains("misspelled")); + assert!(err_key("patches: {minSeverty: x\n").1.contains("invalid YAML")); + } + + #[test] + fn top_level_typos_merge_keys_and_aliases_fail_closed() { + for text in [ + "version: 2\npatchs: {enabled: false}\n", + "version: 2\npacthes: {}\n", + "version: 2\npatches_: {}\n", + ] { + assert!(err_key(text).1.contains("misspelled"), "{text:?}"); + } + for text in [ + "base: &b {patches: {enabled: false}}\n<<: *b\nversion: 2\n", + "k: &k patches\nversion: 2\n*k : {enabled: false}\n", + ] { + assert!(err_key(text).1.contains("merge keys"), "{text:?}"); + } + // Unrelated scanner keys are fine. + assert!(parse( + "version: 2\ntriggerPaths: [a]\nissueRules: {x: true}\ngithubApp: {enabled: true}\n" + ) + .is_ok()); + } + + #[test] + fn non_ascii_specs_and_escapes_never_panic_or_leak() { + for spec in ["abc\u{e9}", "ab\u{20ac}", "p\u{e9}g:npm/x", "\u{1F600}"] { + let text = format!("version: 2\npatches:\n ignorePackages: [\"{spec}\"]\n"); + assert!(parse(&text).is_ok(), "{spec:?}"); + } + let text = "version: 2\npatches:\n ignorePaths: [\"\\e]0;pwned\\a\\e[2J[x\u{202E}\"]\n"; + let err = parse(text).unwrap_err(); + let shown = err.to_string(); + assert!( + !shown.chars().any(|c| c.is_control() || c == '\u{202E}'), + "{shown:?}" + ); + } + + #[test] + fn nesting_limit_boundary() { + // The top-level mapping is level 1: 31 nested lists reach 32 levels. + let at_limit = format!("a: {}{}\n", "[".repeat(31), "]".repeat(31)); + assert!(parse(&at_limit).is_ok()); + let over = format!("a: {}{}\n", "[".repeat(32), "]".repeat(32)); + assert!(err_key(&over).1.contains("deeper than 32")); + } + + #[test] + fn project_ignore_paths_string_without_patches_block() { + let parsed = parse("version: 2\nprojectIgnorePaths: \"examples/**\"\n").unwrap(); + assert_eq!(parsed.project_ignore_paths, vec!["examples/**".to_string()]); + } + #[test] fn yaml_errors() { for text in [ @@ -911,7 +983,17 @@ mod tests { "a: 1\n---\nb: 2\n", "projectIgnorePaths:\n - **\n", ] { - assert!(parse(text).is_err(), "{text:?} must fail"); + let (key, message) = err_key(text); + assert_eq!(key, "", "{text:?}"); + assert!( + [ + "invalid YAML", + "top level must be a mapping", + ] + .iter() + .any(|m| message.contains(m)), + "{text:?}: {message}" + ); } } diff --git a/crates/socket-patch-core/src/policy/tests.rs b/crates/socket-patch-core/src/policy/tests.rs index b3a75a36..fd824749 100644 --- a/crates/socket-patch-core/src/policy/tests.rs +++ b/crates/socket-patch-core/src/policy/tests.rs @@ -558,31 +558,23 @@ mod disk { #[cfg(unix)] #[test] fn owner_rule() { - assert!(owner_trusted(1000, 1000)); - assert!(owner_trusted(0, 1000)); - assert!(!owner_trusted(1001, 1000)); + assert!(owner_trusted(1000, 1000, None)); + assert!(owner_trusted(0, 1000, None)); + assert!(!owner_trusted(1001, 1000, None)); + assert!(owner_trusted(1001, 1000, Some(1001)), "sudo's invoking user"); + assert!(owner_trusted(1001, 0, None), "root trusts every owner"); } #[cfg(unix)] #[test] - fn foreign_owned_git_stops_the_walk() { - // SAFETY: no preconditions. - if unsafe { libc::geteuid() } != 0 { - // Only root can hand `.git` to another owner; `owner_rule` - // covers the decision itself. - return; - } + fn symlinked_git_marks_the_repo_root() { let tmp = tempfile::tempdir().unwrap(); let base = fs::canonicalize(tmp.path()).unwrap(); - fs::create_dir_all(base.join(".git")).unwrap(); - let cwd = base.join("sub"); - fs::create_dir_all(&cwd).unwrap(); - let git = std::ffi::CString::new(base.join(".git").to_str().unwrap()).unwrap(); - // SAFETY: a valid NUL-terminated path. - assert_eq!(unsafe { libc::chown(git.as_ptr(), 4242, 4242) }, 0); - let (found, warnings) = find_repo_root_with_warnings(&cwd); - assert_eq!(found, cwd); - assert_eq!(warnings[0].code, "socket_yml_repo_untrusted"); + fs::create_dir_all(base.join("gitdir")).unwrap(); + let repo = base.join("repo"); + fs::create_dir_all(repo.join("sub")).unwrap(); + std::os::unix::fs::symlink(base.join("gitdir"), repo.join(".git")).unwrap(); + assert_eq!(find_repo_root(&repo.join("sub")), repo); } } diff --git a/docs/design/staged-rollout.md b/docs/design/staged-rollout.md index 1bb12aff..4395f013 100644 --- a/docs/design/staged-rollout.md +++ b/docs/design/staged-rollout.md @@ -1054,9 +1054,11 @@ Gaps and contradictions A resolved with the smallest reasonable decision is empty and the floor rule of item 5 cannot see recorded pins (filtered packages' pins stay byte-identical regardless: the rewriters only touch selected dependencies). `selectHostedScanPaths` applies the built-in - default ignores, so a socket.yml negation of a default cannot re-include - an in-memory root (the file's content is unknown at selection time); a - root named in `projectRoots` is explicit and skips the defaults. There + default ignores (the file's content is unknown at selection time), so a + socket.yml negation re-includes an in-memory root only when the host + streamed its files anyway; the session itself detects roots without the + defaults and applies the full policy. A root named in `projectRoots` is + explicit and skips the defaults. There is no case-variant warning in memory (selection streams exact names only). `ProjectResult.skipped[]` carries the post-lookup policy reasons (severity, disabled); the pre-lookup ones are in the session `policy` @@ -1071,6 +1073,22 @@ Gaps and contradictions A resolved with the smallest reasonable decision validates the key but does not enforce the cap); R1 and R5 come with B. 12. **`get`'s `policy_bypassed`** is one warning per package; the severity reason fires only when none of the package's patches passes the floor. +13. **Repo-root trust** (review follow-up): root trusts every `.git` owner + and `SUDO_UID`'s user is trusted, so a root CI container over a checkout + owned by another uid still applies the policy (distrust would drop a + policy that only narrows). A `.git` symlink counts, as in git. +14. **A disk root with no lockfile** uses its manifests as markers, so + `includePaths: ["/*", "!/*/"]` can match a lockfile-less repo root. +15. **Top-level typos and merge keys fail closed**: a key within two edits + of `patches` starting `pat`/`pac`, and a top-level `<<` or aliased key, + are errors (another YAML reader could see a `patches` block there). +16. **Report-only `--json`** fetches patch details only when a floor or + `enabled: false` could withhold something, so its `filtered[]` matches + the human output. +17. **What the floor reports**: the top-ranked patch it withheld, when the + package ends up unpatched or held at its recorded patch; not when a + lower-ranked admitted patch wins. A kept recorded patch that is itself + below the floor is not a skip. ## 10. Open questions (decided by default, revisit with evidence) From a7a006a5dfd2dc4aebe91b31e4a9d93e878e43b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 14:58:03 +0000 Subject: [PATCH 16/22] Match zero-cap test to dry-run wording The dry-run tense fix changed the deferred line to "would be deferred"; the unit test still expected the wet-run wording. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/src/commands/scan/rollout.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/socket-patch-cli/src/commands/scan/rollout.rs b/crates/socket-patch-cli/src/commands/scan/rollout.rs index c2140994..09ea6647 100644 --- a/crates/socket-patch-cli/src/commands/scan/rollout.rs +++ b/crates/socket-patch-cli/src/commands/scan/rollout.rs @@ -1082,7 +1082,7 @@ mod tests { ) ); assert!( - next[0].starts_with("4 new patches deferred: maxNewPatches=0"), + next[0].starts_with("4 new patches would be deferred: maxNewPatches=0"), "{next:?}" ); assert_eq!(next[1], "Next up: a@1 (high), b@1 (high), c@1 (high), …"); From b975913f3b9b05cde5d77d48a8c30c9c858c54fb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 15:41:37 +0000 Subject: [PATCH 17/22] Give the release test job more time The optimized test job now has to build two more crates and one more test binary for the socket.yml policy. It ran out of time on its last 40 minutes, about a minute short, and a docs-only change already takes 38. Raise the limit to 50 minutes so it can finish. Claude-Session: https://claude.ai/code/session_01BKsyzefGhAnPkYmXCwq3H3 Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb2d4722..d102b932 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -340,8 +340,9 @@ jobs: # default features): ~25m on main with ~240 test binaries, so 30m left # no headroom as suites grow. The manifest-less VEX suites share two # multi-module binaries (tests/e2e_vex_lockfile/, tests/e2e_vex_build/) - # to keep the count down; the extra 10m covers the rest. - timeout-minutes: 40 + # to keep the count down. With no cache on PR runs the job takes ~38m + # before any new crate or suite, so 50m keeps headroom. + timeout-minutes: 50 steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 From e1a35fe66fafb9eec98015558a22e1178038e7a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 16:49:57 +0000 Subject: [PATCH 18/22] Apply socket.yml paths during memory selection The merged plan makes in-memory path selection two-phase: the host fetches the root socket.yml first and passes its text to selectHostedScanPaths. Selection now applies the full path policy, so a negation such as `!/e2e/tests/` brings a test tree back in memory exactly as on disk. A listed policy file that is missing, symlinked or invalid returns policyError with nothing selected. Selection returns policySha256, and the session fails closed when the policy it reads differs. Roots the policy excludes keep their marker files presence-only, so the session still lists them as filtered. Claude-Session: https://claude.ai/code/session_01BKsyzefGhAnPkYmXCwq3H3 Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 12 +- crates/socket-patch-cli/CLI_CONTRACT.md | 4 +- .../src/commands/hosted_bundle.rs | 3 + .../src/hosted_memory/limits.rs | 2 + .../socket-patch-cli/src/hosted_memory/mod.rs | 41 +++- .../src/hosted_memory/roots.rs | 92 ++------- .../src/hosted_memory/select.rs | 138 +++++++++++-- .../src/hosted_memory/types.rs | 29 +++ .../tests/hosted_memory_parity.rs | 192 +++++++++++++++--- crates/socket-patch-node/npm/index.d.ts | 7 +- docs/design/staged-rollout.md | 20 +- 11 files changed, 400 insertions(+), 140 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a6db524..3c79eecd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -573,8 +573,7 @@ into the new version's section — see docs/releasing.md. detected roots (which used to skip them through a hard-coded, case- sensitive segment list). A directory you name (`--cwd`, a literal PATH, `projectRoots`) is not affected; `ignorePaths: ["!/e2e/tests/"]` - re-includes one (in memory only when the host streamed that root's - files: `selectHostedScanPaths` applies the defaults). + re-includes one, in memory too. - **An invalid socket.yml fails scan.** An unparseable file, a misspelled top-level `patches` key (`Patches`, `patchs`), a top-level merge or aliased key, an invalid `patches` block (unknown key, wrong type, bad glob, `patches` @@ -604,9 +603,12 @@ into the new version's section — see docs/releasing.md. top-level `policy` block (`source`, `sha256`, `minSeverity`, `filtered[]`, `retained[]`) and the human output a `Policy (socket.yml): …` line that names every skipped project and every critical/high patch the severity - floor held back. The in-memory engine takes - `noSocketYml` / `minSeverity` / `policyPaths`, `selectHostedScanPaths` - returns `policyPaths`, and the result carries `policy` or `policyError`. + floor held back. In memory, selection is two-phase: + `selectHostedScanPaths` takes the root policy files' text + (`policyFiles`) and `noSocketYml`, applies the full path policy and + returns `policyPaths`, `policySha256` and `policyError`; the session + takes `noSocketYml` / `minSeverity` / `policyPaths` / `policySha256` and + its result carries `policy` or `policyError`. `get` ignores the policy and warns `policy_bypassed`. - **`scan --package `** (repeatable or comma-separated, env diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 25f41588..8a24c086 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -207,12 +207,14 @@ patches: **Paths.** Path lists are gitignore patterns with the npm `ignore` package's semantics (the backend's `projectIgnorePaths` matcher): case-insensitive, anchored at the repo root, a leading or middle `/` anchors, a bare name matches at any depth, a trailing `/` matches directories only, `!` negates, the last match wins, and a negation cannot re-include anything under an ignored directory (evaluation walks top-down). Backslash is gitignore's escape character, not a separator. Patterns with a `..` segment, a drive letter, a NUL byte, or over 1024 bytes are rejected. - They are matched against a project root's **marker files**, repo-relative: the lockfiles in the root's directory (`package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`, `bun.lock(b)`, `vlt-lock.json`, `rush.json`, `uv.lock`, `poetry.lock`, `pdm.lock`, `Pipfile.lock`, `requirements.txt`, `*.py.lock`/`pylock*.toml`, `Cargo.lock`, `go.mod`, `go.sum`, `composer.lock`, `Gemfile.lock`, `gems.locked`, plus the Maven/NuGet markers). A root is ignored iff **every** marker is ignored; with `includePaths`, it is included iff **any** marker matches; a disk root with no lockfile uses its manifests (`package.json`, `pyproject.toml`, `setup.py`, `Cargo.toml`, `composer.json`, `Gemfile`, `pom.xml`, `build.gradle`) instead, and one with neither is matched as its directory. So `/package-lock.json`, `**/yarn.lock` and `examples/**` mean what they mean to the scanner; `includePaths: ["/*", "!/*/"]` targets only the repo-root project. -- Evaluation order (one combined list): the **built-in defaults** `test/ tests/ fixtures/ __fixtures__/ testdata/`, then `projectIgnorePaths`, then `patches.ignorePaths`. Re-include a default with a negation (`ignorePaths: ["!/e2e/tests/"]`). The defaults apply only to **discovered** roots: hosted/vendored PATH-glob matches and roots the in-memory engine detects. In memory a negation re-includes a root only if the host streamed its files: `selectHostedScanPaths` cannot read socket.yml, so it applies the defaults itself (name such a root in `projectRoots` instead). A root you name — `--cwd`, a literal PATH, an in-memory `projectRoots` entry — skips them (the other lists still apply). `node_modules .git .socket .yarn vendor` stay structural excludes of in-memory root detection; no policy negates them. +- Evaluation order (one combined list): the **built-in defaults** `test/ tests/ fixtures/ __fixtures__/ testdata/`, then `projectIgnorePaths`, then `patches.ignorePaths`. Re-include a default with a negation (`ignorePaths: ["!/e2e/tests/"]`). The defaults apply only to **discovered** roots: hosted/vendored PATH-glob matches and roots the in-memory engine detects. A root you name — `--cwd`, a literal PATH, an in-memory `projectRoots` entry — skips them (the other lists still apply). `node_modules .git .socket .yarn vendor` stay structural excludes of in-memory root detection; no policy negates them. - A workspace member that shares its root's lockfile is part of that root's project: exclude it with `ignorePackages`, not paths. - A hosted/vendored PATH that resolves outside the repository root is a usage error (exit 2): one policy per invocation. **Lookup.** The repo root is the nearest ancestor of `--cwd` (inclusive) holding `.git` (a directory, a file for worktrees and submodules, or a symlink to either), not walking past a `GIT_CEILING_DIRECTORIES` entry or into the home directory (unless `--cwd` is it), and, on Unix, only when `.git` belongs to the current user, to root, or to the user `sudo` ran for (`SUDO_UID`); a root process trusts every owner, since CI containers commonly run as root over a checkout owned by another uid (otherwise warning `socket_yml_repo_untrusted` and `--cwd` is the root). No `.git`: the root is `--cwd`. Only `/socket.yml` and `/socket.yaml` are read, matched by exact directory-entry name (`Socket.yml` is not read: warning `socket_yml_name_case`); nested files never are. A symlinked file is followed only to a regular file inside the repo root. `--global` / `--global-prefix` scans read no file. +**In memory (two-phase).** The host fetches the tree's root `socket.yml` / `socket.yaml` first and passes each to `selectHostedScanPaths` as `policyFiles: [{path, text} | {path, missing: true}]` (and `noSocketYml` when the session will bypass it). Selection applies the full path policy (defaults, `projectIgnorePaths`, `patches` lists, negations), so a negated default-ignored root is fetched and patched as on disk; an excluded root's markers come back in `presentOnly` so the session can report it. A listed policy file not passed, passed `missing`, symlinked or invalid makes selection return `policyError` with nothing selected. Selection returns `policyPaths` and `policySha256` (`null` without a file); the host streams the same text and passes both to the session, which fails `socket_yml_invalid` when the policy it reads differs from `policySha256`. + **Validation (fail closed).** Because the file only narrows, a file that cannot be honored never means "no policy". Checked in order: file access (a regular file after resolving, at most 64 KiB, read from the opened handle), encoding (UTF-8; a BOM is stripped and CRLF is fine; UTF-16 and NUL bytes are errors), YAML 1.2 syntax (duplicate keys, a non-mapping top level, nesting deeper than 32 and a second document are errors), a top-level key that looks like a misspelled `patches` (equal to `patch`/`patches` ignoring case, or within two edits of it and starting `pat`/`pac`, e.g. `patchs`), a top-level merge key (`<<`) or aliased key (either could carry a `patches` block other YAML readers apply), the version gate (`patches` requires `version: 2`), then the keys. Inside `patches` and `projectIgnorePaths`, anchors, aliases, merge keys (`<<`) and custom tags are errors (aliases elsewhere are never expanded). An unknown key under `patches` is an error with a did-you-mean hint and "a newer socket-patch may support it". Wrong types are errors — no coercion (`"false"` is not a bool; YAML 1.2, so `no` is a string) — as are an unknown severity, an out-of-range `maxNewPatches`, an invalid pattern or spec, a list over 1000 entries and an entry over 1024 bytes. Every error names the file and the key path (`patches.minSeverity`). `projectIgnorePaths` is validated strictly when a `patches` block exists (a single string is coerced to a one-element list); without one, a malformed value only warns `socket_yml_ignored_value` and is ignored, and it is honored whatever the `version`. An empty or comment-only file counts as no file. When both `socket.yml` and `socket.yaml` exist, both are validated; if their `projectIgnorePaths` and `patches` are equal as parsed values `socket.yml` is used, otherwise the run fails with `socket_yml_ambiguous`. **Error output.** Before any request or write, `scan` exits **1** with scan's error object plus an additive `errorCode` (`socket_yml_invalid` or `socket_yml_ambiguous`): `{"status": "error", "error": "socket.yml: patches.minSeverty: unknown key … (fix the file, or pass --no-socket-yml to ignore it)", "errorCode": "socket_yml_invalid", …}` with every count at zero; no `policy` block. Human output: `Error (socket_yml_invalid): …` on stderr. The in-memory engine reports `policyError: {code, detail}` (the detail without the CLI remedy) with no root processed and no file changed. diff --git a/crates/socket-patch-cli/src/commands/hosted_bundle.rs b/crates/socket-patch-cli/src/commands/hosted_bundle.rs index b1950f5c..277bc104 100644 --- a/crates/socket-patch-cli/src/commands/hosted_bundle.rs +++ b/crates/socket-patch-cli/src/commands/hosted_bundle.rs @@ -60,6 +60,8 @@ struct Bundle { min_severity: Option, #[serde(default)] policy_paths: Option>, + #[serde(default)] + policy_sha256: Option, } fn print_error(code: &str, message: &str) { @@ -131,6 +133,7 @@ pub async fn run(args: HostedBundleArgs) -> i32 { no_socket_yml: bundle.no_socket_yml, min_severity: bundle.min_severity.clone(), policy_paths: bundle.policy_paths.clone(), + policy_sha256: bundle.policy_sha256.clone(), ..HostedScanOptions::default() }; let input = match build_input(bundle, options) { diff --git a/crates/socket-patch-cli/src/hosted_memory/limits.rs b/crates/socket-patch-cli/src/hosted_memory/limits.rs index aebe45f4..43878724 100644 --- a/crates/socket-patch-cli/src/hosted_memory/limits.rs +++ b/crates/socket-patch-cli/src/hosted_memory/limits.rs @@ -29,6 +29,7 @@ pub(crate) struct ResolvedOptions { pub(crate) limits: ResolvedLimits, pub(crate) policy_overrides: socket_patch_core::policy::PolicyOverrides, pub(crate) policy_paths: Vec, + pub(crate) policy_sha256: Option, } pub(crate) fn resolve_options(options: &HostedScanOptions) -> Result { @@ -129,6 +130,7 @@ pub(crate) fn resolve_options(options: &HostedScanOptions) -> Result( paths: impl Iterator, roots: &[String], ecosystems: Option<&[String]>, + policy: &SelectionPolicy, out: &mut Vec, ) { let root_set: BTreeSet<&str> = roots.iter().map(String::as_str).collect(); @@ -336,7 +337,13 @@ fn unrooted_unsupported_warnings<'a>( || dir .split('/') .any(|seg| roots::EXCLUDED_ROOT_SEGMENTS.contains(&seg)) - || roots::default_ignored_dir(dir) + || policy + .admits_root(&Root { + rel_dir: dir, + markers: &[base.to_string()], + explicit: false, + }) + .is_err() { continue; } @@ -390,6 +397,22 @@ async fn engine( return Ok(policy_error_output(&error, warnings, files_input, bytes_input)); } }; + // Path selection chose which files to send by the policy it read; a + // different policy here would judge roots it never fetched. + let read = match policy.source() { + PolicySource::File { path, sha256 } => Some((path.as_str(), sha256.as_str())), + PolicySource::None | PolicySource::Bypassed => None, + }; + if !options.policy_overrides.bypass && read.map(|(_, sha)| sha) != options.policy_sha256.as_deref() { + let error = PolicyError::Invalid { + file: read.map_or(POLICY_FILE_NAMES[0], |(path, _)| path).to_string(), + key: String::new(), + message: "the policy content differs from the one path selection read: pass \ + selectHostedScanPaths' policySha256 and stream the same text" + .to_string(), + }; + return Ok(policy_error_output(&error, warnings, files_input, bytes_input)); + } for w in policy_warnings { warnings.push(EngineWarning::new(w.code, w.detail, None)); } @@ -404,7 +427,7 @@ async fn engine( let root_list: Vec = match &options.project_roots { Some(roots) => roots.clone(), - None => roots::detect_roots_with(files.keys().map(String::as_str), ecosystems, false).0, + None => roots::detect_roots(files.keys().map(String::as_str), ecosystems).0, }; // The full policy (paths from the file too) judges every root before // the project limit; roots named in `projectRoots` are explicit. @@ -447,6 +470,7 @@ async fn engine( files.keys().map(String::as_str), &detected_roots, ecosystems, + &policy, &mut warnings, ); let mut states: Vec = root_list @@ -1229,7 +1253,13 @@ mod tests { "src/Main.java", ]; let mut out = Vec::new(); - unrooted_unsupported_warnings(paths.into_iter(), &["web".to_string()], None, &mut out); + unrooted_unsupported_warnings( + paths.into_iter(), + &["web".to_string()], + None, + socket_patch_core::policy::builtin_defaults(), + &mut out, + ); assert_eq!(out.len(), 2); assert!(out .iter() @@ -1249,6 +1279,7 @@ mod tests { paths.into_iter(), &[], Some(&["npm".to_string()]), + socket_patch_core::policy::builtin_defaults(), &mut filtered, ); assert!(filtered.is_empty()); diff --git a/crates/socket-patch-cli/src/hosted_memory/roots.rs b/crates/socket-patch-cli/src/hosted_memory/roots.rs index 02873cf6..6bc569ac 100644 --- a/crates/socket-patch-cli/src/hosted_memory/roots.rs +++ b/crates/socket-patch-cli/src/hosted_memory/roots.rs @@ -54,22 +54,9 @@ pub(crate) const UNSUPPORTED_MARKERS: [(&str, &[&str]); 2] = [ /// Directory names whose subtrees never hold a project root: installed /// trees, VCS and tool state, and vendored dependencies. Structural, so no /// policy can negate them. (Test and fixture trees are the socket.yml -/// policy's overridable built-in ignores: [`default_ignored_dir`].) +/// policy's overridable built-in ignores.) pub(crate) const EXCLUDED_ROOT_SEGMENTS: [&str; 5] = ["node_modules", ".git", ".socket", ".yarn", "vendor"]; -/// Whether `dir` (repo-relative) is under a built-in default ignore of the -/// socket.yml policy (`test/`, `tests/`, `fixtures/`, …, any case). -pub(crate) fn default_ignored_dir(dir: &str) -> bool { - !dir.is_empty() - && socket_patch_core::policy::builtin_defaults() - .admits_root(&socket_patch_core::policy::Root { - rel_dir: dir, - markers: &[], - explicit: false, - }) - .is_err() -} - /// The marker basenames of `root` among `paths` (the files the policy's /// path filters test for that root). pub(crate) fn root_markers<'a>(root: &str, paths: impl IntoIterator) -> Vec { @@ -124,23 +111,12 @@ fn allowed(ecosystems: Option<&[String]>, eco: &str) -> bool { ecosystems.is_none_or(|list| list.iter().any(|e| e == eco)) } -/// The detected roots (sorted) and the marker paths that did not make one, -/// with the policy's built-in default ignores applied (path selection, -/// which cannot see socket.yml's content). +/// The detected roots (sorted) and the marker paths that did not make one. +/// The socket.yml path policy (built-in default ignores included) is the +/// caller's to apply. pub(crate) fn detect_roots<'a>( paths: impl IntoIterator, ecosystems: Option<&[String]>, -) -> (Vec, Vec) { - detect_roots_with(paths, ecosystems, true) -} - -/// [`detect_roots`]; `apply_defaults: false` leaves the built-in default -/// ignores to the caller (the session applies the full policy, whose -/// negations can re-include a default-ignored root). -pub(crate) fn detect_roots_with<'a>( - paths: impl IntoIterator, - ecosystems: Option<&[String]>, - apply_defaults: bool, ) -> (Vec, Vec) { let mut ignored: Vec = Vec::new(); let mut markers: BTreeMap> = BTreeMap::new(); @@ -185,20 +161,6 @@ pub(crate) fn detect_roots_with<'a>( .collect(); let mut roots: Vec = Vec::new(); for dir in markers.keys() { - let marker_names: Vec = marker_paths - .get(dir) - .into_iter() - .flatten() - .map(|p| split_path(p).1.to_string()) - .collect(); - let default_ignored = apply_defaults - && socket_patch_core::policy::builtin_defaults() - .admits_root(&socket_patch_core::policy::Root { - rel_dir: dir, - markers: &marker_names, - explicit: false, - }) - .is_err(); let rush_internal = rush_roots.iter().any(|r| { let internal = |sub: &str| join_root(r, sub); *dir == internal("common/config/rush") @@ -206,23 +168,15 @@ pub(crate) fn detect_roots_with<'a>( || *dir == internal("common/temp") || dir.starts_with(&format!("{}/", internal("common/temp"))) }); - let reason = if default_ignored { - Some("policy_path_excluded") - } else if rush_internal { - Some("rush_internal") - } else { - None - }; - match reason { - Some(reason) => { - for path in marker_paths.get(dir).into_iter().flatten() { - ignored.push(IgnoredPath { - path: path.clone(), - reason: reason.to_string(), - }); - } + if rush_internal { + for path in marker_paths.get(dir).into_iter().flatten() { + ignored.push(IgnoredPath { + path: path.clone(), + reason: "rush_internal".to_string(), + }); } - None => roots.push(dir.clone()), + } else { + roots.push(dir.clone()); } } roots.sort(); @@ -265,26 +219,14 @@ mod tests { ], None, ); - assert_eq!(found, vec!["docs"]); - assert_eq!(ignored.len(), 4); - let reason = |path: &str| ignored.iter().find(|i| i.path == path).unwrap().reason.clone(); - assert_eq!(reason("node_modules/x/package-lock.json"), "excluded_dir"); - assert_eq!(reason("a/vendor/b/composer.lock"), "excluded_dir"); - assert_eq!(reason(".socket/vendor/npm/package-lock.json"), "excluded_dir"); - // Test/fixture trees are the policy's overridable built-in ignores. - assert_eq!(reason("test/fixtures/yarn.lock"), "policy_path_excluded"); + // Test and fixture trees are left to the socket.yml path policy. + assert_eq!(found, vec!["docs", "test/fixtures"]); + assert_eq!(ignored.len(), 3); + assert!(ignored.iter().all(|i| i.reason == "excluded_dir")); } #[test] - fn default_ignores_are_case_insensitive_and_marker_based() { - let (found, _) = detect_roots( - ["Tests/app/yarn.lock", "e2e/testdata/go.mod", "apps/testing/package-lock.json"], - None, - ); - assert_eq!(found, vec!["apps/testing"]); - assert!(default_ignored_dir("a/__fixtures__")); - assert!(!default_ignored_dir("")); - assert!(!default_ignored_dir("apps/testing")); + fn root_markers_name_every_marker_of_the_root_only() { assert_eq!( root_markers("a", ["a/yarn.lock", "a/package.json", "a/b/yarn.lock", "a/pom.xml"]), vec!["pom.xml".to_string(), "yarn.lock".to_string()] diff --git a/crates/socket-patch-cli/src/hosted_memory/select.rs b/crates/socket-patch-cli/src/hosted_memory/select.rs index cae005f6..b7c8aa6e 100644 --- a/crates/socket-patch-cli/src/hosted_memory/select.rs +++ b/crates/socket-patch-cli/src/hosted_memory/select.rs @@ -14,10 +14,18 @@ use socket_patch_core::constants::npm_family::{ use socket_patch_core::patch::redirect::npmrc::NPMRC_REL; use socket_patch_core::utils::python_lock::is_python_lock_name; +use socket_patch_core::policy::{ + MemoryPolicyFs, PolicyOverrides, PolicySource, Root, RootFile, SelectionPolicy, POLICY_FILE_NAMES, + SOCKET_YML_INVALID, +}; + use super::roots::{ - detect_roots, split_path, strip_root, EXCLUDED_ROOT_SEGMENTS, UNSUPPORTED_MARKERS, + detect_roots, join_root, root_markers, split_path, strip_root, EXCLUDED_ROOT_SEGMENTS, + UNSUPPORTED_MARKERS, +}; +use super::types::{ + IgnoredPath, PathSelection, PolicyErrorInfo, PolicyFileInput, SelectOptions, TreeEntryInput, }; -use super::types::{IgnoredPath, PathSelection, SelectOptions, TreeEntryInput}; use crate::commands::scan::hosted::{PNPM_WORKSPACE_REL, REDIRECT_CANDIDATE_FILES}; /// Most entries [`PathSelection::ignored_sample`] carries. @@ -162,9 +170,60 @@ fn classify(rel: &str, root_files: &BTreeSet<&str>) -> Option { None } +/// The listed root policy files with the text the caller fetched first. A +/// listed file with no text (not passed, `missing`, or a symlink) is present +/// without content, so loading it fails closed. +fn selection_policy_fs(blobs: &BTreeMap, supplied: &[PolicyFileInput]) -> MemoryPolicyFs { + let mut fs = MemoryPolicyFs::default(); + for name in POLICY_FILE_NAMES { + let Some(&symlink) = blobs.get(name) else { + continue; + }; + let text = supplied + .iter() + .find(|f| f.path == name && !f.missing.unwrap_or(false)) + .and_then(|f| f.text.as_ref()); + let file = match text { + Some(text) if !symlink => RootFile::Present(text.as_bytes().to_vec()), + _ => RootFile::PresentWithoutContent, + }; + fs.files.insert(name.to_string(), file); + fs.root_names.push(name.to_string()); + } + fs +} + +/// The policy path selection applies, or why it cannot be honored. +fn selection_policy( + blobs: &BTreeMap, + options: &SelectOptions, +) -> Result { + let supplied = options.policy_files.as_deref().unwrap_or_default(); + if let Some(bad) = supplied.iter().find(|f| !POLICY_FILE_NAMES.contains(&f.path.as_str())) { + return Err(PolicyErrorInfo { + code: SOCKET_YML_INVALID.to_string(), + detail: format!( + "policyFiles entry `{}` is not a root socket.yml or socket.yaml", + socket_patch_core::policy::sanitize(&bad.path) + ), + }); + } + let overrides = PolicyOverrides { + bypass: options.no_socket_yml.unwrap_or(false), + min_severity: None, + }; + SelectionPolicy::load(&selection_policy_fs(blobs, supplied), &overrides) + .map(|(policy, _)| policy) + .map_err(|e| PolicyErrorInfo { + code: e.code().to_string(), + detail: e.detail(), + }) +} + /// `selectHostedScanPaths`: roots (detected, or `options.projectRoots`) -/// plus the files to stream for them. Only `blob` entries are files; mode -/// `120000` is a symbolic link and is reported, never fetched. +/// that the repo's socket.yml path policy admits, plus the files to stream +/// for them. Only `blob` entries are files; mode `120000` is a symbolic link +/// and is reported, never fetched. pub fn select_paths(entries: &[TreeEntryInput], options: &SelectOptions) -> PathSelection { let mut ignored: Vec = Vec::new(); let mut blobs: BTreeMap = BTreeMap::new(); @@ -183,7 +242,27 @@ pub fn select_paths(entries: &[TreeEntryInput], options: &SelectOptions) -> Path } } - let roots: Vec = match &options.project_roots { + let policy_paths: Vec = POLICY_FILE_NAMES + .iter() + .filter(|name| blobs.contains_key(**name)) + .map(|name| name.to_string()) + .collect(); + let policy = match selection_policy(&blobs, options) { + Ok(policy) => policy, + Err(error) => { + return PathSelection { + policy_paths, + policy_error: Some(error), + ..PathSelection::default() + } + } + }; + let policy_sha256 = match policy.source() { + PolicySource::File { sha256, .. } => Some(sha256.clone()), + PolicySource::None | PolicySource::Bypassed => None, + }; + + let candidate_roots: Vec = match &options.project_roots { Some(requested) => { let mut out: BTreeSet = BTreeSet::new(); for root in requested { @@ -208,6 +287,28 @@ pub fn select_paths(entries: &[TreeEntryInput], options: &SelectOptions) -> Path found } }; + // The same root filter the session applies, so a socket.yml negation + // of a built-in ignore brings that tree's files in here too. An + // excluded root's markers stay presence-only: the session sees the root + // and reports it filtered, as disk does, without its content. + let explicit = options.project_roots.is_some(); + let mut roots: Vec = Vec::with_capacity(candidate_roots.len()); + let mut excluded_markers: Vec = Vec::new(); + for root in candidate_roots { + let markers = root_markers(&root, blobs.keys().map(String::as_str)); + let admitted = policy + .admits_root(&Root { + rel_dir: &root, + markers: &markers, + explicit, + }) + .is_ok(); + if admitted { + roots.push(root); + } else { + excluded_markers.extend(markers.iter().map(|m| join_root(&root, m))); + } + } let root_set: BTreeSet<&str> = roots.iter().map(String::as_str).collect(); let mut per_root: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new(); @@ -235,7 +336,7 @@ pub fn select_paths(entries: &[TreeEntryInput], options: &SelectOptions) -> Path let Some(need) = classify(rel, files) else { continue; }; - let full = super::roots::join_root(root, rel); + let full = join_root(root, rel); let slot = needs.entry(full).or_insert(need); *slot = (*slot).min(need); } @@ -255,26 +356,31 @@ pub fn select_paths(entries: &[TreeEntryInput], options: &SelectOptions) -> Path && !dir .split('/') .any(|seg| EXCLUDED_ROOT_SEGMENTS.contains(&seg)) - && !super::roots::default_ignored_dir(dir) + && policy + .admits_root(&Root { + rel_dir: dir, + markers: &[base.to_string()], + explicit: false, + }) + .is_ok() }); if let Some(path) = first { needs.entry(path.clone()).or_insert(Need::Present); } } - // The repo-root policy files: always streamed when listed (a symlinked - // one lands in `symlinks`, and the session then fails closed on it). - let mut policy_paths: Vec = Vec::new(); - for name in socket_patch_core::policy::POLICY_FILE_NAMES { - if blobs.contains_key(name) { - needs.entry(name.to_string()).or_insert(Need::Text); - policy_paths.push(name.to_string()); - } + for path in excluded_markers { + needs.entry(path).or_insert(Need::Present); + } + // The session reads the same policy text again. + for name in &policy_paths { + needs.entry(name.clone()).or_insert(Need::Text); } let mut selection = PathSelection { roots, policy_paths, + policy_sha256, ..PathSelection::default() }; for (path, need) in needs { @@ -437,6 +543,7 @@ mod tests { &SelectOptions { project_roots: None, ecosystems: Some(vec!["npm".into()]), + ..SelectOptions::default() }, ); assert!(s.present_only.is_empty()); @@ -450,6 +557,7 @@ mod tests { &SelectOptions { project_roots: Some(vec!["b/".into(), "../x".into()]), ecosystems: None, + ..SelectOptions::default() }, ); assert_eq!(s.roots, vec!["b"]); diff --git a/crates/socket-patch-cli/src/hosted_memory/types.rs b/crates/socket-patch-cli/src/hosted_memory/types.rs index 04b07971..4e997a01 100644 --- a/crates/socket-patch-cli/src/hosted_memory/types.rs +++ b/crates/socket-patch-cli/src/hosted_memory/types.rs @@ -111,6 +111,10 @@ pub struct HostedScanOptions { /// content, or the session fails with `policyError`. #[serde(default, skip_serializing_if = "Option::is_none")] pub policy_paths: Option>, + /// The `policySha256` path selection returned: the session fails with + /// `policyError` when the policy it reads differs. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy_sha256: Option, } pub const DEFAULT_BATCH_SIZE: u32 = 100; @@ -328,6 +332,24 @@ pub struct SelectOptions { pub project_roots: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub ecosystems: Option>, + /// One entry per root socket.yml / socket.yaml the listing holds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy_files: Option>, + /// Must match the session's `noSocketYml`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub no_socket_yml: Option, +} + +/// `SelectOptions.policyFiles` entry: the root policy file's text, or +/// `missing: true` when the host could not fetch it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PolicyFileInput { + pub path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub text: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub missing: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -353,6 +375,13 @@ pub struct PathSelection { /// the session's `policyPaths`). #[serde(default)] pub policy_paths: Vec, + /// The policy file the selection applied (`null` without one): pass it + /// back as the session's `policySha256`. + #[serde(default)] + pub policy_sha256: Option, + /// A socket.yml that cannot be honored: nothing is selected. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy_error: Option, } /// Engine failure (`finish()` rejection codes). diff --git a/crates/socket-patch-cli/tests/hosted_memory_parity.rs b/crates/socket-patch-cli/tests/hosted_memory_parity.rs index b3027ffa..7c151db2 100644 --- a/crates/socket-patch-cli/tests/hosted_memory_parity.rs +++ b/crates/socket-patch-cli/tests/hosted_memory_parity.rs @@ -763,9 +763,70 @@ fn policy_repo(socket_yml: &str) -> (Vec, BTreeMap>) { (patches, repo) } -fn policy_options() -> socket_patch_cli::hosted_memory::HostedScanOptions { +/// The host's two-phase flow: fetch the root policy files, select with +/// their text, stream what selection asks for (presence-only paths marked +/// present) and pass selection's policy outputs to the session. +fn two_phase( + files: &BTreeMap>, + mut opts: socket_patch_cli::hosted_memory::HostedScanOptions, +) -> ( + socket_patch_cli::hosted_memory::PathSelection, + socket_patch_cli::hosted_memory::HostedScanInput, +) { + use socket_patch_cli::hosted_memory::{select_paths, PolicyFileInput, SelectOptions, TreeEntryInput}; + let entries: Vec = files + .iter() + .map(|(p, bytes)| TreeEntryInput { + path: p.clone(), + mode: "100644".into(), + kind: "blob".into(), + size: Some(bytes.len() as u64), + }) + .collect(); + let policy_files: Vec = ["socket.yml", "socket.yaml"] + .iter() + .filter_map(|name| { + files.get(*name).map(|bytes| PolicyFileInput { + path: name.to_string(), + text: Some(String::from_utf8(bytes.clone()).unwrap()), + missing: None, + }) + }) + .collect(); + let selection = select_paths( + &entries, + &SelectOptions { + policy_files: Some(policy_files), + no_socket_yml: opts.no_socket_yml, + ..SelectOptions::default() + }, + ); + let fetched: BTreeMap> = selection + .fetch_text + .iter() + .chain(selection.fetch_binary.iter()) + .map(|p| (p.clone(), files[p].clone())) + .collect(); + let present: Vec<&str> = selection.present_only.iter().map(String::as_str).collect(); + opts.policy_paths = Some(selection.policy_paths.clone()); + opts.policy_sha256 = selection.policy_sha256.clone(); + let input = build_input(&fetched, &present, opts); + (selection, input) +} + +fn policy_input(files: &BTreeMap>) -> socket_patch_cli::hosted_memory::HostedScanInput { + let (selection, input) = two_phase(files, options(false)); + assert!(selection.policy_error.is_none(), "{:?}", selection.policy_error); + input +} + +/// Session options as selection of `files` would hand them over, without +/// going through selection (for inputs a host may get wrong). +fn policy_options(files: &BTreeMap>) -> socket_patch_cli::hosted_memory::HostedScanOptions { + let (selection, _) = two_phase(files, options(false)); let mut opts = options(false); - opts.policy_paths = Some(vec!["socket.yml".to_string()]); + opts.policy_paths = Some(selection.policy_paths); + opts.policy_sha256 = selection.policy_sha256; opts } @@ -792,7 +853,7 @@ async fn parity_socket_yml_filters_the_same_roots_and_packages() { ); let server = MockServer::start().await; mount_api(&server, &patches).await; - let memory = run_engine(&server, build_input(&repo, &[], policy_options())).await; + let memory = run_engine(&server, policy_input(&repo)).await; assert!(memory.policy_error.is_none(), "{:?}", memory.policy_error); let roots: Vec<&str> = memory.projects.iter().map(|p| p.root.as_str()).collect(); assert_eq!(roots, vec!["apps/web", "services/api"], "the ignored root is not processed"); @@ -831,7 +892,7 @@ async fn parity_socket_yml_severity_floor() { let (patches, repo) = policy_repo("version: 2\npatches:\n minSeverity: critical\n"); let server = MockServer::start().await; mount_api(&server, &patches).await; - let memory = run_engine(&server, build_input(&repo, &[], policy_options())).await; + let memory = run_engine(&server, policy_input(&repo)).await; let web = memory.projects.iter().find(|p| p.root == "apps/web").unwrap(); assert!(web.redirected.is_empty(), "{:#}", web.redirect); assert!(web.skipped.iter().any(|s| s.reason == "policy_severity"), "{:?}", web.skipped); @@ -851,26 +912,44 @@ async fn memory_policy_file_withheld_or_invalid_is_a_policy_error() { let (patches, repo) = policy_repo("version: 2\npatches:\n maxNewPatches: 1\n"); let server = MockServer::start().await; mount_api(&server, &patches).await; + let opts = policy_options(&repo); // Listed by selection but never streamed. let mut withheld = repo.clone(); withheld.remove("socket.yml"); - let out = run_engine(&server, build_input(&withheld, &[], policy_options())).await; + let out = run_engine(&server, build_input(&withheld, &[], opts.clone())).await; let err = out.policy_error.expect("policyError"); assert_eq!(err.code, "socket_yml_invalid"); assert!(out.projects.is_empty() && out.changed_files.is_empty() && out.policy.is_none()); // Streamed present-without-content. - let out = run_engine(&server, build_input(&withheld, &["socket.yml"], policy_options())).await; + let out = run_engine(&server, build_input(&withheld, &["socket.yml"], opts.clone())).await; assert_eq!(out.policy_error.expect("policyError").code, "socket_yml_invalid"); - // Invalid content. + // Content other than what selection read. + let mut changed = repo.clone(); + changed.insert("socket.yml".to_string(), b"version: 2\n".to_vec()); + let out = run_engine(&server, build_input(&changed, &[], opts.clone())).await; + let err = out.policy_error.expect("policyError"); + assert!(err.detail.contains("differs"), "{}", err.detail); + // The file streamed without selection's policySha256. + let mut no_sha = opts.clone(); + no_sha.policy_sha256 = None; + let out = run_engine(&server, build_input(&repo, &[], no_sha)).await; + assert_eq!(out.policy_error.expect("policyError").code, "socket_yml_invalid"); + // Invalid content: selection refuses it before anything is fetched. let (_, bad) = policy_repo("version: 2\npatches:\n apiUrl: https://evil.example\n"); - let out = run_engine(&server, build_input(&bad, &[], policy_options())).await; + let (selection, _) = two_phase(&bad, options(false)); + let err = selection.policy_error.expect("selection policyError"); + assert!(err.detail.contains("patches.apiUrl"), "{}", err.detail); + assert!(selection.roots.is_empty() && selection.fetch_text.is_empty()); + let out = run_engine(&server, build_input(&bad, &[], opts.clone())).await; let err = out.policy_error.expect("policyError"); assert!(err.detail.contains("patches.apiUrl"), "{}", err.detail); assert!(out.changed_files.is_empty()); - // noSocketYml skips it. - let mut opts = policy_options(); - opts.no_socket_yml = Some(true); - let out = run_engine(&server, build_input(&bad, &[], opts)).await; + // noSocketYml skips it on both sides. + let mut bypass = options(false); + bypass.no_socket_yml = Some(true); + let (selection, input) = two_phase(&bad, bypass); + assert!(selection.policy_error.is_none() && selection.policy_sha256.is_none()); + let out = run_engine(&server, input).await; assert!(out.policy_error.is_none()); assert_eq!(out.policy.unwrap()["source"], "bypassed"); } @@ -880,56 +959,99 @@ async fn memory_min_severity_option_beats_the_file() { let (patches, repo) = policy_repo("version: 2\npatches:\n minSeverity: critical\n"); let server = MockServer::start().await; mount_api(&server, &patches).await; - let mut opts = policy_options(); + let mut opts = options(false); opts.min_severity = Some("none".to_string()); - let out = run_engine(&server, build_input(&repo, &[], opts)).await; + let (_, input) = two_phase(&repo, opts); + let out = run_engine(&server, input).await; let policy = out.policy.unwrap(); assert_eq!(policy["minSeverity"], serde_json::json!({"value": null, "source": "flag"})); assert!(out.projects.iter().any(|p| !p.redirected.is_empty())); - let mut bad = policy_options(); + let mut bad = options(false); bad.min_severity = Some("severe".to_string()); assert!(socket_patch_cli::hosted_memory::SessionBuilder::new(bad).is_err()); } #[test] -fn selection_streams_policy_files_and_applies_built_in_ignores() { - use socket_patch_cli::hosted_memory::{select_paths, SelectOptions, TreeEntryInput}; +fn selection_applies_the_path_policy_and_fails_closed() { + use socket_patch_cli::hosted_memory::{select_paths, PolicyFileInput, SelectOptions, TreeEntryInput}; let blob = |path: &str, mode: &str| TreeEntryInput { path: path.to_string(), mode: mode.to_string(), kind: "blob".into(), size: Some(1), }; - let entries = vec![ + let text = |path: &str, text: &str| PolicyFileInput { + path: path.to_string(), + text: Some(text.to_string()), + missing: None, + }; + let mut entries = vec![ blob("socket.yml", "100644"), - blob("socket.yaml", "120000"), blob("Socket.yml", "100644"), blob("apps/web/package-lock.json", "100644"), blob("apps/web/tests/app/package-lock.json", "100644"), blob("Fixtures/x/yarn.lock", "100644"), + blob("apps/old/yarn.lock", "100644"), ]; - let selection = select_paths(&entries, &SelectOptions::default()); - assert_eq!(selection.policy_paths, vec!["socket.yml", "socket.yaml"]); + let with = |files: Vec| SelectOptions { + policy_files: Some(files), + ..SelectOptions::default() + }; + let yml = "version: 2\npatches:\n ignorePaths: [\"/apps/old/\"]\n"; + let selection = select_paths(&entries, &with(vec![text("socket.yml", yml)])); + assert!(selection.policy_error.is_none(), "{:?}", selection.policy_error); + assert_eq!(selection.policy_paths, vec!["socket.yml"]); + assert_eq!(selection.policy_sha256.as_ref().map(String::len), Some(64)); assert!(selection.fetch_text.contains(&"socket.yml".to_string())); - assert!(selection.symlinks.contains(&"socket.yaml".to_string())); assert_eq!(selection.roots, vec!["apps/web"]); - assert!(selection - .ignored_sample - .iter() - .any(|i| i.path == "apps/web/tests/app/package-lock.json" && i.reason == "policy_path_excluded")); + // Excluded roots (file list and built-in ignores, any case) are + // presence-only: never fetched, still reported by the session. + for path in ["apps/old/yarn.lock", "apps/web/tests/app/package-lock.json", "Fixtures/x/yarn.lock"] { + assert!(selection.present_only.contains(&path.to_string()), "{path}: {selection:?}"); + assert!(!selection.fetch_text.contains(&path.to_string()), "{path}"); + } // Named roots are explicit: the built-in ignores do not apply. let named = select_paths( &entries, &SelectOptions { project_roots: Some(vec!["apps/web/tests/app".to_string()]), - ..SelectOptions::default() + ..with(vec![text("socket.yml", yml)]) }, ); assert_eq!(named.roots, vec!["apps/web/tests/app"]); + // A listed policy file with no text, `missing`, or invalid text fails + // closed: nothing is selected. + let missing = PolicyFileInput { + path: "socket.yml".to_string(), + text: None, + missing: Some(true), + }; + for files in [vec![], vec![missing], vec![text("socket.yml", "version: 2\npatches:\n apiUrl: x\n")]] { + let out = select_paths(&entries, &with(files)); + assert_eq!(out.policy_error.as_ref().map(|e| e.code.as_str()), Some("socket_yml_invalid")); + assert!(out.roots.is_empty() && out.fetch_text.is_empty(), "{out:?}"); + assert_eq!(out.policy_paths, vec!["socket.yml"]); + } + let out = select_paths(&entries, &with(vec![text("nested/socket.yml", yml)])); + assert!(out.policy_error.is_some()); + // A symlinked policy file is never read. + entries.push(blob("socket.yaml", "120000")); + let out = select_paths(&entries, &with(vec![text("socket.yml", yml), text("socket.yaml", yml)])); + assert_eq!(out.policy_error.map(|e| e.code), Some("socket_yml_invalid".to_string())); + // noSocketYml: only the built-in ignores; the file need not be passed. + let out = select_paths( + &entries, + &SelectOptions { + no_socket_yml: Some(true), + ..SelectOptions::default() + }, + ); + assert!(out.policy_error.is_none() && out.policy_sha256.is_none()); + assert_eq!(out.roots, vec!["apps/old", "apps/web"]); } #[tokio::test] -async fn memory_negation_reincludes_a_default_ignored_root_it_was_given() { +async fn memory_negation_reincludes_a_default_ignored_root() { let npm = fixtures_root().join("redirect/npm/package-lock-v3/basic"); let patches = patches_from_overrides(&npm.join("overrides.json"), None); let server = MockServer::start().await; @@ -944,11 +1066,23 @@ async fn memory_negation_reincludes_a_default_ignored_root_it_was_given() { "socket.yml".to_string(), b"version: 2\npatches:\n ignorePaths: [\"!/e2e/tests/\"]\n".to_vec(), ); - let memory = run_engine(&server, build_input(&repo, &[], policy_options())).await; + let (selection, input) = two_phase(&repo, options(false)); + assert_eq!(selection.roots, vec!["e2e/tests"]); + assert!(selection.fetch_text.contains(&"e2e/tests/package-lock.json".to_string())); + assert!(!selection.fetch_text.iter().any(|p| p.starts_with("x/")), "{selection:?}"); + let memory = run_engine(&server, input).await; let roots: Vec<&str> = memory.projects.iter().map(|p| p.root.as_str()).collect(); assert_eq!(roots, vec!["e2e/tests"]); + assert!(!memory.projects[0].redirected.is_empty(), "{:#}", memory.projects[0].redirect); let filtered = filtered_set(memory.policy.as_ref().unwrap()); assert!(filtered.contains(&("x/tests".to_string(), None, "policy_path_excluded".to_string()))); let entry = &memory.policy.as_ref().unwrap()["filtered"][0]; assert_eq!(entry["detail"], "tests/ (built-in default)"); + + // Disk patches the same root the same way. + let disk = run_disk_in(&server, &repo, "e2e/tests", false); + assert_eq!(disk.envelope["status"], "success", "{}", disk.stderr); + assert_eq!(memory.projects[0].redirect, disk.envelope["redirect"]); + let memory_changed = engine_changed(&memory); + assert_eq!(memory_changed, disk.changed, "{}", describe(&memory_changed)); } diff --git a/crates/socket-patch-node/npm/index.d.ts b/crates/socket-patch-node/npm/index.d.ts index e12e8d8c..4a1a1890 100644 --- a/crates/socket-patch-node/npm/index.d.ts +++ b/crates/socket-patch-node/npm/index.d.ts @@ -10,8 +10,12 @@ export interface PathSelection { ignoredCount: number ignoredSample: { path: string; reason: string }[] // ≤100 policyPaths: string[] // root socket.yml / socket.yaml the tree lists (also in fetchText or symlinks); pass back as the session's policyPaths + policySha256: string | null // the policy file selection applied; pass back as the session's policySha256 + policyError?: { code: 'socket_yml_invalid' | 'socket_yml_ambiguous'; detail: string } // nothing selected } -export function selectHostedScanPaths(entries: TreeEntryInput[], options?: { projectRoots?: string[]; ecosystems?: Ecosystem[] }): PathSelection +// Fetch every root socket.yml / socket.yaml the listing holds first and pass it in policyFiles: selection applies the full socket.yml path policy. +export type PolicyFileInput = { path: string; text: string } | { path: string; missing: true } +export function selectHostedScanPaths(entries: TreeEntryInput[], options?: { projectRoots?: string[]; ecosystems?: Ecosystem[]; policyFiles?: PolicyFileInput[]; noSocketYml?: boolean }): PathSelection export function hostedScanCandidateFiles(): string[] // debug listing only export function engineVersion(): string // "+" @@ -52,6 +56,7 @@ export interface HostedScanSessionOptions { noSocketYml?: boolean // ignore the repo's socket.yml (built-in test/fixture ignores still apply); default false minSeverity?: 'critical' | 'high' | 'medium' | 'moderate' | 'low' | 'none' // beats socket.yml patches.minSeverity policyPaths?: string[] // selectHostedScanPaths' policyPaths; each must be streamed with content or the session returns policyError + policySha256?: string // selectHostedScanPaths' policySha256; a policy file that differs (or arrives without it) is a policyError } export class HostedScanSession { constructor(options: HostedScanSessionOptions, provider: PatchProvider) diff --git a/docs/design/staged-rollout.md b/docs/design/staged-rollout.md index d5bc86a5..fff254a9 100644 --- a/docs/design/staged-rollout.md +++ b/docs/design/staged-rollout.md @@ -1057,19 +1057,21 @@ Gaps and contradictions A resolved with the smallest reasonable decision 8. **In-memory engine gaps until B lands its recorded view**: `retained[]` is empty and the floor rule of item 5 cannot see recorded pins (filtered packages' pins stay byte-identical regardless: the rewriters only touch - selected dependencies). `selectHostedScanPaths` applies the built-in - default ignores (the file's content is unknown at selection time), so a - socket.yml negation re-includes an in-memory root only when the host - streamed its files anyway; the session itself detects roots without the - defaults and applies the full policy. A root named in `projectRoots` is - explicit and skips the defaults. There - is no case-variant warning in memory (selection streams exact names - only). `ProjectResult.skipped[]` carries the post-lookup policy reasons + selected dependencies). A root named in `projectRoots` is explicit and + skips the defaults. There is no case-variant warning in memory + (selection reads exact names only). `ProjectResult.skipped[]` carries the post-lookup policy reasons (severity, disabled); the pre-lookup ones are in the session `policy` block only. 9. **Session option `policyPaths`** (selection's list, handed back like `projectRoots`) is how the session tells "listed but never sent" from - absent. + absent. Two-phase selection (7.2) names its outputs: selection returns + `policySha256` (`null` without a file) and the session takes it as an + option, since 7.2 does not say how the session learns what the + selector saw; a session that reads a policy file without it fails + closed. Selection also takes `noSocketYml` so both sides bypass + together. A root the selector excludes keeps its markers in + `presentOnly` (no content), so the session still lists it under + `policy.filtered[]` as disk does. 10. **Env layer of `--min-severity`** is read by scan, not clap, so the `policy` block can say `source: "env"`; a malformed env value exits 2 at run time, a malformed flag at parse time. From ed6f51c90ea6692fef4422368843e3d0942f86e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 16:55:35 +0000 Subject: [PATCH 19/22] Keep excluded roots out of the memory stream Review follow-ups to two-phase selection: - Roots the policy excludes are reported in ignoredSample instead of streamed presence-only, so a repo with many fixture lockfiles no longer runs into the session's file limit. - A session that bypasses socket.yml while selection applied it now fails closed instead of processing roots it never fetched. - The docs say policy text must decode losslessly (TextDecoder drops a BOM) and when policySha256 is null. Claude-Session: https://claude.ai/code/session_01BKsyzefGhAnPkYmXCwq3H3 Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/CLI_CONTRACT.md | 4 +- .../socket-patch-cli/src/hosted_memory/mod.rs | 5 ++- .../src/hosted_memory/select.rs | 38 ++++++++-------- .../tests/hosted_memory_parity.rs | 43 +++++++++++++++---- crates/socket-patch-node/npm/index.d.ts | 3 +- docs/design/staged-rollout.md | 8 ++-- 6 files changed, 68 insertions(+), 33 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 8a24c086..3888cd86 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -29,7 +29,7 @@ Rows are in `--help` order (v5.0): the hosted/vendored workflow (`scan` → `vex **Root `--update` flag.** `socket-patch --update [VERSION]` updates the binary itself from GitHub Releases. It is a root flag, not a subcommand: argv is rewritten (the same mechanism as the bare-UUID fallback) onto an internal hidden subcommand whose name carries no stability guarantee — script the flag, never the internal name. Combining the flag with a subcommand (`socket-patch --update scan`) is a usage error (exit 2). Full contract: [Self-update contract](#self-update-contract-socket-patch---update). -**Internal `hosted-bundle` subcommand.** `socket-patch hosted-bundle` is a hidden, INTERNAL parity/debug harness for the in-memory hosted engine (`src/hosted_memory/`, the engine the Node addon embeds): it reads a JSON bundle `{"files": {path: text}, "binaryFiles"?: {path: base64}, "presentOnly"?: [path], "symlinks"?: [path], "projectRoots"?: [dir], "pipenvMajor"?: n, "batchSize"?: n}` on stdin, queries the authenticated org API built from `--api-url` / `--api-token` / `--org` only (both of the latter are required; no public-proxy fallback), and prints the engine result — or `{"status":"error","error":{"code","message"}}` with exit 1 (exit 2 for unusable input or missing credentials). It never touches the filesystem. Its name, input and output carry NO stability guarantee; do not script it. +**Internal `hosted-bundle` subcommand.** `socket-patch hosted-bundle` is a hidden, INTERNAL parity/debug harness for the in-memory hosted engine (`src/hosted_memory/`, the engine the Node addon embeds): it reads a JSON bundle `{"files": {path: text}, "binaryFiles"?: {path: base64}, "presentOnly"?: [path], "symlinks"?: [path], "projectRoots"?: [dir], "pipenvMajor"?: n, "batchSize"?: n, "noSocketYml"?: bool, "minSeverity"?: s, "policyPaths"?: [path], "policySha256"?: s}` on stdin, queries the authenticated org API built from `--api-url` / `--api-token` / `--org` only (both of the latter are required; no public-proxy fallback), and prints the engine result — or `{"status":"error","error":{"code","message"}}` with exit 1 (exit 2 for unusable input or missing credentials). It never touches the filesystem. Its name, input and output carry NO stability guarantee; do not script it. ## Global arguments @@ -213,7 +213,7 @@ patches: **Lookup.** The repo root is the nearest ancestor of `--cwd` (inclusive) holding `.git` (a directory, a file for worktrees and submodules, or a symlink to either), not walking past a `GIT_CEILING_DIRECTORIES` entry or into the home directory (unless `--cwd` is it), and, on Unix, only when `.git` belongs to the current user, to root, or to the user `sudo` ran for (`SUDO_UID`); a root process trusts every owner, since CI containers commonly run as root over a checkout owned by another uid (otherwise warning `socket_yml_repo_untrusted` and `--cwd` is the root). No `.git`: the root is `--cwd`. Only `/socket.yml` and `/socket.yaml` are read, matched by exact directory-entry name (`Socket.yml` is not read: warning `socket_yml_name_case`); nested files never are. A symlinked file is followed only to a regular file inside the repo root. `--global` / `--global-prefix` scans read no file. -**In memory (two-phase).** The host fetches the tree's root `socket.yml` / `socket.yaml` first and passes each to `selectHostedScanPaths` as `policyFiles: [{path, text} | {path, missing: true}]` (and `noSocketYml` when the session will bypass it). Selection applies the full path policy (defaults, `projectIgnorePaths`, `patches` lists, negations), so a negated default-ignored root is fetched and patched as on disk; an excluded root's markers come back in `presentOnly` so the session can report it. A listed policy file not passed, passed `missing`, symlinked or invalid makes selection return `policyError` with nothing selected. Selection returns `policyPaths` and `policySha256` (`null` without a file); the host streams the same text and passes both to the session, which fails `socket_yml_invalid` when the policy it reads differs from `policySha256`. +**In memory (two-phase).** The host fetches the tree's root `socket.yml` / `socket.yaml` first and passes each to `selectHostedScanPaths` as `policyFiles: [{path, text} | {path, missing: true}]` (and `noSocketYml` when the session will bypass it). `text` must be a lossless UTF-8 decode (Node `buffer.toString('utf8')`; `TextDecoder` drops a BOM). Selection applies the full path policy (defaults, `projectIgnorePaths`, `patches` lists, negations), so a negated default-ignored root is fetched and patched as on disk; an excluded root is not streamed and is reported in `ignoredSample` with its `policy_*` reason (the session's `policy.filtered[]` lists only roots it received). Unless `noSocketYml`, a listed policy file not passed, passed `missing`, symlinked or invalid makes selection return `policyError` with nothing selected. Selection returns `policyPaths` and `policySha256` (`null` with no file, an empty file, or `noSocketYml`); the host streams the same text and passes both to the session, with the same `noSocketYml`. The session fails `socket_yml_invalid` when the policy it reads differs from `policySha256`, including a bypassed session given a digest. **Validation (fail closed).** Because the file only narrows, a file that cannot be honored never means "no policy". Checked in order: file access (a regular file after resolving, at most 64 KiB, read from the opened handle), encoding (UTF-8; a BOM is stripped and CRLF is fine; UTF-16 and NUL bytes are errors), YAML 1.2 syntax (duplicate keys, a non-mapping top level, nesting deeper than 32 and a second document are errors), a top-level key that looks like a misspelled `patches` (equal to `patch`/`patches` ignoring case, or within two edits of it and starting `pat`/`pac`, e.g. `patchs`), a top-level merge key (`<<`) or aliased key (either could carry a `patches` block other YAML readers apply), the version gate (`patches` requires `version: 2`), then the keys. Inside `patches` and `projectIgnorePaths`, anchors, aliases, merge keys (`<<`) and custom tags are errors (aliases elsewhere are never expanded). An unknown key under `patches` is an error with a did-you-mean hint and "a newer socket-patch may support it". Wrong types are errors — no coercion (`"false"` is not a bool; YAML 1.2, so `no` is a string) — as are an unknown severity, an out-of-range `maxNewPatches`, an invalid pattern or spec, a list over 1000 entries and an entry over 1024 bytes. Every error names the file and the key path (`patches.minSeverity`). `projectIgnorePaths` is validated strictly when a `patches` block exists (a single string is coerced to a one-element list); without one, a malformed value only warns `socket_yml_ignored_value` and is ignored, and it is honored whatever the `version`. An empty or comment-only file counts as no file. When both `socket.yml` and `socket.yaml` exist, both are validated; if their `projectIgnorePaths` and `patches` are equal as parsed values `socket.yml` is used, otherwise the run fails with `socket_yml_ambiguous`. diff --git a/crates/socket-patch-cli/src/hosted_memory/mod.rs b/crates/socket-patch-cli/src/hosted_memory/mod.rs index 849d94d4..0308757f 100644 --- a/crates/socket-patch-cli/src/hosted_memory/mod.rs +++ b/crates/socket-patch-cli/src/hosted_memory/mod.rs @@ -403,7 +403,10 @@ async fn engine( PolicySource::File { path, sha256 } => Some((path.as_str(), sha256.as_str())), PolicySource::None | PolicySource::Bypassed => None, }; - if !options.policy_overrides.bypass && read.map(|(_, sha)| sha) != options.policy_sha256.as_deref() { + // Selection returns no digest when it bypassed the file, so a digest + // with a bypassed session means the two sides disagree. + let expected = if options.policy_overrides.bypass { None } else { read.map(|(_, sha)| sha) }; + if expected != options.policy_sha256.as_deref() { let error = PolicyError::Invalid { file: read.map_or(POLICY_FILE_NAMES[0], |(path, _)| path).to_string(), key: String::new(), diff --git a/crates/socket-patch-cli/src/hosted_memory/select.rs b/crates/socket-patch-cli/src/hosted_memory/select.rs index b7c8aa6e..012c22cd 100644 --- a/crates/socket-patch-cli/src/hosted_memory/select.rs +++ b/crates/socket-patch-cli/src/hosted_memory/select.rs @@ -289,24 +289,31 @@ pub fn select_paths(entries: &[TreeEntryInput], options: &SelectOptions) -> Path }; // The same root filter the session applies, so a socket.yml negation // of a built-in ignore brings that tree's files in here too. An - // excluded root's markers stay presence-only: the session sees the root - // and reports it filtered, as disk does, without its content. + // excluded root is reported here, not streamed: its markers would + // count against the session's file limit. let explicit = options.project_roots.is_some(); let mut roots: Vec = Vec::with_capacity(candidate_roots.len()); - let mut excluded_markers: Vec = Vec::new(); for root in candidate_roots { let markers = root_markers(&root, blobs.keys().map(String::as_str)); - let admitted = policy - .admits_root(&Root { - rel_dir: &root, - markers: &markers, - explicit, - }) - .is_ok(); - if admitted { - roots.push(root); - } else { - excluded_markers.extend(markers.iter().map(|m| join_root(&root, m))); + match policy.admits_root(&Root { + rel_dir: &root, + markers: &markers, + explicit, + }) { + Ok(()) => roots.push(root), + Err(reason) => { + let paths: Vec = if markers.is_empty() { + vec![root.clone()] + } else { + markers.iter().map(|m| join_root(&root, m)).collect() + }; + for path in paths { + ignored.push(IgnoredPath { + path, + reason: reason.code().to_string(), + }); + } + } } } let root_set: BTreeSet<&str> = roots.iter().map(String::as_str).collect(); @@ -369,9 +376,6 @@ pub fn select_paths(entries: &[TreeEntryInput], options: &SelectOptions) -> Path } } - for path in excluded_markers { - needs.entry(path).or_insert(Need::Present); - } // The session reads the same policy text again. for name in &policy_paths { needs.entry(name.clone()).or_insert(Need::Text); diff --git a/crates/socket-patch-cli/tests/hosted_memory_parity.rs b/crates/socket-patch-cli/tests/hosted_memory_parity.rs index 7c151db2..b297eaf7 100644 --- a/crates/socket-patch-cli/tests/hosted_memory_parity.rs +++ b/crates/socket-patch-cli/tests/hosted_memory_parity.rs @@ -853,10 +853,18 @@ async fn parity_socket_yml_filters_the_same_roots_and_packages() { ); let server = MockServer::start().await; mount_api(&server, &patches).await; - let memory = run_engine(&server, policy_input(&repo)).await; + let (selection, input) = two_phase(&repo, options(false)); + assert!(selection.policy_error.is_none(), "{:?}", selection.policy_error); + let memory = run_engine(&server, input).await; assert!(memory.policy_error.is_none(), "{:?}", memory.policy_error); let roots: Vec<&str> = memory.projects.iter().map(|p| p.root.as_str()).collect(); assert_eq!(roots, vec!["apps/web", "services/api"], "the ignored root is not processed"); + // Selection reports the root it excluded; nothing of it is streamed. + assert!(selection + .ignored_sample + .iter() + .any(|i| i.path == "apps/legacy/package-lock.json" && i.reason == "policy_path_excluded")); + assert!(!selection.fetch_text.iter().chain(&selection.present_only).any(|p| p.starts_with("apps/legacy/"))); let memory_policy = memory.policy.clone().expect("policy block"); assert_eq!(memory_policy["source"], "file"); @@ -878,7 +886,9 @@ async fn parity_socket_yml_filters_the_same_roots_and_packages() { assert!(disk.changed.is_empty(), "{root}: an ignored root changes nothing"); } } - assert_eq!(filtered_set(&memory_policy), disk_filtered); + let mut memory_filtered = filtered_set(&memory_policy); + memory_filtered.insert(("apps/legacy".to_string(), None, "policy_path_excluded".to_string())); + assert_eq!(memory_filtered, disk_filtered); assert!(disk_filtered.contains(&("apps/legacy".to_string(), None, "policy_path_excluded".to_string()))); assert!(disk_filtered.contains(&( "services/api".to_string(), @@ -944,6 +954,11 @@ async fn memory_policy_file_withheld_or_invalid_is_a_policy_error() { let err = out.policy_error.expect("policyError"); assert!(err.detail.contains("patches.apiUrl"), "{}", err.detail); assert!(out.changed_files.is_empty()); + // A bypassed session with a selection that applied the file. + let mut half = opts.clone(); + half.no_socket_yml = Some(true); + let out = run_engine(&server, build_input(&repo, &[], half)).await; + assert_eq!(out.policy_error.expect("policyError").code, "socket_yml_invalid"); // noSocketYml skips it on both sides. let mut bypass = options(false); bypass.no_socket_yml = Some(true); @@ -1005,10 +1020,13 @@ fn selection_applies_the_path_policy_and_fails_closed() { assert!(selection.fetch_text.contains(&"socket.yml".to_string())); assert_eq!(selection.roots, vec!["apps/web"]); // Excluded roots (file list and built-in ignores, any case) are - // presence-only: never fetched, still reported by the session. + // reported and never streamed. for path in ["apps/old/yarn.lock", "apps/web/tests/app/package-lock.json", "Fixtures/x/yarn.lock"] { - assert!(selection.present_only.contains(&path.to_string()), "{path}: {selection:?}"); - assert!(!selection.fetch_text.contains(&path.to_string()), "{path}"); + assert!( + selection.ignored_sample.iter().any(|i| i.path == path && i.reason == "policy_path_excluded"), + "{path}: {selection:?}" + ); + assert!(!selection.fetch_text.contains(&path.to_string()) && !selection.present_only.contains(&path.to_string()), "{path}"); } // Named roots are explicit: the built-in ignores do not apply. let named = select_paths( @@ -1070,14 +1088,21 @@ async fn memory_negation_reincludes_a_default_ignored_root() { assert_eq!(selection.roots, vec!["e2e/tests"]); assert!(selection.fetch_text.contains(&"e2e/tests/package-lock.json".to_string())); assert!(!selection.fetch_text.iter().any(|p| p.starts_with("x/")), "{selection:?}"); + assert!(selection + .ignored_sample + .iter() + .any(|i| i.path == "x/tests/package-lock.json" && i.reason == "policy_path_excluded")); let memory = run_engine(&server, input).await; let roots: Vec<&str> = memory.projects.iter().map(|p| p.root.as_str()).collect(); assert_eq!(roots, vec!["e2e/tests"]); assert!(!memory.projects[0].redirected.is_empty(), "{:#}", memory.projects[0].redirect); - let filtered = filtered_set(memory.policy.as_ref().unwrap()); - assert!(filtered.contains(&("x/tests".to_string(), None, "policy_path_excluded".to_string()))); - let entry = &memory.policy.as_ref().unwrap()["filtered"][0]; - assert_eq!(entry["detail"], "tests/ (built-in default)"); + + // Given every root anyway, the session applies the same filter itself. + let direct = run_engine(&server, build_input(&repo, &[], policy_options(&repo))).await; + let roots: Vec<&str> = direct.projects.iter().map(|p| p.root.as_str()).collect(); + assert_eq!(roots, vec!["e2e/tests"]); + let entry = &direct.policy.as_ref().unwrap()["filtered"][0]; + assert_eq!((entry["project"].as_str(), entry["detail"].as_str()), (Some("x/tests"), Some("tests/ (built-in default)"))); // Disk patches the same root the same way. let disk = run_disk_in(&server, &repo, "e2e/tests", false); diff --git a/crates/socket-patch-node/npm/index.d.ts b/crates/socket-patch-node/npm/index.d.ts index 4a1a1890..c70f28fa 100644 --- a/crates/socket-patch-node/npm/index.d.ts +++ b/crates/socket-patch-node/npm/index.d.ts @@ -10,10 +10,11 @@ export interface PathSelection { ignoredCount: number ignoredSample: { path: string; reason: string }[] // ≤100 policyPaths: string[] // root socket.yml / socket.yaml the tree lists (also in fetchText or symlinks); pass back as the session's policyPaths - policySha256: string | null // the policy file selection applied; pass back as the session's policySha256 + policySha256: string | null // the policy file selection applied (null: none, empty, or noSocketYml); pass back as the session's policySha256 policyError?: { code: 'socket_yml_invalid' | 'socket_yml_ambiguous'; detail: string } // nothing selected } // Fetch every root socket.yml / socket.yaml the listing holds first and pass it in policyFiles: selection applies the full socket.yml path policy. +// text must decode losslessly (buffer.toString('utf8'); TextDecoder drops a BOM and the session then sees different content). Excluded roots are reported in ignoredSample, not streamed. export type PolicyFileInput = { path: string; text: string } | { path: string; missing: true } export function selectHostedScanPaths(entries: TreeEntryInput[], options?: { projectRoots?: string[]; ecosystems?: Ecosystem[]; policyFiles?: PolicyFileInput[]; noSocketYml?: boolean }): PathSelection export function hostedScanCandidateFiles(): string[] // debug listing only diff --git a/docs/design/staged-rollout.md b/docs/design/staged-rollout.md index fff254a9..5a9c32ca 100644 --- a/docs/design/staged-rollout.md +++ b/docs/design/staged-rollout.md @@ -1069,9 +1069,11 @@ Gaps and contradictions A resolved with the smallest reasonable decision option, since 7.2 does not say how the session learns what the selector saw; a session that reads a policy file without it fails closed. Selection also takes `noSocketYml` so both sides bypass - together. A root the selector excludes keeps its markers in - `presentOnly` (no content), so the session still lists it under - `policy.filtered[]` as disk does. + together; a bypassed session given a digest fails closed. A root the + selector excludes is reported in its `ignoredSample` with the + `policy_*` reason and never streamed (streaming its markers would count + every fixture lockfile against `maxFiles`), so in memory + `policy.filtered[]` lists only the roots the session received. 10. **Env layer of `--min-severity`** is read by scan, not clap, so the `policy` block can say `source: "env"`; a malformed env value exits 2 at run time, a malformed flag at parse time. From f680990366bafdb42f846eef0ad68d6a0f621333 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 19:36:35 +0000 Subject: [PATCH 20/22] Keep scan -h short with the policy flags The v5 help rules cap each command's short help at about eight options. `--no-socket-yml` and `--min-severity` pushed `scan -h` to ten, so they now appear only in `scan --help`, like the other advanced scan flags. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01BKsyzefGhAnPkYmXCwq3H3 --- crates/socket-patch-cli/src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/socket-patch-cli/src/lib.rs b/crates/socket-patch-cli/src/lib.rs index 7bc0e4d1..e0bd6136 100644 --- a/crates/socket-patch-cli/src/lib.rs +++ b/crates/socket-patch-cli/src/lib.rs @@ -177,6 +177,8 @@ fn short_help_hidden_own(sub: &str) -> &'static [&'static str] { "vex_no_verify", "vex_doc_id", "vex_compact", + "no_socket_yml", + "min_severity", ], "get" => &["id", "cve", "ghsa", "package", "save_only", "one_off", "all_releases"], "vex" => &["doc_id", "compact"], From 0da9e4b10f17165a8f34617a0367fecf69e25b2f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 19:38:04 +0000 Subject: [PATCH 21/22] v5: keep scan -h within the option budget The staged-rollout and socket.yml flags pushed scan's short help past nine options once #279 trimmed -h. --max-new-patches and --no-socket-yml now show only in --help and parse as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/socket-patch-cli/src/lib.rs b/crates/socket-patch-cli/src/lib.rs index 7bc0e4d1..fd257f40 100644 --- a/crates/socket-patch-cli/src/lib.rs +++ b/crates/socket-patch-cli/src/lib.rs @@ -177,6 +177,8 @@ fn short_help_hidden_own(sub: &str) -> &'static [&'static str] { "vex_no_verify", "vex_doc_id", "vex_compact", + "max_new_patches", + "no_socket_yml", ], "get" => &["id", "cve", "ghsa", "package", "save_only", "one_off", "all_releases"], "vex" => &["doc_id", "compact"], From 65ef71fd4f454aacc0c1f0aed2c3117e79ed624c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 20:49:30 +0000 Subject: [PATCH 22/22] Fail report-only JSON when detail queries fail A report-only `scan --json` (`--prune` with no mode) with a severity floor or `enabled: false` fetches patch details to fill `policy.filtered[]`. If every query failed it still printed a success envelope and exited 0. It now reports the error and exits 1, like the agent and vendored runs. Human-mode policy warnings now print `Warning: ` like the rest of `scan`; the code stays in the JSON `warnings[]` entry. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01BKsyzefGhAnPkYmXCwq3H3 --- .../socket-patch-cli/src/commands/scan/mod.rs | 8 +++++-- .../src/commands/scan/policy.rs | 2 +- .../tests/e2e_socket_yml_policy.rs | 24 +++++++++++++++++++ 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 14b5d367..9d1fad1c 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -2057,7 +2057,7 @@ async fn run_scan( // `enabled: false` still hides candidates; report them like the // human arm does (the detail fetch runs only then). if !apply && !vendor && policy.reports_selection() && !all_packages_with_patches.is_empty() { - let _ = discover_selected( + if let Err((code, message)) = discover_selected( &api_client, &all_packages_with_patches, can_access_paid_patches, @@ -2067,7 +2067,11 @@ async fn run_scan( telemetry, Some(&mut result), ) - .await; + .await + { + emit_discovery_error_json(&mut result, &message); + return code; + } } // --- Apply path (if requested) ----------------------------------- diff --git a/crates/socket-patch-cli/src/commands/scan/policy.rs b/crates/socket-patch-cli/src/commands/scan/policy.rs index 79385fb5..e319661e 100644 --- a/crates/socket-patch-cli/src/commands/scan/policy.rs +++ b/crates/socket-patch-cli/src/commands/scan/policy.rs @@ -480,7 +480,7 @@ impl ScanPolicy { return; } for w in &self.warnings { - eprintln!("Warning ({}): {}", w.code, w.detail); + eprintln!("Warning: {}", w.detail); } } diff --git a/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs b/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs index 12e9fd1b..cc354014 100644 --- a/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs +++ b/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs @@ -684,6 +684,30 @@ async fn enabled_false_reports_and_writes_nothing() { assert_eq!(doc["redirect"]["redirected"], 0); } +#[tokio::test] +#[serial] +async fn report_only_json_fails_when_every_detail_query_fails() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path_regex(format!("^/v0/orgs/{ORG}/patches/by-package/.+$"))) + .respond_with(ResponseTemplate::new(500)) + .with_priority(1) + .mount(&server) + .await; + mount_api(&server, catalog()).await; + // `--prune` with no mode is the report-only arm. + let repo = Repo::new(Some("version: 2\npatches:\n minSeverity: critical\n")); + let before = repo.snapshot(); + let (code, doc) = scan_json(&repo.dir("services/web"), &server.uri(), &["--prune"], &[]); + assert_eq!(code, 1, "{doc:#}"); + assert_eq!(doc["status"], "error", "{doc:#}"); + assert!( + doc["error"].as_str().unwrap_or_default().contains("patch-detail queries failed"), + "{doc:#}" + ); + assert_eq!(repo.snapshot(), before); +} + #[tokio::test] #[serial] async fn recorded_merged_patch_below_a_new_floor_is_kept() {